1pub 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
47pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
49
50pub 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
68enum Accepted {
70 Written(usize),
72 Queued(usize),
74}
75
76struct 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
178fn 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
189fn 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
258struct Queued {
260 unit_id: u8,
261 request: ModbusRequest,
262 pdu: Vec<u8>,
263}
264
265async 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
300async 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 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 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#[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
537async 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}