Skip to main content

netget/client/dicom/
actions.rs

1use crate::llm::actions::{
2    client_trait::{Client, ClientActionResult},
3    protocol_trait::Protocol,
4    ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::dicom::actions::{action, parameter};
8use anyhow::{bail, ensure, Context, Result};
9use serde_json::{json, Value};
10use std::sync::LazyLock;
11
12#[derive(Default)]
13pub struct DicomClientProtocol;
14impl DicomClientProtocol {
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20fn echo() -> ActionDefinition {
21    action(
22        "dicom_echo",
23        "Verify the association with C-ECHO",
24        vec![],
25        json!({"type":"dicom_echo"}),
26    )
27}
28fn store() -> ActionDefinition {
29    action(
30        "dicom_store",
31        "Send an instance with C-STORE. Rust encodes the DICOM JSON dataset in the negotiated transfer syntax; binary values may be given as {\"vr\": \"OB\", \"hex\": \"...\"} (64 KiB at most).",
32        vec![
33            parameter("sop_class_uid", "string", "Storage SOP Class UID, e.g. 1.2.840.10008.5.1.4.1.1.7 (Secondary Capture); it must have been proposed (see storage_classes)", true),
34            parameter("sop_instance_uid", "string", "The instance's UID (also written as SOPInstanceUID)", true),
35            parameter("dataset", "object", "DICOM JSON attributes, e.g. {\"00100010\": {\"vr\": \"PN\", \"Value\": [{\"Alphabetic\": \"Doe^Jane\"}]}}", true),
36        ],
37        json!({"type":"dicom_store","sop_class_uid":"1.2.840.10008.5.1.4.1.1.7","sop_instance_uid":"1.2.826.0.1.3680043.10.1408.99","dataset":{"00100010":{"vr":"PN","Value":[{"Alphabetic":"Doe^Jane"}]},"00100020":{"vr":"LO","Value":["P001"]}}}),
38    )
39}
40fn find() -> ActionDefinition {
41    action(
42        "dicom_find",
43        "Query with C-FIND; every pending response is collected into one result",
44        vec![
45            parameter("model", "string", "study_root (default) or patient_root", false),
46            parameter("level", "string", "PATIENT, STUDY, SERIES or IMAGE", true),
47            parameter("identifier", "object", "Matching and return keys as DICOM JSON; give an empty value ({\"vr\": \"PN\"}) to ask for an attribute", true),
48        ],
49        json!({"type":"dicom_find","level":"STUDY","identifier":{"00100010":{"vr":"PN","Value":[{"Alphabetic":"Doe*"}]},"0020000D":{"vr":"UI"},"00080020":{"vr":"DA"}}}),
50    )
51}
52fn release() -> ActionDefinition {
53    action(
54        "disconnect",
55        "Release the association (A-RELEASE) and stop this client",
56        vec![],
57        json!({"type":"disconnect"}),
58    )
59}
60fn actions() -> Vec<ActionDefinition> {
61    vec![echo(), store(), find(), release()]
62}
63
64pub static ASSOCIATED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
65    EventType::new(
66        "dicom_associated",
67        "The association was accepted",
68        echo().example.clone(),
69    )
70    .with_parameters(vec![
71        parameter("called_ae", "string", "The peer's AE title", true),
72        parameter(
73            "accepted",
74            "array",
75            "Accepted presentation contexts: [{abstract_syntax, transfer_syntax}]",
76            true,
77        ),
78        parameter(
79            "refused",
80            "array",
81            "Abstract syntaxes the peer did not accept",
82            true,
83        ),
84    ])
85    .with_actions(actions())
86});
87pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
88    EventType::new(
89        "dicom_response",
90        "The peer's answer to the last request",
91        release().example.clone(),
92    )
93    .with_parameters(vec![
94        parameter("operation", "string", "echo, store or find", true),
95        parameter(
96            "status",
97            "string",
98            "Final DIMSE status as four hex digits, e.g. 0000 (success), A700, C000",
99            true,
100        ),
101        parameter(
102            "meaning",
103            "string",
104            "success, warning, pending, cancel or failure",
105            true,
106        ),
107        parameter(
108            "matches",
109            "array",
110            "For find: each pending response's identifier as DICOM JSON",
111            false,
112        ),
113        parameter(
114            "comment",
115            "string",
116            "ErrorComment, when the peer sent one",
117            false,
118        ),
119    ])
120    .with_actions(actions())
121});
122
123impl Protocol for DicomClientProtocol {
124    fn protocol_name(&self) -> &'static str {
125        "DICOM"
126    }
127    fn stack_name(&self) -> &'static str {
128        "ETH>IP>TCP>DICOM"
129    }
130    fn keywords(&self) -> Vec<&'static str> {
131        vec!["dicom", "dimse", "scu", "pacs client"]
132    }
133    fn description(&self) -> &'static str {
134        "DICOM DIMSE service user (SCU): associate, then C-ECHO, C-STORE and C-FIND"
135    }
136    fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
137        actions()
138    }
139    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
140        vec![]
141    }
142    fn get_event_types(&self) -> Vec<EventType> {
143        vec![ASSOCIATED_EVENT.clone(), RESPONSE_EVENT.clone()]
144    }
145    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
146        let p =
147            |name: &str, kind: &str, description: &str, example: Value, default: Option<Value>| {
148                ParameterDefinition {
149                    name: name.into(),
150                    type_hint: kind.into(),
151                    description: description.into(),
152                    required: false,
153                    example,
154                    default,
155                }
156            };
157        vec![
158            p(
159                "called_ae",
160                "string",
161                "The peer's AE title (1 to 16 characters)",
162                json!("PACS"),
163                Some(json!(super::DEFAULT_CALLED)),
164            ),
165            p(
166                "calling_ae",
167                "string",
168                "This client's AE title",
169                json!("MODALITY"),
170                Some(json!(super::DEFAULT_CALLING)),
171            ),
172            p(
173                "storage_classes",
174                "array",
175                "Storage SOP Class UIDs to propose (CT, MR and Secondary Capture by default)",
176                json!(["1.2.840.10008.5.1.4.1.1.2"]),
177                Some(json!(super::DEFAULT_STORAGE)),
178            ),
179        ]
180    }
181    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
182        use crate::protocol::metadata::*;
183        ProtocolMetadataV2::builder()
184            .state(DevelopmentState::Experimental)
185            .privilege_requirement(PrivilegeRequirement::None)
186            .implementation("Shared PS3.8 upper layer and dataset codec; proposes Verification, Study and Patient Root C-FIND and storage classes with Explicit then Implicit VR Little Endian")
187            .llm_control("Which instances to send and which queries to run, and what to do with each answer")
188            .e2e_testing("tests/client/dicom: pynetdicom 3.0.4 (independent) as SCP answers C-ECHO, stores an instance and answers C-FIND; a wrong called AE title is rejected")
189            .notes("No C-MOVE, C-GET or C-CANCEL, no compressed transfer syntaxes. 16 MiB datasets.")
190            .max_inbound_bytes(crate::server::dicom::pdu::MAX_PDU as usize)
191            .build()
192    }
193    fn example_prompt(&self) -> &'static str {
194        "Ask the PACS at 127.0.0.1:11112 for every study of patient Doe"
195    }
196    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
197        let llm = json!({"type":"open_client","protocol":"dicom","remote_addr":"127.0.0.1:11112","instruction":"Find the studies of patient Doe","startup_params":{"called_ae":"PACS"}});
198        let mut static_example = llm.clone();
199        static_example["event_handlers"] = json!([
200            {"event_pattern":"dicom_associated","handler":{"type":"static","actions":[find().example]}},
201            {"event_pattern":"dicom_response","handler":{"type":"static","actions":[]}}
202        ]);
203        let mut scripted = static_example.clone();
204        scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
205        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
206    }
207    fn group_name(&self) -> &'static str {
208        "Application"
209    }
210}
211
212fn uid_ok(s: &str) -> bool {
213    !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_digit() || b == b'.')
214}
215
216impl Client for DicomClientProtocol {
217    fn connect(
218        &self,
219        ctx: ConnectContext,
220    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
221    {
222        Box::pin(super::connect(ctx))
223    }
224    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
225        ensure!(
226            crate::utils::json_budget::within_budget(&v, 4 * 1024 * 1024, 100_000, 24),
227            "action exceeds the DICOM bounds"
228        );
229        match v["type"].as_str() {
230            Some("dicom_echo") => {}
231            Some("dicom_store") => {
232                ensure!(
233                    v["sop_class_uid"].as_str().is_some_and(uid_ok)
234                        && v["sop_instance_uid"].as_str().is_some_and(uid_ok),
235                    "sop_class_uid and sop_instance_uid are UIDs"
236                );
237                let ds = v["dataset"]
238                    .as_object()
239                    .context("dataset is a DICOM JSON object")?;
240                crate::server::dicom::dataset::encode(
241                    ds,
242                    crate::server::dicom::dataset::EXPLICIT_LE,
243                )?;
244            }
245            Some("dicom_find") => {
246                ensure!(
247                    matches!(
248                        v["level"].as_str(),
249                        Some("PATIENT" | "STUDY" | "SERIES" | "IMAGE")
250                    ),
251                    "level is PATIENT, STUDY, SERIES or IMAGE"
252                );
253                if let Some(m) = v.get("model").filter(|m| !m.is_null()) {
254                    ensure!(
255                        matches!(m.as_str(), Some("study_root" | "patient_root")),
256                        "model is study_root or patient_root"
257                    );
258                }
259                let ds = v["identifier"]
260                    .as_object()
261                    .context("identifier is a DICOM JSON object")?;
262                crate::server::dicom::dataset::encode(
263                    ds,
264                    crate::server::dicom::dataset::EXPLICIT_LE,
265                )?;
266            }
267            Some("disconnect") => return Ok(ClientActionResult::Disconnect),
268            _ => bail!("Unknown DICOM client action"),
269        }
270        Ok(ClientActionResult::Custom {
271            name: v["type"].as_str().unwrap_or_default().into(),
272            data: v,
273        })
274    }
275}