Skip to main content

bambu_rs/
client.rs

1//! LAN MQTT client — the I/O layer.
2//!
3//! One-shot, stateless cycle (connect → `pushall` → collect snapshot →
4//! disconnect), which suits a CLI invocation and respects the A1/P1 single-MQTT-
5//! connection limit. Built on `rumqttc` + `rustls`; the printer presents a
6//! self-signed certificate with no CA chain, so we accept any certificate while
7//! still validating the TLS handshake signatures.
8//!
9//! [`StatusSource`] abstracts snapshot fetching so consumers (and tests) don't
10//! depend on the concrete MQTT client.
11
12use std::time::Duration;
13
14use rumqttc::{
15    AsyncClient, Event, EventLoop, MqttOptions, Packet, QoS, TlsConfiguration, Transport,
16};
17use serde_json::Value;
18
19use crate::config::ResolvedTarget;
20use crate::core::command::{Command, SequenceIds};
21use crate::core::report::{ReportState, is_full_snapshot_message};
22use crate::core::session::VerifySession;
23use crate::core::version::DeviceVersion;
24
25// Verify-result types live in `core` (pure, I/O-free) and are re-exported here so
26// existing `client::{CommandOutcome, VerifyStage}` users keep working.
27pub use crate::core::session::{CommandOutcome, VerifyStage};
28
29const MQTT_PORT: u16 = 8883;
30const MQTT_USER: &str = "bblp";
31const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
32/// Backoff between reconnect attempts for a continuous (`reconnect`) watch.
33const RECONNECT_DELAY: Duration = Duration::from_secs(2);
34
35/// Errors from the I/O client. Messages never include the access code.
36#[derive(Debug, thiserror::Error)]
37pub enum ClientError {
38    #[error("TLS setup failed: {0}")]
39    Tls(String),
40    #[error("MQTT error: {0}")]
41    Mqtt(String),
42    #[error("timed out after {0:?} (no snapshot, ACK, or terminal state in time)")]
43    Timeout(Duration),
44    #[error("async runtime error: {0}")]
45    Runtime(String),
46}
47
48/// Something that can produce a printer status snapshot. Abstracted so the CLI
49/// and tests don't depend on the concrete MQTT client.
50pub trait StatusSource {
51    fn fetch_snapshot(&self) -> Result<ReportState, ClientError>;
52}
53
54/// Whether [`LanMqttClient::watch`] should keep watching or stop.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum WatchStep {
57    Continue,
58    Stop,
59}
60
61/// A per-connection-unique MQTT client id, `bambu-rs-<pid>-<n>`.
62///
63/// MQTT brokers normally disconnect an existing client when a new one connects
64/// with the **same** client id, so a fixed id would make two concurrent bambu-rs
65/// connections (e.g. `job start --watch` + `timelapse capture`) fight. The pid
66/// distinguishes processes; the atomic counter distinguishes connections within a
67/// process. (Observed: this A1 mini's broker happens *not* to enforce client-id
68/// uniqueness or a 1-connection limit — two connections coexist — but a unique id
69/// is the correct, portable behaviour regardless. See `docs/protocol.md`.)
70fn unique_client_id() -> String {
71    use std::sync::atomic::{AtomicU64, Ordering};
72    static N: AtomicU64 = AtomicU64::new(0);
73    format!(
74        "bambu-rs-{}-{}",
75        std::process::id(),
76        N.fetch_add(1, Ordering::Relaxed)
77    )
78}
79
80/// The report topic for a serial: `device/{serial}/report`.
81pub fn report_topic(serial: &str) -> String {
82    format!("device/{serial}/report")
83}
84
85/// The request topic for a serial: `device/{serial}/request`.
86pub fn request_topic(serial: &str) -> String {
87    format!("device/{serial}/request")
88}
89
90/// A one-shot LAN MQTT client.
91pub struct LanMqttClient {
92    target: ResolvedTarget,
93    timeout: Duration,
94}
95
96impl LanMqttClient {
97    pub fn new(target: ResolvedTarget) -> Self {
98        Self {
99            target,
100            timeout: DEFAULT_TIMEOUT,
101        }
102    }
103
104    pub fn with_timeout(mut self, timeout: Duration) -> Self {
105        self.timeout = timeout;
106        self
107    }
108
109    /// Connect, subscribe to the report topic, and request a `pushall`.
110    async fn connect(&self) -> Result<(AsyncClient, EventLoop), ClientError> {
111        let mut opts = MqttOptions::new(unique_client_id(), &self.target.ip, MQTT_PORT);
112        opts.set_credentials(MQTT_USER, &self.target.access_code);
113        opts.set_keep_alive(Duration::from_secs(30));
114        opts.set_transport(Transport::Tls(tls_config()?));
115
116        let (client, eventloop) = AsyncClient::new(opts, 16);
117        client
118            .subscribe(report_topic(&self.target.serial), QoS::AtMostOnce)
119            .await
120            .map_err(|e| ClientError::Mqtt(e.to_string()))?;
121        client
122            .publish(
123                request_topic(&self.target.serial),
124                QoS::AtMostOnce,
125                false,
126                Command::PushAll.to_payload("0").to_string(),
127            )
128            .await
129            .map_err(|e| ClientError::Mqtt(e.to_string()))?;
130        Ok((client, eventloop))
131    }
132
133    async fn fetch_async(&self) -> Result<ReportState, ClientError> {
134        // Hold `_client` so the event loop stays connected.
135        let (_client, mut eventloop) = self.connect().await?;
136        let mut state = ReportState::new();
137        loop {
138            if let Event::Incoming(Packet::Publish(p)) = poll(&mut eventloop).await?
139                && let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
140            {
141                // Wait for the actual pushall response (push_status, msg == 0),
142                // not an unsolicited delta that merely carries a `print` object: a
143                // delta would be a partial snapshot missing most fields. Check the
144                // raw message before merging (msg is per-message).
145                let full = is_full_snapshot_message(&json);
146                state.apply(json);
147                if full {
148                    return Ok(state);
149                }
150            }
151        }
152    }
153
154    async fn fetch_version_async(&self) -> Result<DeviceVersion, ClientError> {
155        // Hold `_client` so the event loop stays connected.
156        let (client, mut eventloop) = self.connect().await?;
157        // connect() used sequence id "0" for the pushall; get_version gets "1".
158        client
159            .publish(
160                request_topic(&self.target.serial),
161                QoS::AtLeastOnce,
162                false,
163                Command::GetVersion.to_payload("1").to_string(),
164            )
165            .await
166            .map_err(|e| ClientError::Mqtt(e.to_string()))?;
167
168        let mut state = ReportState::new();
169        loop {
170            if let Event::Incoming(Packet::Publish(p)) = poll(&mut eventloop).await?
171                && let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
172            {
173                state.apply(json);
174                // The get_version response arrives under `/info` on the same
175                // report topic; wait for it (not the pushall that connect sent).
176                if let Some(info) = state.pointer("/info")
177                    && info.get("command").and_then(Value::as_str) == Some("get_version")
178                {
179                    return Ok(DeviceVersion::from_info(info));
180                }
181            }
182        }
183    }
184
185    /// Fetch the printer's module/firmware inventory (`info.get_version`).
186    pub fn fetch_version(&self) -> Result<DeviceVersion, ClientError> {
187        self.run_with_timeout(self.fetch_version_async())
188    }
189
190    async fn watch_async<F: FnMut(&ReportState) -> WatchStep>(
191        &self,
192        interval: Option<Duration>,
193        reconnect: bool,
194        stall: Option<Duration>,
195        mut on_update: F,
196    ) -> Result<ReportState, ClientError> {
197        // Merged state persists across reconnects so a continuous monitor keeps
198        // a coherent picture through a printer reboot / Wi-Fi blip.
199        let mut state = ReportState::new();
200        // Stall deadline (continuous monitor only): give up if no report arrives
201        // within `stall`, but reset it on every report — so a responsive printer
202        // is watched indefinitely while a truly-gone one is dropped after the
203        // window (reconnect attempts do NOT reset it).
204        let mut deadline = stall.map(|d| tokio::time::Instant::now() + d);
205        let stalled =
206            |dl: Option<tokio::time::Instant>| dl.is_some_and(|d| tokio::time::Instant::now() >= d);
207
208        'reconnect: loop {
209            let (client, mut eventloop) = match self.connect().await {
210                Ok(c) => c,
211                Err(e) => {
212                    if reconnect && !stalled(deadline) {
213                        tokio::time::sleep(RECONNECT_DELAY).await;
214                        continue 'reconnect;
215                    }
216                    if reconnect {
217                        return Ok(state); // stalled out while reconnecting
218                    }
219                    return Err(e);
220                }
221            };
222
223            // The printer's autonomous push is slow (~2s, small deltas). With an
224            // interval set, poll it like Bambu Studio does — send a periodic
225            // `pushall` to pull full snapshots (~1/s; the printer caps pushall
226            // there) for a higher data-acquisition rate.
227            let mut ticker = interval.map(|d| {
228                let mut t = tokio::time::interval(d);
229                t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
230                t
231            });
232            // connect() already sent the first pushall; drop the immediate tick.
233            if let Some(t) = ticker.as_mut() {
234                t.tick().await;
235            }
236
237            loop {
238                // One step: a report (Some(Ok)), a connection error (Some(Err)),
239                // or a ticker fire that sent a pushall and yielded no data (None).
240                let step = async {
241                    match ticker.as_mut() {
242                        Some(t) => tokio::select! {
243                            ev = poll(&mut eventloop) => Some(ev),
244                            _ = t.tick() => {
245                                let _ = client
246                                    .publish(
247                                        request_topic(&self.target.serial),
248                                        QoS::AtMostOnce,
249                                        false,
250                                        Command::PushAll.to_payload("0").to_string(),
251                                    )
252                                    .await;
253                                None
254                            }
255                        },
256                        None => Some(poll(&mut eventloop).await),
257                    }
258                };
259                let polled = match deadline {
260                    Some(dl) => match tokio::time::timeout_at(dl, step).await {
261                        Ok(v) => v,
262                        Err(_) => return Ok(state), // no report within the stall window
263                    },
264                    None => step.await,
265                };
266                let ev = match polled {
267                    None => continue, // ticker fired (sent pushall), not data
268                    Some(Ok(ev)) => ev,
269                    Some(Err(e)) => {
270                        if reconnect && !stalled(deadline) {
271                            tokio::time::sleep(RECONNECT_DELAY).await;
272                            continue 'reconnect;
273                        }
274                        if reconnect {
275                            return Ok(state);
276                        }
277                        return Err(e);
278                    }
279                };
280                if let Event::Incoming(Packet::Publish(p)) = ev
281                    && let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
282                {
283                    state.apply(json);
284                    deadline = stall.map(|d| tokio::time::Instant::now() + d); // responsive: reset
285                    if state.pointer("/print").is_some()
286                        && matches!(on_update(&state), WatchStep::Stop)
287                    {
288                        return Ok(state);
289                    }
290                }
291            }
292        }
293    }
294
295    /// Watch a job **to completion**: invoke `on_update` on every merged report
296    /// until it returns [`WatchStep::Stop`], or the total `timeout` elapses
297    /// (fail-fast — a dropped connection errors). With `interval` set, also send
298    /// a periodic `pushall` to raise the data rate. For `job start --watch`.
299    pub fn watch<F: FnMut(&ReportState) -> WatchStep>(
300        &self,
301        interval: Option<Duration>,
302        on_update: F,
303    ) -> Result<ReportState, ClientError> {
304        self.run_with_timeout(self.watch_async(interval, false, None, on_update))
305    }
306
307    /// Continuously monitor: like [`watch`](Self::watch) but **never stops on
308    /// its own** and **auto-reconnects** through drops. `timeout` is a *stall*
309    /// window — it ends only after no report arrives for that long (reset on
310    /// every report), so a responsive printer is watched indefinitely while a
311    /// truly-gone one is dropped after the window. For `status --watch`.
312    pub fn monitor<F: FnMut(&ReportState) -> WatchStep>(
313        &self,
314        interval: Option<Duration>,
315        on_update: F,
316    ) -> Result<ReportState, ClientError> {
317        let rt = tokio::runtime::Builder::new_current_thread()
318            .enable_all()
319            .build()
320            .map_err(|e| ClientError::Runtime(e.to_string()))?;
321        rt.block_on(self.watch_async(interval, true, Some(self.timeout), on_update))
322    }
323
324    async fn send_and_watch_async<F: FnMut(&ReportState) -> WatchStep>(
325        &self,
326        commands: &[Command],
327        mut on_update: F,
328    ) -> Result<ReportState, ClientError> {
329        let (client, mut eventloop) = self.connect().await?;
330        // connect() already used sequence id "0" for the pushall.
331        let mut ids = SequenceIds::new();
332        let _ = ids.next_id();
333        for cmd in commands {
334            client
335                .publish(
336                    request_topic(&self.target.serial),
337                    QoS::AtLeastOnce, // control commands go at QoS 1
338                    false,
339                    cmd.to_payload(&ids.next_id()).to_string(),
340                )
341                .await
342                .map_err(|e| ClientError::Mqtt(e.to_string()))?;
343        }
344
345        let mut state = ReportState::new();
346        loop {
347            if let Event::Incoming(Packet::Publish(p)) = poll(&mut eventloop).await?
348                && let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
349            {
350                state.apply(json);
351                if state.pointer("/print").is_some() && matches!(on_update(&state), WatchStep::Stop)
352                {
353                    return Ok(state);
354                }
355            }
356        }
357    }
358
359    /// Publish `commands` (after the initial pushall) on a **single** connection,
360    /// then watch the resulting reports until `on_update` stops or the timeout
361    /// elapses. One connection respects the A1/P1 single-client MQTT limit.
362    pub fn send_and_watch<F: FnMut(&ReportState) -> WatchStep>(
363        &self,
364        commands: &[Command],
365        on_update: F,
366    ) -> Result<ReportState, ClientError> {
367        self.run_with_timeout(self.send_and_watch_async(commands, on_update))
368    }
369
370    async fn send_and_verify_async(&self, cmd: &Command) -> Result<CommandOutcome, ClientError> {
371        let (client, mut eventloop) = self.connect().await?;
372        // connect() used sequence id "0" for the pushall; this command gets "1".
373        let seq = "1";
374        client
375            .publish(
376                request_topic(&self.target.serial),
377                QoS::AtLeastOnce,
378                false,
379                cmd.to_payload(seq).to_string(),
380            )
381            .await
382            .map_err(|e| ClientError::Mqtt(e.to_string()))?;
383
384        // All verify logic lives in the I/O-free VerifySession (see core::session,
385        // unit-tested via FakePrinter). This is just the transport: feed it each
386        // report message; on timeout, ask it for the unverified verdict.
387        //
388        // The per-phase budget starts after connect so verification gets the full
389        // configured timeout regardless of how long connecting took; the outer net
390        // in send_and_verify guards a connect/network hang.
391        let mut session = VerifySession::new(cmd.clone(), seq);
392        let deadline = tokio::time::Instant::now() + self.timeout;
393        loop {
394            let ev = match tokio::time::timeout_at(deadline, poll(&mut eventloop)).await {
395                Err(_) => return Ok(session.timed_out()),
396                Ok(ev) => ev?,
397            };
398            if let Event::Incoming(Packet::Publish(p)) = ev
399                && let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
400                && let Some(outcome) = session.observe(json)
401            {
402                return Ok(outcome);
403            }
404        }
405    }
406
407    /// Send a control command and verify it. The ACK (echoed `sequence_id` +
408    /// `result`) is necessary but not sufficient: for commands with an
409    /// observable effect we also confirm the effect in the report and watch for
410    /// a new `print_error` (see [`CommandOutcome`]). A verify timeout yields
411    /// [`CommandOutcome::Unverified`] — published but not confirmed — never
412    /// assume success.
413    pub fn send_and_verify(&self, cmd: &Command) -> Result<CommandOutcome, ClientError> {
414        // send_and_verify_async manages its own per-phase deadline and returns
415        // Unverified on a verify timeout; this outer net only guards a
416        // connect/network hang.
417        let net = self.timeout + Duration::from_secs(5);
418        let rt = tokio::runtime::Builder::new_current_thread()
419            .enable_all()
420            .build()
421            .map_err(|e| ClientError::Runtime(e.to_string()))?;
422        rt.block_on(async {
423            tokio::time::timeout(net, self.send_and_verify_async(cmd))
424                .await
425                .unwrap_or(Err(ClientError::Timeout(net)))
426        })
427    }
428
429    async fn send_fire_async(&self, cmd: &Command) -> Result<(), ClientError> {
430        let (client, mut eventloop) = self.connect().await?;
431        // connect() used sequence id "0" for the pushall; this command gets "1".
432        client
433            .publish(
434                request_topic(&self.target.serial),
435                QoS::AtLeastOnce,
436                false,
437                cmd.to_payload("1").to_string(),
438            )
439            .await
440            .map_err(|e| ClientError::Mqtt(e.to_string()))?;
441        // Pump the event loop briefly so the QoS-1 PUBLISH is actually written to
442        // the wire before we drop the connection. A reboot then tears the
443        // connection down (an error here is expected, not a failure).
444        let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
445        loop {
446            match tokio::time::timeout_at(deadline, poll(&mut eventloop)).await {
447                Err(_) => break,     // pump window elapsed — publish has been flushed
448                Ok(Ok(_)) => {}      // PUBACK or other events — keep pumping
449                Ok(Err(_)) => break, // connection dropped (expected for reboot)
450            }
451        }
452        Ok(())
453    }
454
455    /// Publish a command **fire-and-forget** — no ACK or effect is awaited. For
456    /// commands whose effect can't be read back because they tear down the
457    /// connection (e.g. [`Command::Reboot`]). Returns once the publish is flushed.
458    pub fn send_fire(&self, cmd: &Command) -> Result<(), ClientError> {
459        self.run_with_timeout(self.send_fire_async(cmd))
460    }
461
462    fn run_with_timeout<T, Fut>(&self, fut: Fut) -> Result<T, ClientError>
463    where
464        Fut: std::future::Future<Output = Result<T, ClientError>>,
465    {
466        let rt = tokio::runtime::Builder::new_current_thread()
467            .enable_all()
468            .build()
469            .map_err(|e| ClientError::Runtime(e.to_string()))?;
470        rt.block_on(async {
471            tokio::time::timeout(self.timeout, fut)
472                .await
473                .unwrap_or(Err(ClientError::Timeout(self.timeout)))
474        })
475    }
476}
477
478impl StatusSource for LanMqttClient {
479    fn fetch_snapshot(&self) -> Result<ReportState, ClientError> {
480        self.run_with_timeout(self.fetch_async())
481    }
482}
483
484/// Poll the event loop, mapping errors to [`ClientError`].
485async fn poll(eventloop: &mut EventLoop) -> Result<Event, ClientError> {
486    eventloop
487        .poll()
488        .await
489        .map_err(|e| ClientError::Mqtt(e.to_string()))
490}
491
492/// Build a rustls config that accepts the printer's self-signed certificate.
493fn tls_config() -> Result<TlsConfiguration, ClientError> {
494    let config = crate::tls::lan_client_config().map_err(|e| ClientError::Tls(e.to_string()))?;
495    Ok(TlsConfiguration::Rustls(config))
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn topics_are_formatted_per_serial() {
504        assert_eq!(report_topic("0309FA"), "device/0309FA/report");
505        assert_eq!(request_topic("0309FA"), "device/0309FA/request");
506    }
507
508    #[test]
509    fn tls_config_builds() {
510        assert!(tls_config().is_ok());
511    }
512
513    #[test]
514    fn client_ids_are_unique_per_connection() {
515        let a = unique_client_id();
516        let b = unique_client_id();
517        assert!(a.starts_with("bambu-rs-"));
518        assert_ne!(a, b); // distinct ids so concurrent connections don't collide
519    }
520}