pub mod actions;
pub mod codec;
use anyhow::Result;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, error, info, warn};
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ActionResult;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::state::app_state::AppState;
use actions::{
ModbusProtocol, MODBUS_READ_BITS_EVENT, MODBUS_READ_REGISTERS_EVENT,
MODBUS_WRITE_REQUEST_EVENT, RESULT_BITS, RESULT_EXCEPTION, RESULT_REGISTERS, RESULT_WRITE_ACK,
};
use codec::{ModbusRequest, MAX_ADU_LEN};
#[derive(Debug, Clone, PartialEq)]
enum ConnectionState {
Idle,
Processing,
Accumulating,
}
struct ConnectionData {
state: ConnectionState,
buffer: Vec<u8>,
write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
closed: Arc<tokio::sync::Notify>,
}
pub const MAX_BUFFERED: usize = MAX_ADU_LEN * 8;
pub const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
pub const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Decision {
ModelAnswer,
ModelReject,
ModelAddressAbsent,
SpecReject,
UnitMismatch,
FailClosedLlmError,
FailClosedNoAction,
FailClosedWrongShape,
FailClosedFramingError,
FailClosedBufferOverflow,
}
impl Decision {
fn as_str(self) -> &'static str {
match self {
Decision::ModelAnswer => "model_answer",
Decision::ModelReject => "model_reject",
Decision::ModelAddressAbsent => "model_address_absent",
Decision::SpecReject => "spec_reject",
Decision::UnitMismatch => "unit_mismatch",
Decision::FailClosedLlmError => "fail_closed_llm_error",
Decision::FailClosedNoAction => "fail_closed_no_action",
Decision::FailClosedWrongShape => "fail_closed_wrong_shape",
Decision::FailClosedFramingError => "fail_closed_framing_error",
Decision::FailClosedBufferOverflow => "fail_closed_buffer_overflow",
}
}
fn is_fail_closed(self) -> bool {
matches!(
self,
Decision::FailClosedLlmError
| Decision::FailClosedNoAction
| Decision::FailClosedWrongShape
| Decision::FailClosedFramingError
| Decision::FailClosedBufferOverflow
)
}
}
pub struct ModbusServer;
impl ModbusServer {
#[allow(clippy::too_many_arguments)]
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
unit_id_filter: Option<u8>,
server_id: crate::state::ServerId,
first_byte_timeout: std::time::Duration,
idle_timeout: std::time::Duration,
) -> Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
Log::new(Some(&status_tx)).info(format!("Modbus server listening on {local_addr}"));
let connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>> =
Arc::new(Mutex::new(HashMap::new()));
let protocol = Arc::new(ModbusProtocol::new());
let task_registrar = app_state.clone();
let accept_handle = tokio::spawn(async move {
Log::new(Some(&status_tx)).info(format!("Modbus accept loop started on {local_addr}"));
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"Modbus",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
info!(
"Modbus accepted connection {} from {}",
connection_id, remote_addr
);
let (mut read_half, write_half) = tokio::io::split(stream);
let write_half = Arc::new(Mutex::new(write_half));
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
app_state
.add_connection_to_server(
server_id,
ServerConnectionState {
id: connection_id,
remote_addr,
local_addr: local_addr_conn,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
"state": "Idle",
"unit_id_filter": unit_id_filter,
})),
},
)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let closed = Arc::new(tokio::sync::Notify::new());
connections.lock().await.insert(
connection_id,
ConnectionData {
state: ConnectionState::Idle,
buffer: Vec::new(),
write_half: write_half.clone(),
closed: closed.clone(),
},
);
let peer_rx = crate::server::peer_support::register_peer_channel(
&app_state,
server_id,
connection_id.as_u32(),
)
.await;
crate::server::peer_support::spawn_peer_command_task(
peer_rx,
protocol.clone(),
app_state.clone(),
server_id,
connection_id.as_u32(),
write_half.clone(),
status_tx.clone(),
);
let llm_clone = llm_client.clone();
let state_clone = app_state.clone();
let status_clone = status_tx.clone();
let conns_clone = connections.clone();
let protocol_clone = protocol.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
let mut read_buf = vec![0u8; 4096];
let log = Log::new(Some(&status_clone));
let mut spoke_once = false;
loop {
let read_deadline = if spoke_once {
idle_timeout
} else {
first_byte_timeout
};
let timed_read = tokio::select! {
_ = closed.notified() => break,
r = tokio::time::timeout(
read_deadline,
read_half.read(&mut read_buf),
) => r,
};
let read_result = match timed_read {
Ok(v) => v,
Err(_) => {
log.info(format!(
"Modbus connection {connection_id} sent nothing \
for {}s; closing \
decision=fail_closed_idle_timeout",
read_deadline.as_secs()
));
conns_clone.lock().await.remove(&connection_id);
state_clone
.remove_peer_handle(
server_id,
connection_id.as_u32(),
)
.await;
state_clone
.close_connection_on_server(
server_id,
connection_id,
)
.await;
let _ = status_clone.send("__UPDATE_UI__".to_string());
break;
}
};
match read_result {
Ok(0) => {
conns_clone.lock().await.remove(&connection_id);
state_clone
.remove_peer_handle(
server_id,
connection_id.as_u32(),
)
.await;
state_clone
.close_connection_on_server(
server_id,
connection_id,
)
.await;
log.info(format!(
"Modbus connection {connection_id} closed"
));
let _ = status_clone.send("__UPDATE_UI__".to_string());
break;
}
Ok(n) => {
spoke_once = true;
let data = read_buf[..n].to_vec();
log.debug(format!(
"Modbus received {} bytes on {}",
n, connection_id
));
log.trace(format!(
"Modbus received (hex): {}",
hex::encode(&data)
));
state_clone
.update_connection_stats(
server_id,
connection_id,
Some(n as u64),
None,
Some(1),
None,
)
.await;
let llm = llm_clone.clone();
let st = state_clone.clone();
let stx = status_clone.clone();
let cs = conns_clone.clone();
let pr = protocol_clone.clone();
let task_owner = state_clone.clone();
task_owner
.spawn_server_task(server_id, async move {
Self::handle_data(
connection_id,
server_id,
data,
unit_id_filter,
llm,
st,
stx,
cs,
pr,
)
.await;
})
.await;
}
Err(e) => {
error!("Modbus read error on {}: {}", connection_id, e);
conns_clone.lock().await.remove(&connection_id);
state_clone
.remove_peer_handle(
server_id,
connection_id.as_u32(),
)
.await;
state_clone
.close_connection_on_server(
server_id,
connection_id,
)
.await;
break;
}
}
}
})
.await;
}
Err(e) => {
error!("Modbus accept error: {}", e);
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn handle_data(
connection_id: ConnectionId,
server_id: crate::state::ServerId,
data: Vec<u8>,
unit_id_filter: Option<u8>,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: Arc<ModbusProtocol>,
) {
let log = Log::new(Some(&status_tx));
{
let mut conns = connections.lock().await;
let Some(conn) = conns.get_mut(&connection_id) else {
return; };
conn.buffer.extend_from_slice(&data);
if conn.buffer.len() > MAX_BUFFERED {
error!(
"Modbus {} queued {} bytes, past the {} one connection may buffer; closing \
decision={}",
connection_id,
conn.buffer.len(),
MAX_BUFFERED,
Decision::FailClosedBufferOverflow.as_str()
);
conn.buffer.clear();
drop(conns);
Self::close(
connection_id,
server_id,
&app_state,
&connections,
&status_tx,
)
.await;
return;
}
if conn.state == ConnectionState::Processing {
log.debug(format!(
"Queued {} bytes for Modbus {connection_id}",
data.len()
));
return;
}
conn.state = ConnectionState::Processing;
}
loop {
let parsed = {
let mut conns = connections.lock().await;
let Some(conn) = conns.get_mut(&connection_id) else {
return;
};
match codec::try_parse_adu(&conn.buffer) {
Ok(Some((adu, consumed))) => {
conn.buffer.drain(..consumed);
Ok(Some(adu))
}
Ok(None) => {
conn.state = if conn.buffer.is_empty() {
ConnectionState::Idle
} else {
ConnectionState::Accumulating
};
Ok(None)
}
Err(e) => Err(e),
}
};
let adu = match parsed {
Ok(Some(adu)) => adu,
Ok(None) => return,
Err(e) => {
log.error(format!(
"Modbus framing error on {connection_id}: {e}; closing decision={}",
Decision::FailClosedFramingError.as_str()
));
Self::close(
connection_id,
server_id,
&app_state,
&connections,
&status_tx,
)
.await;
return;
}
};
let function_code = adu.pdu.first().copied().unwrap_or(0);
if let Some(expected) = unit_id_filter {
if adu.unit_id != expected {
warn!(
"Modbus {} addressed unit {} but this device answers for unit {} \
decision={}",
connection_id,
adu.unit_id,
expected,
Decision::UnitMismatch.as_str()
);
let pdu =
codec::encode_exception(function_code, codec::EXC_GATEWAY_TARGET_FAILED);
Self::send_pdu(
connection_id,
server_id,
adu.transaction_id,
adu.unit_id,
&pdu,
&app_state,
&connections,
&status_tx,
)
.await;
continue;
}
}
let request = match codec::parse_request(&adu.pdu) {
Ok(r) => r,
Err(exception_code) => {
debug!(
"Modbus {} rejecting fc={:#04x} with exception {:#04x} ({}) decision={}",
connection_id,
function_code,
exception_code,
codec::exception_name(exception_code),
Decision::SpecReject.as_str()
);
let pdu = codec::encode_exception(function_code, exception_code);
Self::send_pdu(
connection_id,
server_id,
adu.transaction_id,
adu.unit_id,
&pdu,
&app_state,
&connections,
&status_tx,
)
.await;
continue;
}
};
let (event_type, mut event_data) = Self::build_event(&adu, &request);
event_data["unit_id"] = serde_json::json!(adu.unit_id);
let event = Event::new(event_type, event_data);
let (decision, pdu) = match call_llm(
&llm_client,
&app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
for msg in execution_result.messages {
let _ = status_tx.send(msg);
}
Self::pdu_from_results(
connection_id,
&request,
&execution_result.protocol_results,
)
}
Err(e) => {
log.warn(format!("Modbus LLM error on {connection_id}: {e}"));
(
Decision::FailClosedLlmError,
codec::encode_exception(
request.function_code(),
codec::EXC_SERVER_DEVICE_FAILURE,
),
)
}
};
let summary = format!(
"Modbus {} {} unit={} @{} x{} decision={}",
connection_id,
request.function_name(),
adu.unit_id,
request.start_address(),
request.quantity(),
decision.as_str()
);
if decision.is_fail_closed() {
log.error(format!(
"{summary} (answered with an exception because no usable answer was \
produced)"
));
} else {
log.debug(&summary);
}
Self::send_pdu(
connection_id,
server_id,
adu.transaction_id,
adu.unit_id,
&pdu,
&app_state,
&connections,
&status_tx,
)
.await;
}
}
fn build_event(
adu: &codec::Adu,
request: &ModbusRequest,
) -> (&'static crate::protocol::EventType, serde_json::Value) {
let mut data = serde_json::json!({
"unit_id": adu.unit_id,
"function_code": request.function_code(),
"function": request.function_name(),
"start_address": request.start_address(),
"quantity": request.quantity(),
"answer_with": actions::answer_with_for_request(request),
});
match request {
ModbusRequest::ReadCoils { .. } => {
data["bit_type"] = serde_json::json!("coil");
(&MODBUS_READ_BITS_EVENT, data)
}
ModbusRequest::ReadDiscreteInputs { .. } => {
data["bit_type"] = serde_json::json!("discrete_input");
(&MODBUS_READ_BITS_EVENT, data)
}
ModbusRequest::ReadHoldingRegisters { .. } => {
data["register_type"] = serde_json::json!("holding");
(&MODBUS_READ_REGISTERS_EVENT, data)
}
ModbusRequest::ReadInputRegisters { .. } => {
data["register_type"] = serde_json::json!("input");
(&MODBUS_READ_REGISTERS_EVENT, data)
}
ModbusRequest::WriteSingleCoil { value, .. } => {
data["coil_values"] = serde_json::json!([value]);
(&MODBUS_WRITE_REQUEST_EVENT, data)
}
ModbusRequest::WriteMultipleCoils { values, .. } => {
data["coil_values"] = serde_json::json!(values);
(&MODBUS_WRITE_REQUEST_EVENT, data)
}
ModbusRequest::WriteSingleRegister { value, .. } => {
data["register_values"] = serde_json::json!([value]);
(&MODBUS_WRITE_REQUEST_EVENT, data)
}
ModbusRequest::WriteMultipleRegisters { values, .. } => {
data["register_values"] = serde_json::json!(values);
(&MODBUS_WRITE_REQUEST_EVENT, data)
}
}
}
fn pdu_from_results(
connection_id: ConnectionId,
request: &ModbusRequest,
results: &[ActionResult],
) -> (Decision, Vec<u8>) {
let fc = request.function_code();
for result in results {
let ActionResult::Custom { name, data } = result else {
continue;
};
match name.as_str() {
RESULT_EXCEPTION => {
let code = data
.get("exception_code")
.and_then(|v| v.as_u64())
.filter(|n| *n <= u8::MAX as u64)
.map(|n| n as u8)
.unwrap_or(codec::EXC_SERVER_DEVICE_FAILURE);
return (Decision::ModelReject, codec::encode_exception(fc, code));
}
RESULT_BITS => {
if !request.is_bit_read() {
error!(
"Modbus {}: send_modbus_bits answered a {} request; bits only \
answer function codes 1 and 2",
connection_id,
request.function_name()
);
return (
Decision::FailClosedWrongShape,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
);
}
let values: Vec<bool> = if let Some(map) = data.get("bits") {
match Self::read_from_map(connection_id, request, map, |v| v.as_bool()) {
Ok(values) => values,
Err(absent) => return absent,
}
} else {
data.get("values")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_bool()).collect())
.unwrap_or_default()
};
if values.len() != request.quantity() as usize {
error!(
"Modbus {}: model returned {} bit value(s) for a request of {}; \
answering with a device failure rather than a truncated frame",
connection_id,
values.len(),
request.quantity()
);
return (
Decision::FailClosedWrongShape,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
);
}
return match codec::encode_bits_response(fc, &values) {
Ok(pdu) => (Decision::ModelAnswer, pdu),
Err(e) => {
error!(
"Modbus {}: cannot encode a bit response: {}",
connection_id, e
);
(
Decision::FailClosedWrongShape,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
)
}
};
}
RESULT_REGISTERS => {
if !request.is_register_read() {
error!(
"Modbus {}: send_modbus_registers answered a {} request; registers \
only answer function codes 3 and 4",
connection_id,
request.function_name()
);
return (
Decision::FailClosedWrongShape,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
);
}
let in_range = |v: &serde_json::Value| {
v.as_u64()
.filter(|n| *n <= u16::MAX as u64)
.map(|n| n as u16)
};
let values: Vec<u16> = if let Some(map) = data.get("registers") {
match Self::read_from_map(connection_id, request, map, in_range) {
Ok(values) => values,
Err(absent) => return absent,
}
} else {
data.get("values")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(in_range).collect())
.unwrap_or_default()
};
if values.len() != request.quantity() as usize {
error!(
"Modbus {}: model returned {} register value(s) for a request of \
{}; answering with a device failure rather than a truncated frame",
connection_id,
values.len(),
request.quantity()
);
return (
Decision::FailClosedWrongShape,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
);
}
return match codec::encode_registers_response(fc, &values) {
Ok(pdu) => (Decision::ModelAnswer, pdu),
Err(e) => {
error!(
"Modbus {}: cannot encode a register response: {}",
connection_id, e
);
(
Decision::FailClosedWrongShape,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
)
}
};
}
RESULT_WRITE_ACK => {
if !request.is_write() {
error!(
"Modbus {}: send_modbus_write_ack answered a {} request, which is \
a read",
connection_id,
request.function_name()
);
return (
Decision::FailClosedWrongShape,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
);
}
return (Decision::ModelAnswer, codec::encode_write_ack(request));
}
_ => {}
}
}
error!(
"Modbus {}: no usable action returned for {}; answering with exception 0x04 \
(server device failure)",
connection_id,
request.function_name()
);
(
Decision::FailClosedNoAction,
codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
)
}
fn read_from_map<T>(
connection_id: ConnectionId,
request: &ModbusRequest,
map: &serde_json::Value,
read: impl Fn(&serde_json::Value) -> Option<T>,
) -> std::result::Result<Vec<T>, (Decision, Vec<u8>)> {
let start = u32::from(request.start_address());
let end = start + u32::from(request.quantity());
let mut values = Vec::with_capacity(request.quantity() as usize);
for address in start..end {
match map.get(address.to_string()) {
Some(value) => values.extend(read(value)),
None => {
debug!(
"Modbus {}: address {} of {} is not among the addresses the model gave; \
answering exception 2 (illegal data address)",
connection_id,
address,
request.function_name()
);
return Err((
Decision::ModelAddressAbsent,
codec::encode_exception(
request.function_code(),
codec::EXC_ILLEGAL_DATA_ADDRESS,
),
));
}
}
}
Ok(values)
}
#[allow(clippy::too_many_arguments)]
async fn send_pdu(
connection_id: ConnectionId,
server_id: crate::state::ServerId,
transaction_id: u16,
unit_id: u8,
pdu: &[u8],
app_state: &Arc<AppState>,
connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
status_tx: &mpsc::UnboundedSender<String>,
) {
let adu = match codec::encode_adu(transaction_id, unit_id, pdu) {
Ok(adu) => adu,
Err(e) => {
error!(
"Modbus {} decision=fail_closed_encode: refusing to frame a {}-octet PDU: {}",
connection_id,
pdu.len(),
e
);
return;
}
};
let write_half = {
let conns = connections.lock().await;
conns.get(&connection_id).map(|c| c.write_half.clone())
};
let Some(write_half) = write_half else {
return;
};
let mut write = write_half.lock().await;
if let Err(e) = write.write_all(&adu).await {
error!("Modbus write failed on {}: {}", connection_id, e);
return;
}
if let Err(e) = write.flush().await {
error!("Modbus flush failed on {}: {}", connection_id, e);
return;
}
drop(write);
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(adu.len() as u64),
None,
Some(1),
)
.await;
let log = Log::new(Some(status_tx));
log.debug(format!(
"Modbus sent {} bytes to {} (txid={}, fc={:#04x})",
adu.len(),
connection_id,
transaction_id,
pdu.first().copied().unwrap_or(0)
));
log.trace(format!("Modbus sent (hex): {}", hex::encode(&adu)));
}
async fn close(
connection_id: ConnectionId,
server_id: crate::state::ServerId,
app_state: &Arc<AppState>,
connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
status_tx: &mpsc::UnboundedSender<String>,
) {
let removed = {
let mut conns = connections.lock().await;
conns.remove(&connection_id)
};
let write_half = removed.map(|c| {
c.closed.notify_one();
c.write_half
});
if let Some(write_half) = write_half {
let mut w = write_half.lock().await;
let _ = w.shutdown().await;
}
app_state
.remove_peer_handle(server_id, connection_id.as_u32())
.await;
app_state
.close_connection_on_server(server_id, connection_id)
.await;
Log::new(Some(status_tx)).info(format!("Modbus connection {connection_id} closed"));
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
}