Skip to main content

netget/client/modbus/
mod.rs

1//! Modbus/TCP client: NetGet is the master, the model decides what to read and write.
2//!
3//! Three tasks per connection, all registered with the client:
4//!
5//! * **transport** owns the socket. It frames requests with the shared codec, puts one
6//!   transaction on the wire at a time, checks the response against it by transaction id, and
7//!   reports a request left unanswered past [`RESPONSE_TIMEOUT`]. It never waits on the model.
8//! * **turns** asks the model about each response, in order, and hands its actions back to the
9//!   transport. The chain request → response → model → request passes through that queue, so it
10//!   needs no recursion.
11//! * **commands** runs injected actions (`[ send ]`, MCP `send_to_client`) down the same path.
12
13pub mod actions;
14
15pub use actions::ModbusClientProtocol;
16
17use std::collections::VecDeque;
18use std::net::SocketAddr;
19use std::sync::Arc;
20use std::time::Duration;
21
22use anyhow::{anyhow, Context, Result};
23use serde_json::{json, Value};
24use tokio::io::{AsyncReadExt, AsyncWriteExt};
25use tokio::net::TcpStream;
26use tokio::sync::{mpsc, oneshot};
27use tokio::time::Instant;
28use tracing::{debug, error, info, warn};
29
30use crate::client::llm_budget::call_llm_for_client;
31use crate::client::modbus::actions::{
32    request_from_action, MODBUS_CONNECTED_EVENT, MODBUS_ERROR_EVENT, MODBUS_EXCEPTION_EVENT,
33    MODBUS_READ_RESPONSE_EVENT, MODBUS_WRITE_RESPONSE_EVENT, REQUEST_RESULT,
34};
35use crate::llm::actions::client_trait::{Client, ClientActionResult};
36use crate::llm::actions::protocol_trait::Protocol;
37use crate::llm::ollama_client::OllamaClient;
38use crate::protocol::Event;
39use crate::server::modbus::codec::{
40    encode_adu, encode_request, exception_name, parse_response, try_parse_adu, ModbusRequest,
41    ModbusResponse,
42};
43use crate::state::app_state::AppState;
44use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
45use crate::state::{AccessLogOwner, ClientId, ClientStatus};
46
47/// How long a request may go unanswered before the model is told it timed out.
48pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
49
50/// Requests accepted and not yet answered — the one on the wire plus those queued behind it.
51/// The next one is refused.
52pub const MAX_QUEUED: usize = 32;
53
54const TURN_QUEUE_CAPACITY: usize = 256;
55const OUTBOUND_CAPACITY: usize = 64;
56
57enum Outbound {
58    Request {
59        unit_id: u8,
60        request: ModbusRequest,
61        ack: oneshot::Sender<Result<Accepted, String>>,
62    },
63    Disconnect {
64        ack: oneshot::Sender<()>,
65    },
66}
67
68/// What the transport did with a request it accepted.
69enum Accepted {
70    /// Written now: this many octets.
71    Written(usize),
72    /// Queued behind this many requests; written when they are answered.
73    Queued(usize),
74}
75
76/// The one request on the wire.
77struct InFlight {
78    tid: u16,
79    unit_id: u8,
80    request: ModbusRequest,
81    deadline: Instant,
82}
83
84enum Applied {
85    Sent(usize),
86    Queued(usize),
87    Nothing,
88    Disconnect,
89}
90
91pub struct ModbusClient;
92
93impl ModbusClient {
94    pub async fn connect_with_llm_actions(
95        remote_addr: String,
96        unit_id: u8,
97        llm_client: OllamaClient,
98        app_state: Arc<AppState>,
99        status_tx: mpsc::UnboundedSender<String>,
100        client_id: ClientId,
101    ) -> Result<SocketAddr> {
102        if remote_addr.trim().is_empty() {
103            return Err(anyhow!(
104                "Modbus client needs a remote_addr (host:port); refusing to connect without one"
105            ));
106        }
107        let stream = TcpStream::connect(&remote_addr)
108            .await
109            .with_context(|| format!("Failed to connect to Modbus device at {remote_addr}"))?;
110        let local_addr = stream.local_addr()?;
111        let peer = stream.peer_addr()?;
112        info!("Modbus client {client_id} connected to {peer} (unit {unit_id})");
113        app_state
114            .update_client_status(client_id, ClientStatus::Connected)
115            .await;
116        let _ = status_tx.send(format!("[CLIENT] Modbus client {client_id} connected"));
117        let _ = status_tx.send("__UPDATE_UI__".to_string());
118
119        let protocol = Arc::new(ModbusClientProtocol::new());
120        let (outbound_tx, outbound_rx) = mpsc::channel::<Outbound>(OUTBOUND_CAPACITY);
121        let (turn_tx, turn_rx) = mpsc::channel::<Event>(TURN_QUEUE_CAPACITY);
122
123        let command_rx =
124            crate::client::command_support::register_command_channel(&app_state, client_id).await;
125        app_state
126            .spawn_client_task(
127                client_id,
128                command_loop(
129                    command_rx,
130                    protocol.clone(),
131                    outbound_tx.clone(),
132                    unit_id,
133                    client_id,
134                    app_state.clone(),
135                    status_tx.clone(),
136                ),
137            )
138            .await;
139
140        let turn_abort = app_state
141            .spawn_client_task(
142                client_id,
143                run_turns(
144                    turn_rx,
145                    outbound_tx,
146                    protocol,
147                    unit_id,
148                    llm_client,
149                    app_state.clone(),
150                    status_tx.clone(),
151                    client_id,
152                ),
153            )
154            .await;
155
156        let _ = turn_tx.try_send(Event::new(
157            &MODBUS_CONNECTED_EVENT,
158            json!({"remote_addr": peer.to_string(), "unit_id": unit_id}),
159        ));
160        app_state
161            .spawn_client_task(
162                client_id,
163                run_transport(
164                    stream,
165                    outbound_rx,
166                    turn_tx,
167                    app_state.clone(),
168                    status_tx,
169                    client_id,
170                    turn_abort,
171                ),
172            )
173            .await;
174        Ok(local_addr)
175    }
176}
177
178/// The values a request carried (writes) or that its response carried (reads), as JSON.
179fn values_json(request: &ModbusRequest) -> Value {
180    match request {
181        ModbusRequest::WriteSingleCoil { value, .. } => json!([value]),
182        ModbusRequest::WriteSingleRegister { value, .. } => json!([value]),
183        ModbusRequest::WriteMultipleCoils { values, .. } => json!(values),
184        ModbusRequest::WriteMultipleRegisters { values, .. } => json!(values),
185        _ => json!([]),
186    }
187}
188
189/// The event one matched response becomes.
190fn event_for(pending: &InFlight, response_unit: u8, pdu: &[u8]) -> Event {
191    let request = &pending.request;
192    let base = |mut data: Value| {
193        data["function"] = json!(request.function_name());
194        data["address"] = json!(request.start_address());
195        data
196    };
197    if response_unit != pending.unit_id {
198        return Event::new(
199            &MODBUS_ERROR_EVENT,
200            base(json!({
201                "kind": "unit_mismatch",
202                "message": format!(
203                    "the request went to unit {} and the reply came from unit {response_unit}",
204                    pending.unit_id
205                ),
206            })),
207        );
208    }
209    match parse_response(pdu, request) {
210        Ok(ModbusResponse::Bits(bits)) => Event::new(
211            &MODBUS_READ_RESPONSE_EVENT,
212            base(json!({
213                "unit_id": pending.unit_id,
214                "quantity": request.quantity(),
215                "values": bits,
216            })),
217        ),
218        Ok(ModbusResponse::Registers(regs)) => Event::new(
219            &MODBUS_READ_RESPONSE_EVENT,
220            base(json!({
221                "unit_id": pending.unit_id,
222                "quantity": request.quantity(),
223                "values": regs,
224            })),
225        ),
226        Ok(ModbusResponse::WriteAck) => Event::new(
227            &MODBUS_WRITE_RESPONSE_EVENT,
228            base(json!({
229                "unit_id": pending.unit_id,
230                "quantity": request.quantity(),
231                "values": values_json(request),
232            })),
233        ),
234        Ok(ModbusResponse::Exception { code }) => Event::new(
235            &MODBUS_EXCEPTION_EVENT,
236            base(json!({
237                "unit_id": pending.unit_id,
238                "code": code,
239                "name": exception_name(code),
240            })),
241        ),
242        Err(message) => Event::new(
243            &MODBUS_ERROR_EVENT,
244            base(json!({"kind": "bad_response", "message": message})),
245        ),
246    }
247}
248
249fn enqueue(turn_tx: &mpsc::Sender<Event>, event: Event, client_id: ClientId) {
250    if turn_tx.try_send(event).is_err() {
251        warn!(
252            "Modbus client {client_id} dropped a response: the model is {TURN_QUEUE_CAPACITY} \
253             events behind decision=turn_queue_full"
254        );
255    }
256}
257
258/// A request accepted by the transport and not yet written.
259struct Queued {
260    unit_id: u8,
261    request: ModbusRequest,
262    pdu: Vec<u8>,
263}
264
265/// Write the next queued request if nothing is on the wire. Returns the octets written, or
266/// `None` when nothing was written.
267async fn pump<W: tokio::io::AsyncWrite + Unpin>(
268    writer: &mut W,
269    queue: &mut VecDeque<Queued>,
270    in_flight: &mut Option<InFlight>,
271    next_tid: &mut u16,
272    client_id: ClientId,
273) -> std::io::Result<Option<usize>> {
274    if in_flight.is_some() {
275        return Ok(None);
276    }
277    let Some(next) = queue.pop_front() else {
278        return Ok(None);
279    };
280    let tid = *next_tid;
281    *next_tid = next_tid.wrapping_add(1);
282    let frame = encode_adu(tid, next.unit_id, &next.pdu)
283        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
284    writer.write_all(&frame).await?;
285    writer.flush().await?;
286    debug!(
287        "Modbus client {client_id} sent {} tid {tid} unit {}",
288        next.request.function_name(),
289        next.unit_id
290    );
291    *in_flight = Some(InFlight {
292        tid,
293        unit_id: next.unit_id,
294        request: next.request,
295        deadline: Instant::now() + RESPONSE_TIMEOUT,
296    });
297    Ok(Some(frame.len()))
298}
299
300/// Own the socket for the life of the connection.
301///
302/// **One transaction on the wire at a time.** The Modbus/TCP implementation guide lets a master
303/// pipeline, but a device may accept one outstanding transaction, and pymodbus 3.15 silently
304/// drops the second of two ADUs that arrive in one segment. A master that pipelines therefore
305/// loses requests against real devices, with no error at either end. Requests queue here, and
306/// the next is written when the previous one is answered or times out.
307async fn run_transport(
308    stream: TcpStream,
309    mut outbound_rx: mpsc::Receiver<Outbound>,
310    turn_tx: mpsc::Sender<Event>,
311    app_state: Arc<AppState>,
312    status_tx: mpsc::UnboundedSender<String>,
313    client_id: ClientId,
314    turn_abort: tokio::task::AbortHandle,
315) {
316    let (mut reader, mut writer) = tokio::io::split(stream);
317    let mut queue: VecDeque<Queued> = VecDeque::new();
318    let mut in_flight: Option<InFlight> = None;
319    let mut next_tid: u16 = 1;
320    let mut buf: Vec<u8> = Vec::with_capacity(1024);
321    let mut chunk = [0u8; 1024];
322
323    let status = loop {
324        let deadline = in_flight.as_ref().map(|f| f.deadline);
325        tokio::select! {
326            read = reader.read(&mut chunk) => {
327                let n = match read {
328                    Ok(0) => {
329                        info!("Modbus client {client_id} closed by the device");
330                        break ClientStatus::Disconnected;
331                    }
332                    Ok(n) => n,
333                    Err(e) => {
334                        error!("Modbus client {client_id} read error: {e}");
335                        break ClientStatus::Error(e.to_string());
336                    }
337                };
338                buf.extend_from_slice(&chunk[..n]);
339                // Every ADU is at most MAX_ADU_LEN octets and `try_parse_adu` refuses a longer
340                // declared length, so the buffer never holds more than one incomplete frame
341                // plus one read.
342                let mut fatal = None;
343                loop {
344                    match try_parse_adu(&buf) {
345                        Ok(None) => break,
346                        Ok(Some((adu, used))) => {
347                            buf.drain(..used);
348                            match in_flight.take() {
349                                Some(f) if f.tid == adu.transaction_id => {
350                                    let event = event_for(&f, adu.unit_id, &adu.pdu);
351                                    enqueue(&turn_tx, event, client_id);
352                                }
353                                other => {
354                                    in_flight = other;
355                                    warn!(
356                                        "Modbus client {client_id} got a response for \
357                                         transaction {} that is not in flight \
358                                         decision=unmatched_response",
359                                        adu.transaction_id
360                                    );
361                                }
362                            }
363                        }
364                        Err(e) => {
365                            fatal = Some(e.to_string());
366                            break;
367                        }
368                    }
369                }
370                if let Some(e) = fatal {
371                    error!("Modbus client {client_id} framing error: {e}");
372                    break ClientStatus::Error(e);
373                }
374            }
375            _ = async {
376                match deadline {
377                    Some(d) => tokio::time::sleep_until(d).await,
378                    None => std::future::pending::<()>().await,
379                }
380            } => {
381                if let Some(f) = in_flight.take() {
382                    warn!(
383                        "Modbus client {client_id} transaction {} unanswered after \
384                         {RESPONSE_TIMEOUT:?} decision=timeout",
385                        f.tid
386                    );
387                    enqueue(
388                        &turn_tx,
389                        Event::new(
390                            &MODBUS_ERROR_EVENT,
391                            json!({
392                                "kind": "timeout",
393                                "message": format!(
394                                    "no response within {} seconds",
395                                    RESPONSE_TIMEOUT.as_secs()
396                                ),
397                                "function": f.request.function_name(),
398                                "address": f.request.start_address(),
399                            }),
400                        ),
401                        client_id,
402                    );
403                }
404            }
405            out = outbound_rx.recv() => match out {
406                None => break ClientStatus::Disconnected,
407                Some(Outbound::Disconnect { ack }) => {
408                    let _ = writer.shutdown().await;
409                    let _ = ack.send(());
410                    info!("Modbus client {client_id} disconnected on request");
411                    break ClientStatus::Disconnected;
412                }
413                Some(Outbound::Request { unit_id, request, ack }) => {
414                    if queue.len() + usize::from(in_flight.is_some()) >= MAX_QUEUED {
415                        let _ = ack.send(Err(format!(
416                            "{MAX_QUEUED} requests are already waiting for a response"
417                        )));
418                        continue;
419                    }
420                    let pdu = match encode_request(&request) {
421                        Ok(pdu) => pdu,
422                        Err(e) => {
423                            let _ = ack.send(Err(e));
424                            continue;
425                        }
426                    };
427                    queue.push_back(Queued { unit_id, request, pdu });
428                    let ahead = queue.len() - 1 + usize::from(in_flight.is_some());
429                    match pump(&mut writer, &mut queue, &mut in_flight, &mut next_tid, client_id)
430                        .await
431                    {
432                        Ok(Some(bytes)) => {
433                            let _ = ack.send(Ok(Accepted::Written(bytes)));
434                        }
435                        Ok(None) => {
436                            let _ = ack.send(Ok(Accepted::Queued(ahead)));
437                        }
438                        Err(e) => {
439                            let _ = ack.send(Err(format!("write failed: {e}")));
440                            error!("Modbus client {client_id} write error: {e}");
441                            break ClientStatus::Error(e.to_string());
442                        }
443                    }
444                    continue;
445                }
446            }
447        }
448        // A response or a timeout freed the wire: send the next queued request.
449        if let Err(e) = pump(
450            &mut writer,
451            &mut queue,
452            &mut in_flight,
453            &mut next_tid,
454            client_id,
455        )
456        .await
457        {
458            error!("Modbus client {client_id} write error: {e}");
459            break ClientStatus::Error(e.to_string());
460        }
461    };
462
463    turn_abort.abort();
464    app_state.remove_client_handle(client_id).await;
465    app_state.update_client_status(client_id, status).await;
466    let _ = status_tx.send(format!("[CLIENT] Modbus client {client_id} disconnected"));
467    let _ = status_tx.send("__UPDATE_UI__".to_string());
468}
469
470/// Answer queued events with the model, one at a time, in arrival order.
471#[allow(clippy::too_many_arguments)]
472async fn run_turns(
473    mut turn_rx: mpsc::Receiver<Event>,
474    outbound_tx: mpsc::Sender<Outbound>,
475    protocol: Arc<ModbusClientProtocol>,
476    unit_id: u8,
477    llm_client: OllamaClient,
478    app_state: Arc<AppState>,
479    status_tx: mpsc::UnboundedSender<String>,
480    client_id: ClientId,
481) {
482    while let Some(event) = turn_rx.recv().await {
483        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
484            continue;
485        };
486        let memory = app_state
487            .get_memory_for_client(client_id)
488            .await
489            .unwrap_or_default();
490        match call_llm_for_client(
491            &llm_client,
492            &app_state,
493            client_id.to_string(),
494            &instruction,
495            &memory,
496            Some(&event),
497            protocol.as_ref(),
498            &status_tx,
499        )
500        .await
501        {
502            Ok(result) => {
503                if let Some(mem) = result.memory_updates {
504                    app_state.set_memory_for_client(client_id, mem).await;
505                }
506                info!(
507                    "Modbus client {client_id} {} decision={} actions={}",
508                    event.id(),
509                    if result.actions.is_empty() {
510                        "model_silent"
511                    } else {
512                        "model_actions"
513                    },
514                    result.actions.len()
515                );
516                for action in result.actions {
517                    match apply_action(protocol.as_ref(), &outbound_tx, unit_id, action).await {
518                        Ok(Applied::Disconnect) => return,
519                        Ok(_) => {}
520                        Err(e) => {
521                            error!("Modbus client {client_id} action failed: {e}");
522                            let _ = status_tx.send(format!(
523                                "[ERROR] Modbus client {client_id} action failed: {e}"
524                            ));
525                        }
526                    }
527                }
528            }
529            Err(e) => error!(
530                "Modbus client {client_id} {} decision=llm_error: {e}",
531                event.id()
532            ),
533        }
534    }
535}
536
537/// Execute one action and hand its request to the transport. Shared by the model's turns and
538/// injected commands.
539async fn apply_action(
540    protocol: &ModbusClientProtocol,
541    outbound_tx: &mpsc::Sender<Outbound>,
542    default_unit: u8,
543    action: Value,
544) -> Result<Applied> {
545    match protocol.execute_action(action)? {
546        ClientActionResult::Custom { name, data } if name == REQUEST_RESULT => {
547            let parsed = request_from_action(&data)?
548                .ok_or_else(|| anyhow!("a request action produced no request"))?;
549            let (ack, done) = oneshot::channel();
550            outbound_tx
551                .send(Outbound::Request {
552                    unit_id: parsed.unit_id.unwrap_or(default_unit),
553                    request: parsed.request,
554                    ack,
555                })
556                .await
557                .map_err(|_| anyhow!("the connection is closed"))?;
558            match done
559                .await
560                .map_err(|_| anyhow!("the connection closed before the request was accepted"))?
561                .map_err(|e| anyhow!(e))?
562            {
563                Accepted::Written(bytes) => Ok(Applied::Sent(bytes)),
564                Accepted::Queued(ahead) => Ok(Applied::Queued(ahead)),
565            }
566        }
567        ClientActionResult::Disconnect => {
568            let (ack, done) = oneshot::channel();
569            if outbound_tx.send(Outbound::Disconnect { ack }).await.is_ok() {
570                let _ = done.await;
571            }
572            Ok(Applied::Disconnect)
573        }
574        _ => Ok(Applied::Nothing),
575    }
576}
577
578async fn command_loop(
579    mut command_rx: mpsc::Receiver<ClientCommand>,
580    protocol: Arc<ModbusClientProtocol>,
581    outbound_tx: mpsc::Sender<Outbound>,
582    unit_id: u8,
583    client_id: ClientId,
584    app_state: Arc<AppState>,
585    status_tx: mpsc::UnboundedSender<String>,
586) {
587    while let Some(command) = command_rx.recv().await {
588        let action = command.action.clone();
589        let outcome = match apply_action(protocol.as_ref(), &outbound_tx, unit_id, action.clone())
590            .await
591        {
592            Ok(Applied::Sent(bytes_sent)) => ClientSendOutcome::Sent { bytes_sent },
593            Ok(Applied::Queued(ahead)) => ClientSendOutcome::Executed {
594                detail: format!("queued behind {ahead} request(s); written when they are answered"),
595            },
596            Ok(Applied::Nothing) => ClientSendOutcome::Executed {
597                detail: "executed (nothing to write)".to_string(),
598            },
599            Ok(Applied::Disconnect) => ClientSendOutcome::Disconnected,
600            Err(e) => ClientSendOutcome::Rejected {
601                error: e.to_string(),
602            },
603        };
604        app_state
605            .record_access_log(
606                AccessLogOwner::Client(client_id.as_u32()),
607                protocol.protocol_name(),
608                None,
609                "injected_action",
610                action,
611                vec![serde_json::to_value(&outcome).unwrap_or_default()],
612            )
613            .await;
614        let disconnect = matches!(outcome, ClientSendOutcome::Disconnected);
615        let _ = status_tx.send("__UPDATE_UI__".to_string());
616        crate::client::command_support::reply(command, Ok(outcome));
617        if disconnect {
618            break;
619        }
620    }
621}