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}