Skip to main content

netget/client/modbus/
actions.rs

1//! Modbus/TCP client actions, events and metadata.
2//!
3//! NetGet is the master: it dials a Modbus/TCP device and the model decides which coils and
4//! registers to read and what to write. The framing — MBAP header, transaction ids, byte
5//! counts, bit packing — is the shared codec's (`src/server/modbus/codec.rs`), so the model
6//! works in addresses, quantities and values, never bytes.
7
8use 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
21/// The name of the `ClientActionResult::Custom` every request travels as.
22pub const REQUEST_RESULT: &str = "modbus_request";
23
24/// The unit id requests use when neither the action nor the `unit_id` startup parameter names
25/// one. 1 is what most Modbus/TCP devices answer on, and what `mbpoll` defaults to.
26pub 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
176/// Modbus/TCP client protocol.
177pub 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
337/// One validated request, with the unit id the action named (if any).
338pub struct ClientRequest {
339    pub unit_id: Option<u8>,
340    pub request: ModbusRequest,
341}
342
343/// Parse one model action into a request. `Ok(None)` is `disconnect`.
344///
345/// Numbers are refused rather than narrowed: `65536` is not register value `0`, and a unit id
346/// of `257` is not unit `1`. The finished request is then run through the shared codec's
347/// encoder, which refuses anything the specification would answer with an exception.
348pub 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            // 502 is a destination here, never a bind.
478            .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}