Skip to main content

netget/client/mqtt/
mod.rs

1//! MQTT client implementation, over rumqttc's 3.1.1 and 5.0 stacks.
2//!
3//! `protocol_version` picks the stack at connect time. Everything above the transport — the
4//! turn queue, the model calls, the injected-command loop — is shared: [`Broker`] is the one
5//! handle actions are applied through, [`Transport`] the one event loop polled, and
6//! [`Message`] the one shape a received PUBLISH reaches the model in. Only the two enums know
7//! there are two rumqttc APIs.
8pub mod actions;
9
10pub use actions::MqttClientProtocol;
11
12use anyhow::{Context, Result};
13use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet, QoS};
14use std::net::SocketAddr;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use std::time::Duration;
18use tokio::sync::mpsc;
19use tracing::{debug, error, info, trace, warn};
20
21use crate::protocol::StartupParams;
22
23use crate::client::llm_budget::call_llm_for_client;
24use crate::client::mqtt::actions::{MQTT_CLIENT_CONNECTED_EVENT, MQTT_MESSAGE_RECEIVED_EVENT};
25use crate::llm::actions::client_trait::{Client, ClientActionResult};
26use crate::llm::ollama_client::OllamaClient;
27use crate::llm::ClientLlmResult;
28use crate::protocol::Event as ProtocolEvent;
29use crate::state::app_state::AppState;
30use crate::state::{ClientId, ClientStatus};
31
32/// MQTT client that connects to an MQTT broker
33pub struct MqttClient;
34
35impl MqttClient {
36    /// Connect to an MQTT broker with integrated LLM actions
37    pub async fn connect_with_llm_actions(
38        remote_addr: String,
39        llm_client: OllamaClient,
40        app_state: Arc<AppState>,
41        status_tx: mpsc::UnboundedSender<String>,
42        client_id: ClientId,
43        startup_params: Option<StartupParams>,
44    ) -> Result<SocketAddr> {
45        // Parse remote address (host:port)
46        let parts: Vec<&str> = remote_addr.split(':').collect();
47        let host = parts
48            .get(0)
49            .context("Missing host in remote_addr")?
50            .to_string();
51        let port: u16 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(1883);
52
53        // Extract startup parameters
54        let mqtt_client_id = startup_params
55            .as_ref()
56            .map(|p| p.get_optional_string("client_id"))
57            .transpose()?
58            .flatten()
59            .unwrap_or_else(|| format!("netget-{}", client_id));
60
61        let username = startup_params
62            .as_ref()
63            .map(|p| p.get_optional_string("username"))
64            .transpose()?
65            .flatten();
66
67        let password = startup_params
68            .as_ref()
69            .map(|p| p.get_optional_string("password"))
70            .transpose()?
71            .flatten();
72
73        let keep_alive = startup_params
74            .as_ref()
75            .map(|p| p.get_optional_u64("keep_alive"))
76            .transpose()?
77            .flatten()
78            .unwrap_or(60);
79
80        let clean_session = startup_params
81            .as_ref()
82            .map(|p| p.get_optional_bool("clean_session"))
83            .transpose()?
84            .flatten()
85            .unwrap_or(true);
86
87        let version = startup_params
88            .as_ref()
89            .map(|p| p.get_optional_string("protocol_version"))
90            .transpose()?
91            .flatten()
92            .unwrap_or_else(|| DEFAULT_PROTOCOL_VERSION.to_string());
93        let v5 = match version.trim() {
94            "3.1.1" | "4" | "311" => false,
95            "5" | "5.0" | "v5" => true,
96            other => anyhow::bail!("protocol_version must be \"3.1.1\" or \"5.0\", not {other:?}"),
97        };
98
99        info!(
100            "MQTT client {} connecting to {}:{} with client_id={} (MQTT {})",
101            client_id,
102            host,
103            port,
104            mqtt_client_id,
105            if v5 { "5.0" } else { "3.1.1" }
106        );
107
108        let (mqtt_client, eventloop) = if v5 {
109            let mut options = rumqttc::v5::MqttOptions::new(&mqtt_client_id, &host, port);
110            options.set_keep_alive(Duration::from_secs(keep_alive));
111            options.set_clean_start(clean_session);
112            if let (Some(user), Some(pass)) = (username, password) {
113                options.set_credentials(user, pass);
114            }
115            let (c, e) = rumqttc::v5::AsyncClient::new(options, 10);
116            (Broker::V5(c), Transport::V5(Box::new(e)))
117        } else {
118            let mut options = MqttOptions::new(&mqtt_client_id, &host, port);
119            options.set_keep_alive(Duration::from_secs(keep_alive));
120            options.set_clean_session(clean_session);
121            if let (Some(user), Some(pass)) = (username, password) {
122                options.set_credentials(user, pass);
123            }
124            let (c, e) = AsyncClient::new(options, 10);
125            (Broker::V4(c), Transport::V4(Box::new(e)))
126        };
127
128        // For returning the local address, we need to extract it from the eventloop
129        // rumqttc doesn't expose local_addr directly, so we'll construct a fake SocketAddr
130        // based on the remote address. The actual TCP connection is managed internally.
131        let local_addr: SocketAddr = format!("0.0.0.0:0").parse().unwrap();
132
133        // Clone for the event loop task
134        let mqtt_client_clone = mqtt_client.clone();
135        let app_state_clone = app_state.clone();
136        let status_tx_clone = status_tx.clone();
137
138        // Set when a `disconnect` was asked for, so the event loop's read error that follows
139        // is reported as a clean disconnect rather than as a connection failure.
140        let disconnecting = Arc::new(AtomicBool::new(false));
141
142        // Command channel for injected actions (the dashboard's [ send ]). Registered here,
143        // before the transport task starts, so it exists throughout the connected-event LLM
144        // call the turn task makes on CONNACK - a manual `*` rule can park that call for
145        // minutes and the operator must still be able to publish.
146        let command_rx =
147            crate::client::command_support::register_command_channel(&app_state, client_id).await;
148        let cmd_client = mqtt_client.clone();
149        let cmd_state = app_state.clone();
150        let cmd_status_tx = status_tx.clone();
151        let cmd_disconnecting = disconnecting.clone();
152        let cmd_task = tokio::spawn(async move {
153            command_loop(
154                command_rx,
155                cmd_client,
156                client_id,
157                cmd_state,
158                cmd_status_tx,
159                cmd_disconnecting,
160            )
161            .await;
162        });
163        app_state.register_client_task(client_id, cmd_task).await;
164
165        // Two tasks per connection, because the transport and the model run on different
166        // clocks. rumqttc writes PINGREQ and reads PINGRESP only while `EventLoop::poll` is
167        // being awaited; a model turn can take minutes (a `manual` rule parks it for a human
168        // for up to 300s), and a broker drops a client that is silent for 1.5 x keep-alive.
169        // So `poll_transport` owns the event loop and never waits on the model, and
170        // `run_model_turns` answers events one at a time, in order, from a queue - which is
171        // also what keeps two turns from ever overlapping on this connection.
172        let (turn_tx, turn_rx) = mpsc::channel::<TurnEvent>(TURN_QUEUE_CAPACITY);
173        let turn_abort = app_state
174            .spawn_client_task(
175                client_id,
176                run_model_turns(
177                    turn_rx,
178                    mqtt_client_clone,
179                    llm_client,
180                    app_state_clone.clone(),
181                    status_tx_clone.clone(),
182                    client_id,
183                    mqtt_client_id,
184                    disconnecting.clone(),
185                ),
186            )
187            .await;
188        app_state
189            .spawn_client_task(
190                client_id,
191                poll_transport(
192                    eventloop,
193                    app_state_clone,
194                    status_tx_clone,
195                    client_id,
196                    disconnecting,
197                    turn_tx,
198                    turn_abort,
199                ),
200            )
201            .await;
202
203        Ok(local_addr)
204    }
205}
206
207/// How many broker events may wait for the model while a turn is running.
208///
209/// The transport task must never block on the model - that is the whole point of running it
210/// separately - so when this queue is full an incoming PUBLISH is dropped (and logged with
211/// `decision=turn_queue_full`) rather than stalling the keep-alive. Each entry is at most one
212/// packet of rumqttc's default 10 KiB incoming limit, so the queue bounds the memory a
213/// flooding broker can pin at ~2.5 MiB per client. 256 unanswered messages is already many
214/// minutes of model time; a backlog past that is not going to be caught up with.
215const TURN_QUEUE_CAPACITY: usize = 256;
216
217/// `protocol_version` when the caller names none.
218pub const DEFAULT_PROTOCOL_VERSION: &str = "3.1.1";
219
220/// One thing the model is asked about, in arrival order.
221enum TurnEvent {
222    /// The first CONNACK: the client's chance to subscribe or publish first. On v5 it carries
223    /// what the CONNACK said (reason code and properties).
224    Connected(Option<serde_json::Value>),
225    /// A PUBLISH delivered by the broker.
226    Message(Message),
227}
228
229/// A PUBLISH from the broker, in either version.
230struct Message {
231    topic: String,
232    payload: Vec<u8>,
233    qos: u8,
234    retain: bool,
235    /// v5 only: the message's properties as the model sees them.
236    properties: Option<serde_json::Value>,
237}
238
239/// The request side of a connection: what actions are applied through.
240#[derive(Clone)]
241enum Broker {
242    V4(AsyncClient),
243    V5(rumqttc::v5::AsyncClient),
244}
245
246/// Subscription options the model may set. Only v5 has anything beyond QoS.
247#[derive(Clone, Copy, Default)]
248struct SubscribeOptions {
249    no_local: bool,
250    retain_as_published: bool,
251    retain_handling: u8,
252}
253
254impl Broker {
255    fn is_v5(&self) -> bool {
256        matches!(self, Broker::V5(_))
257    }
258
259    async fn subscribe(&self, topic: &str, qos: u8, opts: SubscribeOptions) -> Result<()> {
260        match self {
261            Broker::V4(c) => c.subscribe(topic, qos_from_u64(u64::from(qos))).await?,
262            Broker::V5(c) => {
263                use rumqttc::v5::mqttbytes::v5::{Filter, RetainForwardRule};
264                let mut filter = Filter::new(topic, v5_qos(qos));
265                filter.nolocal = opts.no_local;
266                filter.preserve_retain = opts.retain_as_published;
267                filter.retain_forward_rule = match opts.retain_handling {
268                    1 => RetainForwardRule::OnNewSubscribe,
269                    2 => RetainForwardRule::Never,
270                    _ => RetainForwardRule::OnEverySubscribe,
271                };
272                c.subscribe_many(vec![filter]).await?
273            }
274        }
275        Ok(())
276    }
277
278    async fn publish(
279        &self,
280        topic: &str,
281        qos: u8,
282        retain: bool,
283        payload: Vec<u8>,
284        properties: Option<&serde_json::Value>,
285    ) -> Result<()> {
286        match self {
287            Broker::V4(c) => {
288                c.publish(topic, qos_from_u64(u64::from(qos)), retain, payload)
289                    .await?
290            }
291            Broker::V5(c) => match properties {
292                Some(p) => {
293                    c.publish_with_properties(
294                        topic,
295                        v5_qos(qos),
296                        retain,
297                        payload,
298                        publish_properties(p)?,
299                    )
300                    .await?
301                }
302                None => c.publish(topic, v5_qos(qos), retain, payload).await?,
303            },
304        }
305        Ok(())
306    }
307
308    async fn unsubscribe(&self, topic: &str) -> Result<()> {
309        match self {
310            Broker::V4(c) => c.unsubscribe(topic).await?,
311            Broker::V5(c) => c.unsubscribe(topic).await?,
312        }
313        Ok(())
314    }
315
316    async fn disconnect(&self) -> Result<()> {
317        match self {
318            Broker::V4(c) => c.disconnect().await?,
319            Broker::V5(c) => c.disconnect().await?,
320        }
321        Ok(())
322    }
323}
324
325fn v5_qos(qos: u8) -> rumqttc::v5::mqttbytes::QoS {
326    use rumqttc::v5::mqttbytes::QoS as Q;
327    match qos {
328        1 => Q::AtLeastOnce,
329        2 => Q::ExactlyOnce,
330        _ => Q::AtMostOnce,
331    }
332}
333
334/// The model's `properties` object (validated by the action executor) as rumqttc's.
335fn publish_properties(
336    p: &serde_json::Value,
337) -> Result<rumqttc::v5::mqttbytes::v5::PublishProperties> {
338    use crate::server::mqtt::v5 as props;
339    let text = |k: &str| p.get(k).and_then(|v| v.as_str()).map(str::to_string);
340    let correlation_data = match text("correlation_data") {
341        Some(c) => Some(
342            props::text_to_binary(
343                &c,
344                p.get("correlation_data_encoding").and_then(|e| e.as_str()),
345            )
346            .map_err(|e| anyhow::anyhow!("correlation_data: {e}"))?
347            .into(),
348        ),
349        None => None,
350    };
351    Ok(rumqttc::v5::mqttbytes::v5::PublishProperties {
352        payload_format_indicator: p
353            .get("payload_format_indicator")
354            .and_then(|v| v.as_u64())
355            .map(|v| v as u8),
356        message_expiry_interval: p
357            .get("message_expiry_interval")
358            .and_then(|v| v.as_u64())
359            .map(|v| v as u32),
360        topic_alias: None,
361        response_topic: text("response_topic"),
362        correlation_data,
363        user_properties: props::user_properties_from_json(p.get("user_properties"))
364            .map_err(|e| anyhow::anyhow!(e))?,
365        subscription_identifiers: Vec::new(),
366        content_type: text("content_type"),
367    })
368}
369
370/// What the transport saw, in either version.
371enum Notice {
372    ConnAck(Option<serde_json::Value>),
373    Publish(Message),
374    /// The broker closed the session; on v5 its DISCONNECT may say why.
375    Disconnect(Option<String>),
376    /// The broker broke the protocol in a way this client ends the connection over. Carries
377    /// the category only, never the packet's content.
378    ProtocolViolation(&'static str),
379    Other,
380}
381
382/// What a v5 PUBLISH carrying a Topic Alias is reported as.
383pub const TOPIC_ALIAS_VIOLATION: &str = "topic alias";
384
385/// The event-loop side of a connection.
386enum Transport {
387    V4(Box<EventLoop>),
388    V5(Box<rumqttc::v5::EventLoop>),
389}
390
391impl Transport {
392    async fn next(&mut self) -> std::result::Result<Notice, String> {
393        match self {
394            Transport::V4(e) => match e.poll().await.map_err(|e| e.to_string())? {
395                Event::Incoming(Packet::ConnAck(_)) => Ok(Notice::ConnAck(None)),
396                Event::Incoming(Packet::Publish(p)) => Ok(Notice::Publish(Message {
397                    topic: p.topic.clone(),
398                    payload: p.payload.to_vec(),
399                    qos: p.qos as u8,
400                    retain: p.retain,
401                    properties: None,
402                })),
403                Event::Incoming(Packet::Disconnect) => Ok(Notice::Disconnect(None)),
404                other => {
405                    trace!("MQTT event: {:?}", other);
406                    Ok(Notice::Other)
407                }
408            },
409            Transport::V5(e) => {
410                use rumqttc::v5::mqttbytes::v5::Packet as P5;
411                use rumqttc::v5::Event as E5;
412                match e.poll().await.map_err(|e| e.to_string())? {
413                    E5::Incoming(P5::ConnAck(ack)) => {
414                        let mut info = serde_json::json!({
415                            "reason_code": connack_code(&ack.code),
416                            "session_present": ack.session_present,
417                        });
418                        if let Some(p) = &ack.properties {
419                            info["properties"] = connack_properties_json(p);
420                        }
421                        Ok(Notice::ConnAck(Some(info)))
422                    }
423                    // NetGet's CONNECT sends no Topic Alias Maximum, which is 0, so the broker
424                    // MUST NOT send an alias (§3.1.2.11.5, [MQTT-3.1.2-27]). rumqttc does not
425                    // check that: it stores every alias it reads in a map of its own with no
426                    // bound, before the PUBLISH reaches this code. So the first aliased
427                    // PUBLISH ends the connection. What rumqttc stored by then is bounded by
428                    // one read batch (it handles at most 10 packets before yielding the first,
429                    // each within its 10 KiB incoming limit), and dropping the event loop frees
430                    // it. rumqttc's socket is private, so no DISCONNECT 0x94 precedes the close.
431                    E5::Incoming(P5::Publish(p))
432                        if p.properties
433                            .as_ref()
434                            .is_some_and(|props| props.topic_alias.is_some()) =>
435                    {
436                        Ok(Notice::ProtocolViolation(TOPIC_ALIAS_VIOLATION))
437                    }
438                    E5::Incoming(P5::Publish(p)) => Ok(Notice::Publish(Message {
439                        topic: String::from_utf8_lossy(&p.topic).into_owned(),
440                        payload: p.payload.to_vec(),
441                        qos: p.qos as u8,
442                        retain: p.retain,
443                        properties: Some(
444                            p.properties
445                                .as_ref()
446                                .map(publish_properties_json)
447                                .unwrap_or_else(|| serde_json::json!({})),
448                        ),
449                    })),
450                    E5::Incoming(P5::Disconnect(d)) => Ok(Notice::Disconnect(Some(format!(
451                        "reason 0x{:02x}{}",
452                        d.reason_code as u8,
453                        d.properties
454                            .as_ref()
455                            .and_then(|p| p.reason_string.as_ref())
456                            .map(|r| format!(" ({r})"))
457                            .unwrap_or_default()
458                    )))),
459                    other => {
460                        trace!("MQTT v5 event: {:?}", other);
461                        Ok(Notice::Other)
462                    }
463                }
464            }
465        }
466    }
467}
468
469/// A v5 CONNACK reason as its wire value (§3.2.2.2).
470fn connack_code(code: &rumqttc::v5::mqttbytes::v5::ConnectReturnCode) -> u8 {
471    use rumqttc::v5::mqttbytes::v5::ConnectReturnCode as C;
472    match code {
473        C::Success => 0x00,
474        C::RefusedProtocolVersion | C::UnsupportedProtocolVersion => 0x84,
475        C::BadClientId | C::ClientIdentifierNotValid => 0x85,
476        C::ServiceUnavailable | C::ServerUnavailable => 0x88,
477        C::UnspecifiedError => 0x80,
478        C::MalformedPacket => 0x81,
479        C::ProtocolError => 0x82,
480        C::ImplementationSpecificError => 0x83,
481        C::BadUserNamePassword => 0x86,
482        C::NotAuthorized => 0x87,
483        C::ServerBusy => 0x89,
484        C::Banned => 0x8A,
485        C::BadAuthenticationMethod => 0x8C,
486        C::TopicNameInvalid => 0x90,
487        C::PacketTooLarge => 0x95,
488        C::QuotaExceeded => 0x97,
489        C::PayloadFormatInvalid => 0x99,
490        C::RetainNotSupported => 0x9A,
491        C::QoSNotSupported => 0x9B,
492        C::UseAnotherServer => 0x9C,
493        C::ServerMoved => 0x9D,
494        C::ConnectionRateExceeded => 0x9F,
495    }
496}
497
498fn pairs_json(pairs: &[(String, String)]) -> serde_json::Value {
499    serde_json::Value::Array(
500        pairs
501            .iter()
502            .map(|(k, v)| serde_json::json!([k, v]))
503            .collect(),
504    )
505}
506
507/// CONNACK properties under the server's own snake_case names.
508fn connack_properties_json(p: &rumqttc::v5::mqttbytes::v5::ConnAckProperties) -> serde_json::Value {
509    let mut out = serde_json::Map::new();
510    let mut put = |k: &str, v: serde_json::Value| {
511        if !v.is_null() {
512            out.insert(k.to_string(), v);
513        }
514    };
515    put(
516        "session_expiry_interval",
517        serde_json::json!(p.session_expiry_interval),
518    );
519    put("receive_maximum", serde_json::json!(p.receive_max));
520    put("maximum_qos", serde_json::json!(p.max_qos));
521    put("retain_available", serde_json::json!(p.retain_available));
522    put("maximum_packet_size", serde_json::json!(p.max_packet_size));
523    put(
524        "assigned_client_identifier",
525        serde_json::json!(p.assigned_client_identifier),
526    );
527    put("topic_alias_maximum", serde_json::json!(p.topic_alias_max));
528    put("reason_string", serde_json::json!(p.reason_string));
529    put("server_keep_alive", serde_json::json!(p.server_keep_alive));
530    put(
531        "response_information",
532        serde_json::json!(p.response_information),
533    );
534    put("server_reference", serde_json::json!(p.server_reference));
535    if !p.user_properties.is_empty() {
536        put("user_properties", pairs_json(&p.user_properties));
537    }
538    serde_json::Value::Object(out)
539}
540
541/// PUBLISH properties under the server's own snake_case names, binary as text or hex.
542fn publish_properties_json(p: &rumqttc::v5::mqttbytes::v5::PublishProperties) -> serde_json::Value {
543    let mut out = serde_json::Map::new();
544    if let Some(v) = p.payload_format_indicator {
545        out.insert("payload_format_indicator".into(), serde_json::json!(v));
546    }
547    if let Some(v) = p.message_expiry_interval {
548        out.insert("message_expiry_interval".into(), serde_json::json!(v));
549    }
550    if let Some(v) = &p.content_type {
551        out.insert("content_type".into(), serde_json::json!(v));
552    }
553    if let Some(v) = &p.response_topic {
554        out.insert("response_topic".into(), serde_json::json!(v));
555    }
556    if let Some(v) = &p.correlation_data {
557        let (text, encoding) = crate::server::mqtt::v5::binary_to_text(v);
558        out.insert("correlation_data".into(), serde_json::json!(text));
559        out.insert(
560            "correlation_data_encoding".into(),
561            serde_json::json!(encoding),
562        );
563    }
564    if !p.subscription_identifiers.is_empty() {
565        out.insert(
566            "subscription_identifiers".into(),
567            serde_json::json!(p.subscription_identifiers),
568        );
569    }
570    if !p.user_properties.is_empty() {
571        out.insert("user_properties".into(), pairs_json(&p.user_properties));
572    }
573    serde_json::Value::Object(out)
574}
575
576/// Own the rumqttc event loop for the life of the connection.
577///
578/// Awaiting `poll` is what drives the MQTT transport: it writes queued requests (the model's
579/// publishes and subscribes, injected commands), sends PINGREQ once per keep-alive interval
580/// and reads PINGRESP. Nothing here waits on the model; events it needs answered go onto the
581/// turn queue. Status (Connected / Disconnected / Error) is set here, so it stays true while a
582/// turn is parked.
583async fn poll_transport(
584    mut eventloop: Transport,
585    app_state: Arc<AppState>,
586    status_tx: mpsc::UnboundedSender<String>,
587    client_id: ClientId,
588    disconnecting: Arc<AtomicBool>,
589    turn_tx: mpsc::Sender<TurnEvent>,
590    turn_abort: tokio::task::AbortHandle,
591) {
592    let mut connected = false;
593
594    loop {
595        match eventloop.next().await {
596            Ok(notification) => {
597                match notification {
598                    Notice::ConnAck(info) => {
599                        if !connected {
600                            connected = true;
601                            info!("MQTT client {} connected to broker", client_id);
602                            app_state
603                                .update_client_status(client_id, ClientStatus::Connected)
604                                .await;
605                            let _ = status_tx
606                                .send(format!("[CLIENT] MQTT client {} connected", client_id));
607                            let _ = status_tx.send("__UPDATE_UI__".to_string());
608                            enqueue_turn(&turn_tx, TurnEvent::Connected(info), client_id);
609                        }
610                    }
611                    Notice::Publish(publish) => {
612                        debug!(
613                            "MQTT client {} received message on topic '{}': {} bytes",
614                            client_id,
615                            publish.topic,
616                            publish.payload.len()
617                        );
618                        enqueue_turn(&turn_tx, TurnEvent::Message(publish), client_id);
619                    }
620                    Notice::Disconnect(why) => {
621                        let why = why.map(|w| format!(" ({w})")).unwrap_or_default();
622                        info!("MQTT client {} disconnected by broker{}", client_id, why);
623                        app_state
624                            .update_client_status(client_id, ClientStatus::Disconnected)
625                            .await;
626                        let _ = status_tx.send(format!(
627                            "[CLIENT] MQTT client {} disconnected by broker{}",
628                            client_id, why
629                        ));
630                        let _ = status_tx.send("__UPDATE_UI__".to_string());
631                        break;
632                    }
633                    Notice::ProtocolViolation(category) => {
634                        // Returning ends the connection: the event loop, its socket and
635                        // whatever rumqttc stored for it are dropped below.
636                        warn!(
637                            "MQTT client {} closing: broker sent a {} \
638                             decision=protocol_error",
639                            client_id, category
640                        );
641                        app_state
642                            .update_client_status(
643                                client_id,
644                                ClientStatus::Error(format!("protocol violation: {category}")),
645                            )
646                            .await;
647                        let _ = status_tx.send(format!(
648                            "[CLIENT] MQTT client {} closed the connection: protocol violation \
649                             ({})",
650                            client_id, category
651                        ));
652                        let _ = status_tx.send("__UPDATE_UI__".to_string());
653                        break;
654                    }
655                    // PINGREQ / PINGRESP, acknowledgements and outgoing packets are traced by
656                    // `Transport::next`.
657                    Notice::Other => {}
658                }
659            }
660            Err(e) => {
661                // A `disconnect` action closes the socket, and rumqttc reports the closed
662                // socket as a poll error. Reporting that as ClientStatus::Error would draw a
663                // deliberate hang-up as a failure, so the requested case is separated out.
664                if disconnecting.load(Ordering::SeqCst) {
665                    info!("MQTT client {} disconnected on request", client_id);
666                    app_state
667                        .update_client_status(client_id, ClientStatus::Disconnected)
668                        .await;
669                    let _ =
670                        status_tx.send(format!("[CLIENT] MQTT client {} disconnected", client_id));
671                } else {
672                    error!("MQTT client {} connection error: {}", client_id, e);
673                    app_state
674                        .update_client_status(client_id, ClientStatus::Error(e.to_string()))
675                        .await;
676                    let _ =
677                        status_tx.send(format!("[CLIENT] MQTT client {} error: {}", client_id, e));
678                }
679                let _ = status_tx.send("__UPDATE_UI__".to_string());
680                break;
681            }
682        }
683    }
684
685    // The connection is gone, so a turn still running - or parked for a human - has nothing
686    // left to answer on, and the queued events behind it would each cost a model call for a
687    // reply that cannot be sent.
688    turn_abort.abort();
689    // Every exit path lands here: drop the command handle so the dashboard stops offering
690    // [ send ] on a dead connection. It also closes the command channel, which ends
691    // `command_loop`.
692    app_state.remove_client_handle(client_id).await;
693    let _ = status_tx.send("__UPDATE_UI__".to_string());
694}
695
696/// Put an event on the turn queue without ever waiting for room.
697fn enqueue_turn(turn_tx: &mpsc::Sender<TurnEvent>, event: TurnEvent, client_id: ClientId) {
698    match turn_tx.try_send(event) {
699        Ok(()) => {}
700        Err(mpsc::error::TrySendError::Full(TurnEvent::Message(publish))) => {
701            warn!(
702                "MQTT client {} dropped a message on '{}': {} events already wait for the \
703                 model decision=turn_queue_full",
704                client_id, publish.topic, TURN_QUEUE_CAPACITY
705            );
706        }
707        Err(mpsc::error::TrySendError::Full(TurnEvent::Connected(_))) => {
708            warn!(
709                "MQTT client {} dropped its connected event decision=turn_queue_full",
710                client_id
711            );
712        }
713        // The turn task has ended (aborted with the client); nothing to answer.
714        Err(mpsc::error::TrySendError::Closed(_)) => {}
715    }
716}
717
718/// Answer queued events with the model, one at a time and in arrival order.
719#[allow(clippy::too_many_arguments)]
720async fn run_model_turns(
721    mut turn_rx: mpsc::Receiver<TurnEvent>,
722    mqtt_client: Broker,
723    llm_client: OllamaClient,
724    app_state: Arc<AppState>,
725    status_tx: mpsc::UnboundedSender<String>,
726    client_id: ClientId,
727    mqtt_client_id: String,
728    disconnecting: Arc<AtomicBool>,
729) {
730    while let Some(turn) = turn_rx.recv().await {
731        match turn {
732            TurnEvent::Connected(info) => {
733                handle_connected(
734                    info,
735                    &mqtt_client,
736                    &llm_client,
737                    &app_state,
738                    &status_tx,
739                    client_id,
740                    &mqtt_client_id,
741                    &disconnecting,
742                )
743                .await;
744            }
745            TurnEvent::Message(publish) => {
746                handle_incoming_message(
747                    &publish,
748                    &mqtt_client,
749                    &llm_client,
750                    &app_state,
751                    &status_tx,
752                    client_id,
753                    &disconnecting,
754                )
755                .await;
756            }
757        }
758    }
759}
760
761/// Ask the model what to do now that the broker accepted the CONNECT.
762#[allow(clippy::too_many_arguments)]
763async fn handle_connected(
764    connack: Option<serde_json::Value>,
765    mqtt_client: &Broker,
766    llm_client: &OllamaClient,
767    app_state: &Arc<AppState>,
768    status_tx: &mpsc::UnboundedSender<String>,
769    client_id: ClientId,
770    mqtt_client_id: &str,
771    disconnecting: &Arc<AtomicBool>,
772) {
773    let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
774        return;
775    };
776    let protocol = Arc::new(MqttClientProtocol::new());
777    let mut data = serde_json::json!({
778        "remote_addr": "connected",
779        "client_id": mqtt_client_id,
780        "protocol_version": if mqtt_client.is_v5() { "5.0" } else { "3.1.1" },
781    });
782    // v5 CONNACK: its reason code and properties (an assigned client id among them).
783    if let Some(serde_json::Value::Object(ack)) = connack {
784        for (k, v) in ack {
785            data[k] = v;
786        }
787    }
788    let event = ProtocolEvent::new(&MQTT_CLIENT_CONNECTED_EVENT, data);
789
790    let memory = app_state
791        .get_memory_for_client(client_id)
792        .await
793        .unwrap_or_default();
794
795    match call_llm_for_client(
796        llm_client,
797        app_state,
798        client_id.to_string(),
799        &instruction,
800        &memory,
801        Some(&event),
802        protocol.as_ref(),
803        status_tx,
804    )
805    .await
806    {
807        Ok(result) => {
808            handle_llm_actions(
809                result,
810                mqtt_client,
811                app_state,
812                client_id,
813                &protocol,
814                disconnecting,
815            )
816            .await;
817        }
818        Err(e) => {
819            error!("LLM error for MQTT client {}: {}", client_id, e);
820        }
821    }
822}
823
824/// Handle incoming MQTT message
825#[allow(clippy::too_many_arguments)]
826async fn handle_incoming_message(
827    publish: &Message,
828    mqtt_client: &Broker,
829    llm_client: &OllamaClient,
830    app_state: &Arc<AppState>,
831    status_tx: &mpsc::UnboundedSender<String>,
832    client_id: ClientId,
833    disconnecting: &Arc<AtomicBool>,
834) {
835    let topic = publish.topic.clone();
836    let payload = String::from_utf8_lossy(&publish.payload).to_string();
837    let qos = publish.qos;
838    let retain = publish.retain;
839
840    // Call LLM with message received event
841    if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
842        let protocol = Arc::new(MqttClientProtocol::new());
843        let mut data = serde_json::json!({
844            "topic": topic,
845            "payload": payload,
846            "qos": qos,
847            "retain": retain,
848        });
849        if let Some(properties) = &publish.properties {
850            data["properties"] = properties.clone();
851        }
852        let event = ProtocolEvent::new(&MQTT_MESSAGE_RECEIVED_EVENT, data);
853
854        let memory = app_state
855            .get_memory_for_client(client_id)
856            .await
857            .unwrap_or_default();
858
859        match call_llm_for_client(
860            llm_client,
861            app_state,
862            client_id.to_string(),
863            &instruction,
864            &memory,
865            Some(&event),
866            protocol.as_ref(),
867            status_tx,
868        )
869        .await
870        {
871            Ok(result) => {
872                handle_llm_actions(
873                    result,
874                    mqtt_client,
875                    app_state,
876                    client_id,
877                    &protocol,
878                    disconnecting,
879                )
880                .await;
881            }
882            Err(e) => {
883                error!("LLM error for MQTT client {}: {}", client_id, e);
884            }
885        }
886    }
887}
888
889/// What [`apply_action`] did with one executed action.
890enum MqttApplied {
891    /// Handed to rumqttc, whose event loop writes the packet on the socket. rumqttc returns
892    /// as soon as the request is accepted and reports no byte count, so this is deliberately
893    /// not a "sent N bytes" claim.
894    Queued(String),
895    /// Ran, but nothing was put on the wire.
896    NoWire(String),
897    /// A disconnect was requested and sent to the broker.
898    Disconnect,
899}
900
901/// Apply one executed action to the live broker connection.
902///
903/// Shared by the LLM path ([`handle_llm_actions`]) and by injected commands
904/// ([`command_loop`]), so the mapping from `mqtt_publish`/`mqtt_subscribe`/`mqtt_unsubscribe`
905/// onto `rumqttc` calls exists exactly once and an injected action behaves identically to an
906/// LLM-produced one.
907async fn apply_action(
908    action_result: ClientActionResult,
909    mqtt_client: &Broker,
910    client_id: ClientId,
911    disconnecting: &Arc<AtomicBool>,
912) -> Result<MqttApplied> {
913    match action_result {
914        ClientActionResult::Custom { name, data } => match name.as_str() {
915            "mqtt_subscribe" => {
916                let topics = data
917                    .get("topics")
918                    .and_then(|v| v.as_array())
919                    .context("mqtt_subscribe result has no 'topics' array")?;
920                let qos = data.get("qos").and_then(|v| v.as_u64()).unwrap_or(0);
921                let flag = |k: &str| data.get(k).and_then(|v| v.as_bool()).unwrap_or(false);
922                let opts = SubscribeOptions {
923                    no_local: flag("no_local"),
924                    retain_as_published: flag("retain_as_published"),
925                    retain_handling: data
926                        .get("retain_handling")
927                        .and_then(|v| v.as_u64())
928                        .unwrap_or(0) as u8,
929                };
930
931                let mut subscribed: Vec<String> = Vec::new();
932                for topic in topics {
933                    if let Some(topic_str) = topic.as_str() {
934                        mqtt_client
935                            .subscribe(topic_str, qos as u8, opts)
936                            .await
937                            .with_context(|| format!("subscribe to '{topic_str}' failed"))?;
938                        info!(
939                            "MQTT client {} subscribed to '{}' with QoS {}",
940                            client_id, topic_str, qos
941                        );
942                        subscribed.push(topic_str.to_string());
943                    }
944                }
945                if subscribed.is_empty() {
946                    return Ok(MqttApplied::NoWire(
947                        "mqtt_subscribe: no valid topic strings".to_string(),
948                    ));
949                }
950                Ok(MqttApplied::Queued(format!(
951                    "SUBSCRIBE to [{}] at QoS {} accepted by rumqttc",
952                    subscribed.join(", "),
953                    qos
954                )))
955            }
956            "mqtt_publish" => {
957                let topic = data
958                    .get("topic")
959                    .and_then(|v| v.as_str())
960                    .context("mqtt_publish result has no 'topic'")?;
961                let payload = data
962                    .get("payload")
963                    .and_then(|v| v.as_str())
964                    .context("mqtt_publish result has no 'payload'")?;
965                let qos = data.get("qos").and_then(|v| v.as_u64()).unwrap_or(0);
966                let retain = data
967                    .get("retain")
968                    .and_then(|v| v.as_bool())
969                    .unwrap_or(false);
970
971                let properties = data.get("properties").filter(|p| !p.is_null());
972                if properties.is_some() && !mqtt_client.is_v5() {
973                    anyhow::bail!(
974                        "PUBLISH properties need MQTT 5; this client connected with 3.1.1 \
975                         (start it with protocol_version \"5.0\")"
976                    );
977                }
978                mqtt_client
979                    .publish(
980                        topic,
981                        qos as u8,
982                        retain,
983                        payload.as_bytes().to_vec(),
984                        properties,
985                    )
986                    .await
987                    .with_context(|| format!("publish to '{topic}' failed"))?;
988                info!(
989                    "MQTT client {} published to '{}': {}",
990                    client_id, topic, payload
991                );
992                Ok(MqttApplied::Queued(format!(
993                    "PUBLISH to '{}' ({} byte payload, QoS {}, retain {}) accepted by rumqttc",
994                    topic,
995                    payload.len(),
996                    qos,
997                    retain
998                )))
999            }
1000            "mqtt_unsubscribe" => {
1001                let topics = data
1002                    .get("topics")
1003                    .and_then(|v| v.as_array())
1004                    .context("mqtt_unsubscribe result has no 'topics' array")?;
1005                let mut removed: Vec<String> = Vec::new();
1006                for topic in topics {
1007                    if let Some(topic_str) = topic.as_str() {
1008                        mqtt_client
1009                            .unsubscribe(topic_str)
1010                            .await
1011                            .with_context(|| format!("unsubscribe from '{topic_str}' failed"))?;
1012                        info!(
1013                            "MQTT client {} unsubscribed from '{}'",
1014                            client_id, topic_str
1015                        );
1016                        removed.push(topic_str.to_string());
1017                    }
1018                }
1019                if removed.is_empty() {
1020                    return Ok(MqttApplied::NoWire(
1021                        "mqtt_unsubscribe: no valid topic strings".to_string(),
1022                    ));
1023                }
1024                Ok(MqttApplied::Queued(format!(
1025                    "UNSUBSCRIBE from [{}] accepted by rumqttc",
1026                    removed.join(", ")
1027                )))
1028            }
1029            other => Err(anyhow::anyhow!(
1030                "MQTT client cannot apply custom result '{}'",
1031                other
1032            )),
1033        },
1034        ClientActionResult::Disconnect => {
1035            info!("MQTT client {} disconnecting", client_id);
1036            // Set before the request goes out, so the poll error rumqttc raises for the
1037            // closed socket is already attributable when the event loop sees it.
1038            disconnecting.store(true, Ordering::SeqCst);
1039            mqtt_client
1040                .disconnect()
1041                .await
1042                .inspect_err(|_| disconnecting.store(false, Ordering::SeqCst))
1043                .context("disconnect request failed")?;
1044            Ok(MqttApplied::Disconnect)
1045        }
1046        ClientActionResult::WaitForMore => Ok(MqttApplied::NoWire(
1047            "waiting for the next broker message; nothing sent".to_string(),
1048        )),
1049        ClientActionResult::NoAction => {
1050            Ok(MqttApplied::NoWire("no_action: nothing sent".to_string()))
1051        }
1052        ClientActionResult::SendData(_) => {
1053            Err(anyhow::anyhow!("MQTT has no raw-byte channel; use publish"))
1054        }
1055        ClientActionResult::Multiple(_) => Err(anyhow::anyhow!(
1056            "MQTT client does not support Multiple action results"
1057        )),
1058    }
1059}
1060
1061fn qos_from_u64(qos: u64) -> QoS {
1062    match qos {
1063        1 => QoS::AtLeastOnce,
1064        2 => QoS::ExactlyOnce,
1065        _ => QoS::AtMostOnce,
1066    }
1067}
1068
1069/// Handle LLM action results
1070async fn handle_llm_actions(
1071    result: ClientLlmResult,
1072    mqtt_client: &Broker,
1073    app_state: &Arc<AppState>,
1074    client_id: ClientId,
1075    protocol: &Arc<MqttClientProtocol>,
1076    disconnecting: &Arc<AtomicBool>,
1077) {
1078    // Update memory
1079    if let Some(mem) = result.memory_updates {
1080        app_state.set_memory_for_client(client_id, mem).await;
1081    }
1082
1083    // Execute actions
1084    for action in result.actions {
1085        match protocol.execute_action(action) {
1086            Ok(action_result) => {
1087                match apply_action(action_result, mqtt_client, client_id, disconnecting).await {
1088                    Ok(MqttApplied::Queued(detail)) => {
1089                        debug!("MQTT client {}: {}", client_id, detail);
1090                    }
1091                    Ok(MqttApplied::NoWire(detail)) => {
1092                        debug!("MQTT client {}: {}", client_id, detail);
1093                    }
1094                    Ok(MqttApplied::Disconnect) => {}
1095                    Err(e) => {
1096                        error!("MQTT client {} could not apply action: {}", client_id, e);
1097                    }
1098                }
1099            }
1100            Err(e) => {
1101                error!("Error executing MQTT action: {}", e);
1102            }
1103        }
1104    }
1105}
1106
1107/// Drain injected commands (the dashboard's \[ send \]) until the channel closes - which
1108/// happens when the client is removed or the broker event loop exits - or until an injected
1109/// `disconnect` ends the session.
1110///
1111/// This is option (a) of the library-driven archetype: `rumqttc::AsyncClient` is a cheap
1112/// clonable handle to the event loop's request channel, so no `Arc<Mutex<_>>` and no
1113/// restructuring was needed - the command loop holds its own clone and applies actions
1114/// through the same [`apply_action`] the LLM path uses.
1115async fn command_loop(
1116    mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
1117    mqtt_client: Broker,
1118    client_id: ClientId,
1119    app_state: Arc<AppState>,
1120    status_tx: mpsc::UnboundedSender<String>,
1121    disconnecting: Arc<AtomicBool>,
1122) {
1123    use crate::llm::actions::protocol_trait::Protocol;
1124    use crate::state::client_handles::ClientSendOutcome;
1125    use crate::state::AccessLogOwner;
1126
1127    let protocol = MqttClientProtocol::new();
1128
1129    while let Some(command) = command_rx.recv().await {
1130        let action = command.action.clone();
1131        let outcome: Result<ClientSendOutcome> = match protocol.execute_action(action.clone()) {
1132            Err(e) => Ok(ClientSendOutcome::Rejected {
1133                error: e.to_string(),
1134            }),
1135            Ok(result) => match apply_action(result, &mqtt_client, client_id, &disconnecting).await
1136            {
1137                // Never `Sent`: rumqttc accepts the request into its event loop's queue and
1138                // returns; the bytes are written by that loop afterwards and it reports no
1139                // count, so claiming a byte count here would be a guess.
1140                Ok(MqttApplied::Queued(detail)) => Ok(ClientSendOutcome::Executed { detail }),
1141                Ok(MqttApplied::NoWire(detail)) => Ok(ClientSendOutcome::Executed { detail }),
1142                Ok(MqttApplied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
1143                Err(e) => Err(e),
1144            },
1145        };
1146
1147        let outcome_json = match &outcome {
1148            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
1149            Err(e) => serde_json::json!({"error": e.to_string()}),
1150        };
1151        app_state
1152            .record_access_log(
1153                AccessLogOwner::Client(client_id.as_u32()),
1154                protocol.protocol_name(),
1155                None,
1156                "injected_action",
1157                action,
1158                vec![outcome_json],
1159            )
1160            .await;
1161
1162        let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1163        if let Err(e) = &outcome {
1164            error!("MQTT client {} injected action failed: {}", client_id, e);
1165            let _ = status_tx.send(format!(
1166                "[WARN] Client {} injected action failed: {}",
1167                client_id, e
1168            ));
1169        }
1170        let _ = status_tx.send("__UPDATE_UI__".to_string());
1171        crate::client::command_support::reply(command, outcome);
1172
1173        if disconnect {
1174            // The event loop drops the handle on its way out too; doing it here as well means
1175            // the dashboard stops offering [ send ] the moment the DISCONNECT went out.
1176            app_state.remove_client_handle(client_id).await;
1177            break;
1178        }
1179    }
1180}