1use crate::llm::actions::{
9 client_trait::{Client, ClientActionResult},
10 protocol_trait::Protocol,
11 ActionDefinition, Parameter, ParameterDefinition,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::server::modbus::codec::{encode_request, ModbusRequest};
16use crate::state::app_state::AppState;
17use anyhow::{anyhow, Context, Result};
18use serde_json::{json, Value};
19use std::sync::LazyLock;
20
21pub const REQUEST_RESULT: &str = "modbus_request";
23
24pub const DEFAULT_UNIT_ID: u8 = 1;
27
28fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
29 Parameter {
30 name: name.to_string(),
31 type_hint: type_hint.to_string(),
32 description: description.to_string(),
33 required,
34 }
35}
36
37fn function_param() -> Parameter {
38 param(
39 "function",
40 "string",
41 "read_coils, read_discrete_inputs, read_holding_registers, read_input_registers, \
42 write_single_coil, write_single_register, write_multiple_coils or \
43 write_multiple_registers",
44 true,
45 )
46}
47
48fn unit_param() -> Parameter {
49 param(
50 "unit_id",
51 "number",
52 "The unit (slave) id the request was addressed to",
53 true,
54 )
55}
56
57pub static MODBUS_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
58 EventType::new(
59 "modbus_connected",
60 "Connected to a Modbus/TCP device",
61 json!({"type": "modbus_read_holding_registers", "address": 0, "quantity": 2}),
62 )
63 .with_parameters(vec![
64 param(
65 "remote_addr",
66 "string",
67 "The device this client is connected to",
68 true,
69 ),
70 param(
71 "unit_id",
72 "number",
73 "The unit id requests use unless an action names another",
74 true,
75 ),
76 ])
77});
78
79pub static MODBUS_READ_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
80 EventType::new(
81 "modbus_read_response",
82 "The device answered a read with the values requested",
83 json!({"type": "modbus_write_single_register", "address": 10, "value": 1}),
84 )
85 .with_parameters(vec![
86 function_param(),
87 unit_param(),
88 param(
89 "address",
90 "number",
91 "First address read (protocol address, 0-based)",
92 true,
93 ),
94 param("quantity", "number", "How many items were read", true),
95 param(
96 "values",
97 "array",
98 "One entry per item from address upward: booleans for coils and discrete inputs, \
99 integers 0-65535 for registers",
100 true,
101 ),
102 ])
103});
104
105pub static MODBUS_WRITE_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
106 EventType::new(
107 "modbus_write_response",
108 "The device acknowledged a write (its echo matched what was written)",
109 json!({"type": "modbus_read_holding_registers", "address": 0, "quantity": 1}),
110 )
111 .with_parameters(vec![
112 function_param(),
113 unit_param(),
114 param("address", "number", "First address written", true),
115 param("quantity", "number", "How many items were written", true),
116 param(
117 "values",
118 "array",
119 "What was written, from address upward",
120 true,
121 ),
122 ])
123});
124
125pub static MODBUS_EXCEPTION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
126 EventType::new(
127 "modbus_exception",
128 "The device refused a request with a Modbus exception",
129 json!({"type": "modbus_read_holding_registers", "address": 0, "quantity": 1}),
130 )
131 .with_parameters(vec![
132 function_param(),
133 unit_param(),
134 param(
135 "address",
136 "number",
137 "First address of the refused request",
138 true,
139 ),
140 param(
141 "code",
142 "number",
143 "The exception code (1-6, 8, 10, 11)",
144 true,
145 ),
146 param(
147 "name",
148 "string",
149 "The exception's name, e.g. illegal_data_address or illegal_function",
150 true,
151 ),
152 ])
153});
154
155pub static MODBUS_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
156 EventType::new(
157 "modbus_error",
158 "A request got no usable answer: the device did not reply in time, or replied with a \
159 frame that does not answer the request",
160 json!({"type": "modbus_read_holding_registers", "address": 0, "quantity": 1}),
161 )
162 .with_parameters(vec![
163 param(
164 "kind",
165 "string",
166 "timeout (no reply in time), bad_response (a reply that does not match the \
167 request), or unit_mismatch (a reply from a different unit id)",
168 true,
169 ),
170 param("message", "string", "What went wrong", true),
171 function_param(),
172 param("address", "number", "First address of the request", true),
173 ])
174});
175
176pub struct ModbusClientProtocol;
178
179impl ModbusClientProtocol {
180 pub fn new() -> Self {
181 Self
182 }
183}
184
185impl Default for ModbusClientProtocol {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191fn unit_id_param() -> Parameter {
192 param(
193 "unit_id",
194 "number",
195 "Unit (slave) id 0-255; defaults to the client's unit_id startup parameter",
196 false,
197 )
198}
199
200fn read_action(name: &str, what: &str, max: u16) -> ActionDefinition {
201 ActionDefinition {
202 name: name.to_string(),
203 description: format!("Read {what}"),
204 parameters: vec![
205 param(
206 "address",
207 "number",
208 "First address, 0-based protocol address",
209 true,
210 ),
211 param(
212 "quantity",
213 "number",
214 &format!("How many to read, 1-{max}"),
215 true,
216 ),
217 unit_id_param(),
218 ],
219 example: json!({"type": name, "address": 0, "quantity": 2}),
220 log_template: Some(LogTemplate::new().with_info(format!(
221 "-> {name} {{address}}+{{quantity}} unit={{unit_id}}"
222 ))),
223 }
224}
225
226fn all_actions() -> Vec<ActionDefinition> {
227 vec![
228 read_action("modbus_read_coils", "coils (FC 1)", 2000),
229 read_action(
230 "modbus_read_discrete_inputs",
231 "discrete inputs (FC 2)",
232 2000,
233 ),
234 read_action(
235 "modbus_read_holding_registers",
236 "holding registers (FC 3)",
237 125,
238 ),
239 read_action("modbus_read_input_registers", "input registers (FC 4)", 125),
240 ActionDefinition {
241 name: "modbus_write_single_coil".to_string(),
242 description: "Turn one coil on or off (FC 5)".to_string(),
243 parameters: vec![
244 param("address", "number", "The coil's address", true),
245 param("value", "boolean", "true = on, false = off", true),
246 unit_id_param(),
247 ],
248 example: json!({"type": "modbus_write_single_coil", "address": 3, "value": true}),
249 log_template: Some(
250 LogTemplate::new()
251 .with_info("-> modbus_write_single_coil {address}={value} unit={unit_id}"),
252 ),
253 },
254 ActionDefinition {
255 name: "modbus_write_single_register".to_string(),
256 description: "Write one holding register (FC 6)".to_string(),
257 parameters: vec![
258 param("address", "number", "The register's address", true),
259 param(
260 "value",
261 "number",
262 "The register's new value, an integer 0-65535",
263 true,
264 ),
265 unit_id_param(),
266 ],
267 example: json!({"type": "modbus_write_single_register", "address": 5, "value": 1234}),
268 log_template: Some(
269 LogTemplate::new()
270 .with_info("-> modbus_write_single_register {address}={value} unit={unit_id}"),
271 ),
272 },
273 ActionDefinition {
274 name: "modbus_write_multiple_coils".to_string(),
275 description: "Write consecutive coils (FC 15)".to_string(),
276 parameters: vec![
277 param("address", "number", "First coil's address", true),
278 param(
279 "values",
280 "array",
281 "1-1968 booleans, from address upward",
282 true,
283 ),
284 unit_id_param(),
285 ],
286 example: json!({
287 "type": "modbus_write_multiple_coils",
288 "address": 0,
289 "values": [true, false, true]
290 }),
291 log_template: Some(LogTemplate::new().with_info(
292 "-> modbus_write_multiple_coils {address} {json(values)} unit={unit_id}",
293 )),
294 },
295 ActionDefinition {
296 name: "modbus_write_multiple_registers".to_string(),
297 description: "Write consecutive holding registers (FC 16)".to_string(),
298 parameters: vec![
299 param("address", "number", "First register's address", true),
300 param(
301 "values",
302 "array",
303 "1-123 integers 0-65535, from address upward",
304 true,
305 ),
306 unit_id_param(),
307 ],
308 example: json!({
309 "type": "modbus_write_multiple_registers",
310 "address": 10,
311 "values": [1, 2, 3]
312 }),
313 log_template: Some(LogTemplate::new().with_info(
314 "-> modbus_write_multiple_registers {address} {json(values)} unit={unit_id}",
315 )),
316 },
317 ActionDefinition {
318 name: "disconnect".to_string(),
319 description: "Close the connection to the Modbus device".to_string(),
320 parameters: vec![],
321 example: json!({"type": "disconnect"}),
322 log_template: Some(LogTemplate::new().with_info("-> modbus disconnect")),
323 },
324 ]
325}
326
327fn u16_field(action: &Value, name: &str) -> Result<u16> {
328 let v = action
329 .get(name)
330 .with_context(|| format!("missing number field '{name}'"))?;
331 let n = v
332 .as_u64()
333 .with_context(|| format!("'{name}' must be a non-negative integer, got {v}"))?;
334 u16::try_from(n).map_err(|_| anyhow!("'{name}' is {n}; Modbus fields are 0-65535"))
335}
336
337pub struct ClientRequest {
339 pub unit_id: Option<u8>,
340 pub request: ModbusRequest,
341}
342
343pub fn request_from_action(action: &Value) -> Result<Option<ClientRequest>> {
349 let action_type = action
350 .get("type")
351 .and_then(Value::as_str)
352 .context("missing 'type'")?;
353 let unit_id = match action.get("unit_id") {
354 None | Some(Value::Null) => None,
355 Some(v) => {
356 let n = v
357 .as_u64()
358 .with_context(|| format!("'unit_id' must be an integer 0-255, got {v}"))?;
359 Some(u8::try_from(n).map_err(|_| anyhow!("unit_id {n} is outside 0-255"))?)
360 }
361 };
362 let request = match action_type {
363 "modbus_read_coils"
364 | "modbus_read_discrete_inputs"
365 | "modbus_read_holding_registers"
366 | "modbus_read_input_registers" => {
367 let start = u16_field(action, "address")?;
368 let quantity = u16_field(action, "quantity")?;
369 match action_type {
370 "modbus_read_coils" => ModbusRequest::ReadCoils { start, quantity },
371 "modbus_read_discrete_inputs" => {
372 ModbusRequest::ReadDiscreteInputs { start, quantity }
373 }
374 "modbus_read_holding_registers" => {
375 ModbusRequest::ReadHoldingRegisters { start, quantity }
376 }
377 _ => ModbusRequest::ReadInputRegisters { start, quantity },
378 }
379 }
380 "modbus_write_single_coil" => ModbusRequest::WriteSingleCoil {
381 address: u16_field(action, "address")?,
382 value: action
383 .get("value")
384 .and_then(Value::as_bool)
385 .context("'value' must be true or false")?,
386 },
387 "modbus_write_single_register" => ModbusRequest::WriteSingleRegister {
388 address: u16_field(action, "address")?,
389 value: u16_field(action, "value")?,
390 },
391 "modbus_write_multiple_coils" => ModbusRequest::WriteMultipleCoils {
392 start: u16_field(action, "address")?,
393 values: action
394 .get("values")
395 .and_then(Value::as_array)
396 .context("'values' must be an array of booleans")?
397 .iter()
398 .map(|v| {
399 v.as_bool()
400 .with_context(|| format!("every coil value must be a boolean, got {v}"))
401 })
402 .collect::<Result<_>>()?,
403 },
404 "modbus_write_multiple_registers" => ModbusRequest::WriteMultipleRegisters {
405 start: u16_field(action, "address")?,
406 values: action
407 .get("values")
408 .and_then(Value::as_array)
409 .context("'values' must be an array of integers")?
410 .iter()
411 .map(|v| {
412 let n = v
413 .as_u64()
414 .with_context(|| format!("register values are integers, got {v}"))?;
415 u16::try_from(n).map_err(|_| anyhow!("register value {n} is outside 0-65535"))
416 })
417 .collect::<Result<_>>()?,
418 },
419 "disconnect" => return Ok(None),
420 other => return Err(anyhow!("Unknown Modbus client action: {other}")),
421 };
422 encode_request(&request).map_err(|e| anyhow!(e))?;
423 Ok(Some(ClientRequest { unit_id, request }))
424}
425
426impl Protocol for ModbusClientProtocol {
427 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
428 vec![ParameterDefinition {
429 name: "unit_id".to_string(),
430 type_hint: "number".to_string(),
431 description: "Unit (slave) id 0-255 requests are addressed to unless an action \
432 names another (default 1)"
433 .to_string(),
434 required: false,
435 example: json!(1),
436 default: Some(json!(DEFAULT_UNIT_ID)),
437 }]
438 }
439
440 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
441 all_actions()
442 }
443
444 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
445 all_actions()
446 }
447
448 fn protocol_name(&self) -> &'static str {
449 "Modbus"
450 }
451
452 fn get_event_types(&self) -> Vec<EventType> {
453 vec![
454 MODBUS_CONNECTED_EVENT.clone(),
455 MODBUS_READ_RESPONSE_EVENT.clone(),
456 MODBUS_WRITE_RESPONSE_EVENT.clone(),
457 MODBUS_EXCEPTION_EVENT.clone(),
458 MODBUS_ERROR_EVENT.clone(),
459 ]
460 }
461
462 fn stack_name(&self) -> &'static str {
463 "ETH>IP>TCP>Modbus"
464 }
465
466 fn keywords(&self) -> Vec<&'static str> {
467 vec!["modbus", "modbus client", "modbus master", "plc", "scada"]
468 }
469
470 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
471 use crate::protocol::metadata::{
472 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
473 };
474
475 ProtocolMetadataV2::builder()
476 .state(DevelopmentState::Beta)
477 .privilege_requirement(PrivilegeRequirement::None)
479 .implementation(
480 "Modbus/TCP master on tokio, framing with the server's own codec \
481 (src/server/modbus/codec.rs: encode_request, parse_response, try_parse_adu) \
482 rather than a second copy of it, and not tokio-modbus. One transport task owns \
483 the socket, matches responses to requests by transaction id and times out \
484 unanswered requests; the model is asked from a separate turn task.",
485 )
486 .llm_control(
487 "Which coils, discrete inputs and registers to read (FC 1-4) and what to write \
488 (FC 5, 6, 15, 16), on which unit id. Every response arrives as a structured \
489 event with values, an exception name, or an error kind.",
490 )
491 .e2e_testing(
492 "tests/client/modbus/real_server_test.rs, 7 LLM calls, against a pymodbus 3.15 \
493 device (Python: its own framer and datastore) read back with mbpoll (C, \
494 libmodbus). The model reads input registers (FC 4), writes values computed \
495 from them to holding registers (FC 16), turns a coil on (FC 5), and in one \
496 turn reads discrete inputs (FC 2) and a register that does not exist (FC 3), \
497 which pymodbus refuses with exception 2; mbpoll reads back the registers and \
498 the coil. A second test injects FC 6 and FC 15 through the command channel and \
499 mbpoll reads them back. Not #[ignore]d; a missing python3, pymodbus or mbpoll \
500 fails the test. unanswered_test.rs covers a wrong response, a timeout and the \
501 request queue bound; codec_test.rs the shared codec's client half.",
502 )
503 .notes(
504 "TCP only (no RTU/ASCII). Eight function codes. Each response is checked \
505 against its request: function code, byte count, and a write's echo. \
506 A request unanswered after 5s is reported as modbus_error kind timeout.",
507 )
508 .build()
509 }
510
511 fn description(&self) -> &'static str {
512 "Modbus/TCP master for reading and writing coils and registers on a device"
513 }
514
515 fn example_prompt(&self) -> &'static str {
516 "Connect to the Modbus device at 192.168.1.50:502 and read holding registers 0-9"
517 }
518
519 fn group_name(&self) -> &'static str {
520 "Industrial"
521 }
522
523 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
524 use crate::llm::actions::StartupExamples;
525
526 StartupExamples::new(
527 json!({
528 "type": "open_client",
529 "remote_addr": "localhost:5020",
530 "base_stack": "modbus",
531 "instruction": "Read holding registers 0-3 and report the values",
532 "startup_params": {"unit_id": 1}
533 }),
534 json!({
535 "type": "open_client",
536 "remote_addr": "localhost:5020",
537 "base_stack": "modbus",
538 "event_handlers": [{
539 "event_pattern": "modbus_read_response",
540 "handler": {
541 "type": "script",
542 "language": "python",
543 "code": "<modbus_client_handler>"
544 }
545 }]
546 }),
547 json!({
548 "type": "open_client",
549 "remote_addr": "localhost:5020",
550 "base_stack": "modbus",
551 "event_handlers": [
552 {
553 "event_pattern": "modbus_connected",
554 "handler": {
555 "type": "static",
556 "actions": [{
557 "type": "modbus_read_holding_registers",
558 "address": 0,
559 "quantity": 4
560 }]
561 }
562 },
563 {
564 "event_pattern": "modbus_read_response",
565 "handler": {"type": "static", "actions": [{"type": "disconnect"}]}
566 }
567 ]
568 }),
569 )
570 }
571}
572
573impl Client for ModbusClientProtocol {
574 fn connect(
575 &self,
576 ctx: crate::protocol::ConnectContext,
577 ) -> std::pin::Pin<
578 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
579 > {
580 Box::pin(async move {
581 let unit_id = match ctx.startup_params.as_ref() {
582 Some(p) => match p.get_optional_u64("unit_id")? {
583 None => DEFAULT_UNIT_ID,
584 Some(n) => {
585 u8::try_from(n).map_err(|_| anyhow!("unit_id {n} is outside 0-255"))?
586 }
587 },
588 None => DEFAULT_UNIT_ID,
589 };
590 crate::client::modbus::ModbusClient::connect_with_llm_actions(
591 ctx.remote_addr,
592 unit_id,
593 ctx.llm_client,
594 ctx.state,
595 ctx.status_tx,
596 ctx.client_id,
597 )
598 .await
599 })
600 }
601
602 fn execute_action(&self, action: Value) -> Result<ClientActionResult> {
603 match request_from_action(&action)? {
604 None => Ok(ClientActionResult::Disconnect),
605 Some(_) => Ok(ClientActionResult::Custom {
606 name: REQUEST_RESULT.to_string(),
607 data: action,
608 }),
609 }
610 }
611}