1use crate::llm::actions::{
13 protocol_trait::{ActionResult, Protocol, Server},
14 ActionDefinition, Parameter,
15};
16use crate::protocol::log_template::LogTemplate;
17use crate::protocol::EventType;
18use crate::state::app_state::AppState;
19use anyhow::{Context, Result};
20use serde_json::json;
21use std::sync::LazyLock;
22
23use super::codec;
24
25pub const RESULT_BITS: &str = "modbus_bits";
27pub const RESULT_REGISTERS: &str = "modbus_registers";
28pub const RESULT_WRITE_ACK: &str = "modbus_write_ack";
29pub const RESULT_EXCEPTION: &str = "modbus_exception";
30
31#[derive(Default)]
33pub struct ModbusProtocol;
34
35impl ModbusProtocol {
36 pub fn new() -> Self {
37 Self
38 }
39}
40
41impl Protocol for ModbusProtocol {
42 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
43 vec![
44 crate::llm::actions::ParameterDefinition {
45 name: "unit_id".to_string(),
46 type_hint: "integer".to_string(),
47 description:
48 "Modbus unit (slave) identifier this device answers for, 0-255. When set, a \
49 request addressed to any other unit id is answered with exception 0x0B \
50 (gateway target device failed to respond), which is how a real Modbus/TCP \
51 gateway behaves. When omitted the server answers on every unit id, which is \
52 what most Modbus/TCP devices do."
53 .to_string(),
54 required: false,
55 example: json!(1),
56 default: None,
57 },
58 crate::llm::actions::ParameterDefinition {
59 name: "first_byte_timeout_secs".to_string(),
60 type_hint: "integer".to_string(),
61 description: "Seconds a newly connected peer may send nothing before the server \
62 closes it, silently. Default 30: a Modbus client sends its first \
63 request as soon as it connects."
64 .to_string(),
65 required: false,
66 example: json!(30),
67 default: Some(serde_json::json!(super::FIRST_BYTE_READ_TIMEOUT.as_secs())),
68 },
69 crate::llm::actions::ParameterDefinition {
70 name: "idle_timeout_secs".to_string(),
71 type_hint: "integer".to_string(),
72 description: "Seconds a peer that has already sent a request may then send \
73 nothing before the server closes it. Default 600, twice the window \
74 a `manual` rule gives a human to answer; a SCADA master polls every \
75 few seconds, so lower it (real gateways use ~60) when no human is \
76 answering."
77 .to_string(),
78 required: false,
79 example: json!(600),
80 default: Some(serde_json::json!(
81 super::IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
82 )),
83 },
84 ]
85 }
86
87 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
88 vec![]
91 }
92
93 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
94 vec![
95 send_modbus_bits_action(),
96 send_modbus_registers_action(),
97 send_modbus_write_ack_action(),
98 send_modbus_exception_action(),
99 ]
100 }
101
102 fn protocol_name(&self) -> &'static str {
103 "Modbus"
104 }
105
106 fn get_event_types(&self) -> Vec<EventType> {
107 get_modbus_event_types()
108 }
109
110 fn stack_name(&self) -> &'static str {
111 "ETH>IP>TCP>MODBUS"
112 }
113
114 fn keywords(&self) -> Vec<&'static str> {
115 vec![
118 "modbus",
119 "modbus tcp",
120 "modbus/tcp",
121 "plc simulator",
122 "scada device",
123 ]
124 }
125
126 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
127 use crate::protocol::metadata::{
128 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
129 };
130
131 ProtocolMetadataV2::builder()
132 .state(DevelopmentState::Stable)
138 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(502))
142 .well_known_port(502)
143 .implementation(
144 "Hand-rolled MBAP + PDU codec (src/server/modbus/codec.rs); function codes \
145 1/2/3/4/5/6/15/16 with spec-mandated exception responses",
146 )
147 .llm_control(
148 "Coil and register values, whether a write is accepted, and which Modbus \
149 exception to raise. Framing (transaction id, unit id, byte counts, write \
150 echo) is server-side",
151 )
152 .e2e_testing(
153 "STABLE rests on all six conditions, each checked against source and by running \
154 it on 26 September 2026. (1) TWO independent clients, neither #[ignore]d and \
155 neither able to skip, each driving ALL EIGHT function codes this server \
156 implements with every answer asserted on what the client decoded: tokio-modbus \
157 0.17 (e2e_test.rs::test_modbus_reads_writes_and_exceptions_against_tokio_modbus \
158 -- the server does not link it) and mbpoll on libmodbus, a C implementation \
159 (real_client_test.rs::test_modbus_reads_writes_and_exceptions_against_mbpoll, \
160 which FAILS when mbpoll is absent). Register values and coil/discrete-input \
161 bits are asserted in order, ten bits across a byte boundary; FC 5/6/15/16 are \
162 accepted only if the values each client packed reached the model as sent, and \
163 each client validates the echo; each has an exception path decoded as an \
164 exception, not data. (2) pcap_oracle_test.rs runs Wireshark's mbtcp dissector \
165 over one session of all eight function codes and an exception, both \
166 directions. (3) fuzz/fuzz_targets/modbus_adu.rs: 226,125 runs in 61s, clean. \
167 Modbus does not nest, so the corpus seeds every declared length instead of a \
168 depth bomb (max_adu, over_max_adu, declared_longer_than_sent, each quantity \
169 limit), and the target asserts framing round-trips and that every accepted \
170 read can be answered. (4) bounds_test.rs and connection_bounds_test.rs drive \
171 every declared bound from the wire -- MAX_ADU_LEN (inbound, via the MBAP \
172 length field: 260 octets answered, 261 closed), MAX_BUFFERED (2080 queued \
173 tolerated, 2081 closed), MAX_CONNECTIONS (256 admitted, 257th closed \
174 silently, the slot returned after both kinds of close), both read deadlines, \
175 the four quantity limits and the unit_id filter -- each verified by REMOVING \
176 the bound and watching its test fail. (5) Both CLAUDE.md files were re-read \
177 against source; the false claims are corrected in them. (6) No #[ignore] and \
178 no skip gate in tests/server/modbus/. UNPROVEN, because none of it is \
179 implemented: Modbus RTU/ASCII, function codes outside 1/2/3/4/5/6/15/16, \
180 Modbus Security; and no real PLC has been pointed at it.",
181 )
182 .notes(
183 "The rating covers Modbus TCP with function codes 1/2/3/4/5/6/15/16 and nothing \
184 else; any other function code is answered with exception 0x01. A connection \
185 the server closes (framing error, queue overrun) stops its reader and returns \
186 its connection-cap slot at once, even while the peer holds its end open. A \
187 model answering with an action the event does \
188 not offer (bits for a register read) is refused by the LLM layer as an unknown \
189 action and logged decision=fail_closed_llm_error; the wire answer is exception \
190 0x04 either way. Untested: any real PLC, and pymodbus",
191 )
192 .max_inbound_bytes(crate::server::modbus::codec::MAX_ADU_LEN)
193 .build()
194 }
195
196 fn description(&self) -> &'static str {
197 "Modbus TCP server impersonating a PLC or industrial sensor"
198 }
199
200 fn example_prompt(&self) -> &'static str {
201 "Pretend to be a water treatment PLC via modbus on port 5020; holding register 0 is \
202 tank level in cm around 180, register 1 is pump speed in RPM"
203 }
204
205 fn group_name(&self) -> &'static str {
206 "Application"
207 }
208
209 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
210 use crate::llm::actions::StartupExamples;
211
212 let script = r#"import json, sys
215data = json.load(sys.stdin)
216event = data["event"]
217if data["event_type_id"] == "modbus_read_registers":
218 qty = event.get("quantity") or 1
219 start = event.get("start_address") or 0
220 actions = [{"type": "send_modbus_registers",
221 "registers": {str(a): 0 for a in range(start, start + qty)}}]
222else:
223 actions = []
224print(json.dumps({"actions": actions}))"#;
225
226 StartupExamples::new(
227 json!({
229 "type": "open_server",
230 "port": 5020,
231 "base_stack": "modbus",
232 "instruction": "Act as a water treatment PLC. Holding register 0 is the tank \
233 level in cm (drifts around 180), register 1 is pump speed in \
234 RPM. Coil 0 is the pump run command. Reject writes to \
235 registers above 9 with illegal_data_address."
236 }),
237 json!({
239 "type": "open_server",
240 "port": 5020,
241 "base_stack": "modbus",
242 "event_handlers": [{
243 "event_pattern": "modbus_read_registers",
244 "handler": {
245 "type": "script",
246 "language": "python",
247 "code": script
248 }
249 }]
250 }),
251 json!({
253 "type": "open_server",
254 "port": 5020,
255 "base_stack": "modbus",
256 "event_handlers": [
257 {
258 "event_pattern": "modbus_read_registers",
259 "handler": {
260 "type": "static",
261 "actions": [{
262 "type": "send_modbus_exception",
263 "exception_code": 2
264 }]
265 }
266 },
267 {
268 "event_pattern": "modbus_write_request",
269 "handler": {
270 "type": "static",
271 "actions": [{
272 "type": "send_modbus_exception",
273 "exception_code": 2
274 }]
275 }
276 }
277 ]
278 }),
279 )
280 }
281}
282
283impl Server for ModbusProtocol {
284 fn spawn(
285 &self,
286 ctx: crate::protocol::SpawnContext,
287 ) -> std::pin::Pin<
288 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
289 > {
290 Box::pin(async move {
291 let unit_id = match ctx.startup_params.as_ref() {
293 Some(p) => p.get_optional_u64("unit_id")?,
294 None => None,
295 };
296 let unit_id = match unit_id {
297 Some(v) if v > 255 => {
298 anyhow::bail!("unit_id must be between 0 and 255, got {v}");
299 }
300 Some(v) => Some(v as u8),
301 None => None,
302 };
303
304 let secs = |name: &str, default: std::time::Duration| -> Result<std::time::Duration> {
307 let value = match ctx.startup_params.as_ref() {
308 Some(p) => p.get_optional_u64(name)?,
309 None => None,
310 };
311 match value {
312 None => Ok(default),
313 Some(0) => anyhow::bail!("{name} must be at least 1 second, got 0"),
314 Some(n) => Ok(std::time::Duration::from_secs(n)),
315 }
316 };
317 let first_byte_timeout =
318 secs("first_byte_timeout_secs", super::FIRST_BYTE_READ_TIMEOUT)?;
319 let idle_timeout = secs("idle_timeout_secs", super::IDLE_BETWEEN_REQUESTS_TIMEOUT)?;
320
321 let listen_addr = ctx.legacy_listen_addr();
322 super::ModbusServer::spawn_with_llm_actions(
323 listen_addr,
324 ctx.llm_client,
325 ctx.state,
326 ctx.status_tx,
327 unit_id,
328 ctx.server_id,
329 first_byte_timeout,
330 idle_timeout,
331 )
332 .await
333 })
334 }
335
336 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
337 let action_type = action
338 .get("type")
339 .and_then(|v| v.as_str())
340 .context("Missing 'type' field in action")?;
341
342 match action_type {
343 "send_modbus_bits" => {
344 if let Some(map) = action.get("bits") {
345 let bits = address_map(map, "send_modbus_bits", "bits", |v| {
346 v.as_bool().map(serde_json::Value::Bool).ok_or_else(|| {
347 format!(
348 "must be true or false, got {v}. Coils and discrete inputs \
349 are single bits; use send_modbus_registers for 16-bit values."
350 )
351 })
352 })?;
353 return Ok(ActionResult::Custom {
354 name: RESULT_BITS.to_string(),
355 data: json!({ "bits": bits }),
356 });
357 }
358 let values = action.get("values").and_then(|v| v.as_array()).context(
361 "send_modbus_bits requires 'bits', an object of address -> true/false",
362 )?;
363
364 let bits: Vec<bool> = values
365 .iter()
366 .enumerate()
367 .map(|(i, v)| {
368 v.as_bool().ok_or_else(|| {
369 anyhow::anyhow!(
370 "send_modbus_bits 'values'[{i}] must be true or false, got {v}. \
371 Coils and discrete inputs are single bits; use \
372 send_modbus_registers for 16-bit values."
373 )
374 })
375 })
376 .collect::<Result<_>>()?;
377
378 if bits.is_empty() {
379 anyhow::bail!(
380 "send_modbus_bits 'values' must not be empty; return one boolean per \
381 coil requested"
382 );
383 }
384
385 Ok(ActionResult::Custom {
386 name: RESULT_BITS.to_string(),
387 data: json!({ "values": bits }),
388 })
389 }
390 "send_modbus_registers" => {
391 if let Some(map) = action.get("registers") {
392 let registers = address_map(map, "send_modbus_registers", "registers", |v| {
393 match v.as_u64() {
394 Some(n) if n <= u64::from(u16::MAX) => Ok(json!(n)),
395 _ => Err(format!(
396 "must be a whole number between 0 and 65535, got {v}. Modbus \
397 registers are unsigned 16-bit; encode signed or floating point \
398 values yourself (two's complement, or two registers for a \
399 32-bit float)."
400 )),
401 }
402 })?;
403 return Ok(ActionResult::Custom {
404 name: RESULT_REGISTERS.to_string(),
405 data: json!({ "registers": registers }),
406 });
407 }
408 let values = action.get("values").and_then(|v| v.as_array()).context(
411 "send_modbus_registers requires 'registers', an object of address -> \
412 value 0-65535",
413 )?;
414
415 let registers: Vec<u16> = values
416 .iter()
417 .enumerate()
418 .map(|(i, v)| {
419 let n = v.as_u64().ok_or_else(|| {
420 anyhow::anyhow!(
421 "send_modbus_registers 'values'[{i}] must be a whole number \
422 between 0 and 65535, got {v}. Modbus registers are unsigned \
423 16-bit; encode signed or floating point values yourself \
424 (e.g. two's complement, or two registers for a 32-bit float)."
425 )
426 })?;
427 if n > 65535 {
428 anyhow::bail!(
429 "send_modbus_registers 'values'[{i}] = {n} exceeds 65535; a \
430 Modbus register holds 16 bits"
431 );
432 }
433 Ok(n as u16)
434 })
435 .collect::<Result<_>>()?;
436
437 if registers.is_empty() {
438 anyhow::bail!(
439 "send_modbus_registers 'values' must not be empty; return one number \
440 per register requested"
441 );
442 }
443
444 Ok(ActionResult::Custom {
445 name: RESULT_REGISTERS.to_string(),
446 data: json!({ "values": registers }),
447 })
448 }
449 "send_modbus_write_ack" => Ok(ActionResult::Custom {
450 name: RESULT_WRITE_ACK.to_string(),
451 data: json!({}),
452 }),
453 "send_modbus_exception" => {
454 let raw = action
455 .get("exception_code")
456 .context("send_modbus_exception requires an 'exception_code'")?;
457
458 let code = if let Some(n) = raw.as_u64() {
459 if n > u8::MAX as u64
464 || codec::exception_name(n as u8) == codec::UNKNOWN_EXCEPTION
465 {
466 anyhow::bail!(
467 "send_modbus_exception 'exception_code' {n} is not a Modbus \
468 exception code. The specification defines 1 (illegal function), \
469 2 (illegal data address), 3 (illegal data value), 4 (server \
470 device failure), 5 (acknowledge), 6 (server device busy), \
471 8 (memory parity error), 10 (gateway path unavailable) and \
472 11 (gateway target device failed to respond)."
473 );
474 }
475 n as u8
476 } else if let Some(s) = raw.as_str() {
477 codec::exception_code_from_name(s).ok_or_else(|| {
478 anyhow::anyhow!(
479 "send_modbus_exception 'exception_code' {s:?} is not a known \
480 exception name. Use a number (1-4) or one of \
481 \"illegal_function\", \"illegal_data_address\", \
482 \"illegal_data_value\", \"server_device_failure\"."
483 )
484 })?
485 } else {
486 anyhow::bail!(
487 "send_modbus_exception 'exception_code' must be a number (1-4) or a \
488 name such as \"illegal_data_address\", got {raw}"
489 );
490 };
491
492 Ok(ActionResult::Custom {
493 name: RESULT_EXCEPTION.to_string(),
494 data: json!({
495 "exception_code": code,
496 "exception_name": codec::exception_name(code),
497 }),
498 })
499 }
500 "close_connection" => Ok(ActionResult::CloseConnection),
504 _ => Err(anyhow::anyhow!("Unknown Modbus action: {action_type}")),
505 }
506 }
507}
508
509fn address_map(
517 map: &serde_json::Value,
518 action: &str,
519 field: &str,
520 value: impl Fn(&serde_json::Value) -> std::result::Result<serde_json::Value, String>,
521) -> Result<serde_json::Map<String, serde_json::Value>> {
522 let object = map.as_object().with_context(|| {
523 format!(
524 "{action} '{field}' must be an object keyed by address, e.g. {{\"40\": ...}}, got \
525 {map}"
526 )
527 })?;
528 let mut out = serde_json::Map::new();
529 for (key, raw) in object {
530 let address: u16 = key.trim().parse().map_err(|_| {
531 anyhow::anyhow!(
532 "{action} '{field}' key {key:?} is not a Modbus address; keys are the decimal \
533 addresses 0-65535 the values belong to"
534 )
535 })?;
536 let checked = value(raw)
537 .map_err(|reason| anyhow::anyhow!("{action} '{field}'[{address}] {reason}"))?;
538 out.insert(address.to_string(), checked);
539 }
540 Ok(out)
541}
542
543fn send_modbus_bits_action() -> ActionDefinition {
548 ActionDefinition {
549 name: "send_modbus_bits".to_string(),
550 description:
551 "Answer a coil or discrete-input read (function code 1 or 2) with the bits this \
552 device has, as an object keyed by address. Include only addresses the \
553 instructions give this device. NetGet answers the read from your entries, and \
554 any requested address missing from them is answered as exception 2 (illegal data \
555 address) - so never add an entry for an address the device does not have. Where \
556 the instructions leave a value open, you decide what the device reads as - \
557 invent plausible, self-consistent state and use set_memory/append_memory to keep \
558 it consistent across requests."
559 .to_string(),
560 parameters: vec![Parameter {
561 name: "bits".to_string(),
562 type_hint: "object".to_string(),
563 description: "Address (as a string) -> true or false, one entry per coil or \
564 discrete input this device has."
565 .to_string(),
566 required: true,
567 }],
568 example: json!({
569 "type": "send_modbus_bits",
570 "bits": {"40": true, "41": false}
571 }),
572 log_template: Some(
573 LogTemplate::new()
574 .with_info("-> {output_bytes}B coils")
575 .with_debug("Modbus bits response")
576 .with_trace("Modbus bits: {json_pretty(.)}"),
577 ),
578 }
579}
580
581fn send_modbus_registers_action() -> ActionDefinition {
582 ActionDefinition {
583 name: "send_modbus_registers".to_string(),
584 description:
585 "Answer a holding-register or input-register read (function code 3 or 4) with the \
586 registers this device has, as an object keyed by register address. Include only \
587 addresses the instructions give this device. NetGet answers the read from your \
588 entries, and any requested address missing from them is answered as exception 2 \
589 (illegal data address) - so never add an entry for an address the device does \
590 not have. Values are whole numbers 0-65535. Where the instructions leave a value \
591 open, you decide what the device reads as: a tank level, a motor current, a fault \
592 word."
593 .to_string(),
594 parameters: vec![Parameter {
595 name: "registers".to_string(),
596 type_hint: "object".to_string(),
597 description: "Register address (as a string) -> value 0-65535, one entry per \
598 register this device has."
599 .to_string(),
600 required: true,
601 }],
602 example: json!({
603 "type": "send_modbus_registers",
604 "registers": {"40": 1834, "41": 1450}
605 }),
606 log_template: Some(
607 LogTemplate::new()
608 .with_info("-> {output_bytes}B registers")
609 .with_debug("Modbus register response")
610 .with_trace("Modbus registers: {json_pretty(.)}"),
611 ),
612 }
613}
614
615fn send_modbus_write_ack_action() -> ActionDefinition {
616 ActionDefinition {
617 name: "send_modbus_write_ack".to_string(),
618 description:
619 "Accept a write (function code 5, 6, 15 or 16) and confirm it. Modbus confirms a \
620 write by echoing it, which is built for you from the request - this action takes \
621 no parameters. Use it when the device would allow the write; use \
622 send_modbus_exception when it would not (a read-only address, a value outside \
623 the range the equipment accepts, an interlock)."
624 .to_string(),
625 parameters: vec![],
626 example: json!({ "type": "send_modbus_write_ack" }),
627 log_template: Some(
628 LogTemplate::new()
629 .with_info("-> write accepted")
630 .with_debug("Modbus write acknowledged"),
631 ),
632 }
633}
634
635fn send_modbus_exception_action() -> ActionDefinition {
636 ActionDefinition {
637 name: "send_modbus_exception".to_string(),
638 description: "Refuse the request with a Modbus exception response. This is the explicit \
639 refusal path and is structurally distinct from answering with values - use it \
640 whenever the device would not or could not serve the request."
641 .to_string(),
642 parameters: vec![Parameter {
643 name: "exception_code".to_string(),
644 type_hint: "integer or string".to_string(),
645 description: "1 / \"illegal_function\" - this device does not implement the function. \
646 2 / \"illegal_data_address\" - the address or range does not exist on this \
647 device (the most common refusal). \
648 3 / \"illegal_data_value\" - the value is outside what the equipment \
649 accepts. \
650 4 / \"server_device_failure\" - an unrecoverable fault occurred while \
651 handling the request. \
652 6 / \"server_device_busy\" - the device is busy; the client should retry."
653 .to_string(),
654 required: true,
655 }],
656 example: json!({
657 "type": "send_modbus_exception",
658 "exception_code": 2
659 }),
660 log_template: Some(
661 LogTemplate::new()
662 .with_info("-> exception {exception_code}")
663 .with_debug("Modbus exception {exception_code}"),
664 ),
665 }
666}
667
668pub static SEND_MODBUS_BITS_ACTION: LazyLock<ActionDefinition> =
669 LazyLock::new(send_modbus_bits_action);
670pub static SEND_MODBUS_REGISTERS_ACTION: LazyLock<ActionDefinition> =
671 LazyLock::new(send_modbus_registers_action);
672pub static SEND_MODBUS_WRITE_ACK_ACTION: LazyLock<ActionDefinition> =
673 LazyLock::new(send_modbus_write_ack_action);
674pub static SEND_MODBUS_EXCEPTION_ACTION: LazyLock<ActionDefinition> =
675 LazyLock::new(send_modbus_exception_action);
676
677fn common_event_parameters() -> Vec<Parameter> {
683 vec![
684 Parameter {
685 name: "unit_id".to_string(),
686 type_hint: "integer".to_string(),
687 description: "Modbus unit (slave) identifier the request was addressed to".to_string(),
688 required: true,
689 },
690 Parameter {
691 name: "function_code".to_string(),
692 type_hint: "integer".to_string(),
693 description: "Numeric Modbus function code of the request".to_string(),
694 required: true,
695 },
696 Parameter {
697 name: "function".to_string(),
698 type_hint: "string".to_string(),
699 description: "Name of the function, e.g. read_holding_registers".to_string(),
700 required: true,
701 },
702 Parameter {
703 name: "start_address".to_string(),
704 type_hint: "integer".to_string(),
705 description:
706 "First address requested, 0-65535. This is the raw protocol address: a client \
707 asking for '40001' in the classic data-model numbering sends 0 here."
708 .to_string(),
709 required: true,
710 },
711 Parameter {
712 name: "quantity".to_string(),
713 type_hint: "integer".to_string(),
714 description: "How many coils or registers the request covers".to_string(),
715 required: true,
716 },
717 Parameter {
718 name: "answer_with".to_string(),
719 type_hint: "string".to_string(),
720 description: "Which answer this request takes, naming the addresses it covers"
721 .to_string(),
722 required: true,
723 },
724 ]
725}
726
727pub fn answer_with_for_request(request: &codec::ModbusRequest) -> String {
743 use codec::ModbusRequest as R;
744 let start = u32::from(request.start_address());
745 let quantity = u32::from(request.quantity());
746 let (singular, plural) = match request {
747 R::ReadCoils { .. } | R::WriteSingleCoil { .. } | R::WriteMultipleCoils { .. } => {
748 ("coil", "coils")
749 }
750 R::ReadDiscreteInputs { .. } => ("discrete input", "discrete inputs"),
751 R::ReadHoldingRegisters { .. }
752 | R::WriteSingleRegister { .. }
753 | R::WriteMultipleRegisters { .. } => ("holding register", "holding registers"),
754 R::ReadInputRegisters { .. } => ("input register", "input registers"),
755 };
756 let addresses = if quantity <= 1 {
757 format!("{singular} {start}")
758 } else {
759 format!("{plural} {start} to {}", start + quantity - 1)
760 };
761 let keyed = match request {
762 R::ReadCoils { .. } | R::ReadDiscreteInputs { .. } => Some(("send_modbus_bits", "bits")),
763 R::ReadHoldingRegisters { .. } | R::ReadInputRegisters { .. } => {
764 Some(("send_modbus_registers", "registers"))
765 }
766 _ => None,
767 };
768 if let Some((action, field)) = keyed {
769 return format!(
770 "{action} with a {field} object holding every {singular} your instructions give \
771 this device, keyed by address. The client asked for {addresses}; NetGet answers \
772 exception 2 if any of them is not in your object"
773 );
774 }
775 let illustration = if start > 0 {
778 format!(
779 "one whose {plural} are 0 to {} has no {singular} {start}",
780 start - 1
781 )
782 } else {
783 format!(
784 "one whose {plural} start at {} has no {singular} 0",
785 start + quantity
786 )
787 };
788 format!(
789 "first check whether your instructions give this device {addresses}. A device has \
790 only the addresses its instructions give it - {illustration}. If any of those \
791 addresses is outside what they describe, answer exactly {{\"type\": \
792 \"send_modbus_exception\", \"exception_code\": 2}} and nothing else. If every one \
793 of them exists, send_modbus_write_ack, or send_modbus_exception with exception_code 3 \
794 when the value is one the equipment would refuse"
795 )
796}
797
798pub static MODBUS_READ_BITS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
800 let mut params = common_event_parameters();
801 params.push(Parameter {
802 name: "bit_type".to_string(),
803 type_hint: "string".to_string(),
804 description:
805 "\"coil\" for function code 1 (read/write outputs, e.g. a pump run command) or \
806 \"discrete_input\" for function code 2 (read-only inputs, e.g. a limit switch)"
807 .to_string(),
808 required: true,
809 });
810
811 EventType::new(
812 "modbus_read_bits",
813 "A Modbus client is reading coils (FC 1) or discrete inputs (FC 2). Answer with the \
814 bits this device has, keyed by address; NetGet serves the read from them and answers \
815 exception 2 for any requested address you did not give.",
816 json!({
817 "type": "send_modbus_bits",
818 "bits": {"40": true, "41": false}
819 }),
820 )
821 .with_parameters(params)
822 .with_actions(vec![
823 SEND_MODBUS_BITS_ACTION.clone(),
824 SEND_MODBUS_EXCEPTION_ACTION.clone(),
825 ])
826 .with_alternative_example(json!({
827 "type": "send_modbus_exception",
828 "exception_code": 2
829 }))
830 .with_log_template(
831 LogTemplate::new()
832 .with_info("{client_ip}:{client_port} {function} @{start_address} x{quantity}")
833 .with_debug("Modbus {function} unit={unit_id} start={start_address} qty={quantity}")
834 .with_trace("Modbus read bits: {json_pretty(.)}"),
835 )
836});
837
838pub static MODBUS_READ_REGISTERS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
840 let mut params = common_event_parameters();
841 params.push(Parameter {
842 name: "register_type".to_string(),
843 type_hint: "string".to_string(),
844 description: "\"holding\" for function code 3 (read/write registers, e.g. a setpoint) or \
845 \"input\" for function code 4 (read-only measurements, e.g. a sensor reading)"
846 .to_string(),
847 required: true,
848 });
849
850 EventType::new(
851 "modbus_read_registers",
852 "A Modbus client is reading holding registers (FC 3) or input registers (FC 4). \
853 Answer with the registers this device has, keyed by address; NetGet serves the read \
854 from them and answers exception 2 for any requested address you did not give.",
855 json!({
856 "type": "send_modbus_registers",
857 "registers": {"40": 1834, "41": 1450}
858 }),
859 )
860 .with_parameters(params)
861 .with_actions(vec![
862 SEND_MODBUS_REGISTERS_ACTION.clone(),
863 SEND_MODBUS_EXCEPTION_ACTION.clone(),
864 ])
865 .with_alternative_example(json!({
866 "type": "send_modbus_exception",
867 "exception_code": 2
868 }))
869 .with_log_template(
870 LogTemplate::new()
871 .with_info("{client_ip}:{client_port} {function} @{start_address} x{quantity}")
872 .with_debug("Modbus {function} unit={unit_id} start={start_address} qty={quantity}")
873 .with_trace("Modbus read registers: {json_pretty(.)}"),
874 )
875});
876
877pub static MODBUS_WRITE_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
879 let mut params = common_event_parameters();
880 params.push(Parameter {
881 name: "coil_values".to_string(),
882 type_hint: "array of booleans".to_string(),
883 description:
884 "Present for function codes 5 and 15: the bit values the client is trying to write"
885 .to_string(),
886 required: false,
887 });
888 params.push(Parameter {
889 name: "register_values".to_string(),
890 type_hint: "array of integers".to_string(),
891 description:
892 "Present for function codes 6 and 16: the register values the client is trying to \
893 write"
894 .to_string(),
895 required: false,
896 });
897
898 EventType::new(
899 "modbus_write_request",
900 "A Modbus client is writing coils or registers (FC 5, 6, 15 or 16). Decide whether \
901 this device accepts the write - consider whether the address is writable on the \
902 equipment you are impersonating and whether the value is in range - then acknowledge \
903 it or refuse with an exception. Record accepted writes with set_memory so later \
904 reads reflect them.",
905 json!({ "type": "send_modbus_write_ack" }),
906 )
907 .with_parameters(params)
908 .with_actions(vec![
909 SEND_MODBUS_WRITE_ACK_ACTION.clone(),
910 SEND_MODBUS_EXCEPTION_ACTION.clone(),
911 ])
912 .with_alternative_example(json!({
913 "type": "send_modbus_exception",
914 "exception_code": 2
915 }))
916 .with_log_template(
917 LogTemplate::new()
918 .with_info("{client_ip}:{client_port} {function} @{start_address} x{quantity}")
919 .with_debug("Modbus {function} unit={unit_id} start={start_address} qty={quantity}")
920 .with_trace("Modbus write: {json_pretty(.)}"),
921 )
922});
923
924pub fn get_modbus_event_types() -> Vec<EventType> {
926 vec![
927 MODBUS_READ_BITS_EVENT.clone(),
928 MODBUS_READ_REGISTERS_EVENT.clone(),
929 MODBUS_WRITE_REQUEST_EVENT.clone(),
930 ]
931}