Skip to main content

netget/server/modbus/
actions.rs

1//! Modbus TCP protocol actions.
2//!
3//! The model plays the PLC. It is asked what a bank of coils or registers *reads as*, and
4//! whether a write is accepted — not how to frame a reply. Transaction id, unit id,
5//! function code, byte counts and the write echo are all reconstructed by
6//! `src/server/modbus/mod.rs` from the request it parsed, so the model cannot break
7//! framing and never has to echo an identifier back.
8//!
9//! Every action therefore returns [`ActionResult::Custom`] carrying structured data
10//! (booleans, integers, an exception code) rather than bytes.
11
12use 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
25/// Name carried by the [`ActionResult::Custom`] results this protocol produces.
26pub 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/// Modbus TCP server protocol handler.
32#[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        // A Modbus server never speaks unprompted: every frame on the wire is a response
89        // to a request. There is nothing meaningful to trigger from user input.
90        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        // Deliberately distinctive: no bare "plc", "industrial" or "register", which would
116        // collide with unrelated requests the way the BLE profiles collided with FTP/NFS.
117        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            // STABLE against the six conditions in the root CLAUDE.md, re-derived from source on
133            // 26 September 2026. Read `.e2e_testing` and `.notes`, and "Maturity: the six
134            // conditions" in src/server/modbus/CLAUDE.md, before quoting it: the rating covers
135            // the evidence for the surface this server implements, which is Modbus TCP with
136            // eight function codes.
137            .state(DevelopmentState::Stable)
138            // 502 is below 1024 so this requirement genuinely fires; server_startup only
139            // enforces it when the port actually requested is privileged, so running on a
140            // high port as an unprivileged user still works.
141            .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        // Deterministic: answer every read with a zero-filled register bank of
213        // the requested width, no LLM call.
214        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            // LLM mode: the model invents self-consistent telemetry per request.
228            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            // Script mode: deterministic register bank held by the script.
238            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            // Static mode: a fixed answer for every read of the same width.
252            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            // Propagate, never unwrap: these values come from the model or an MCP caller.
292            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            // A zero deadline would close every connection before it could speak, so it is
305            // refused rather than honoured.
306            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                // The positional form, one value per requested address starting at
359                // `start_address`: still accepted, so handlers written against it keep working.
360                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                // The positional form, one value per requested register starting at
409                // `start_address`: still accepted, so handlers written against it keep working.
410                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                    // Checked against the codes the specification actually defines, not
460                    // against `u8`. A bare range check plus `n as u8` would have put 0x104
461                    // on the wire as 0x04 (server device failure) and 0x07 as an exception
462                    // no client can interpret.
463                    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            // Not offered to the model (a Modbus server answers requests, it does not hang
501            // up on its own), but the dashboard's "disconnect this peer" injects it through
502            // the peer command task, which half-closes the write side on this result.
503            "close_connection" => Ok(ActionResult::CloseConnection),
504            _ => Err(anyhow::anyhow!("Unknown Modbus action: {action_type}")),
505        }
506    }
507}
508
509/// Validate an address-keyed answer (`{"500": 0, "501": 0}`) and normalise its keys.
510///
511/// Every key must be a Modbus address, 0-65535, written in decimal; it is re-keyed in canonical
512/// form (`"007"` becomes `"7"`) so the connection loop can look each requested address up by
513/// its plain decimal spelling. Each value goes through `value` and is refused with the address
514/// named. An empty object is a valid answer: the device has none of the addresses asked for,
515/// which the connection loop answers as exception 2.
516fn 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
543// ===========================================================================
544// Action definitions
545// ===========================================================================
546
547fn 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
677// ===========================================================================
678// Event types
679// ===========================================================================
680
681/// Parameters shared by every Modbus event.
682fn 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
727/// The `answer_with` event field: the answer this request takes, naming the exact addresses.
728///
729/// **A read is answered from an address-keyed map, and NetGet does the comparison.** Told "ten
730/// holding registers at addresses 0 to 9, all zero. There is nothing at any other address",
731/// llama3.1:8b answered a read of 500-501 by describing the whole device - ten zeros - under
732/// every wording that asked it to compare 500 against 0-9 first (0/5 to 3/5 across five
733/// wordings, recorded in `src/server/modbus/CLAUDE.md`). Its reasoning said the device "has
734/// only holding registers 0 to 9, which is exactly what the client is asking for": it restates
735/// the instruction rather than comparing addresses. Keyed by address, that same answer is a
736/// correct one - `{"0": 0, ..., "9": 0}` has no entry for 500, and a missing entry is exception
737/// 2 - so the hint asks for the device's registers and says what a missing one means, and asks
738/// for no comparison at all.
739///
740/// A write carries no values to key, so it keeps the check-first wording: the addresses, a
741/// worked comparison computed from the request itself, and the literal exception-2 action.
742pub 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    // A worked comparison computed from this request, never from any particular instruction:
776    // a range that stops just short of the first address asked for.
777    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
798/// Read of coils (FC 1) or discrete inputs (FC 2).
799pub 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
838/// Read of holding registers (FC 3) or input registers (FC 4).
839pub 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
877/// Write of coils or registers (FC 5, 6, 15, 16).
878pub 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
924/// All Modbus event types. Each one is emitted by `src/server/modbus/mod.rs`.
925pub 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}