bambu_rs/server/live.rs
1//! The live printer source: bridges the blocking LAN MQTT [`monitor`] loop into
2//! a [`watch`] channel so the dashboard can stream the *real* device status the
3//! same way it streams the fake one.
4//!
5//! [`monitor`]: crate::client::LanMqttClient::monitor
6
7use std::thread;
8use std::time::Duration;
9
10use tokio::sync::watch;
11
12use super::api::PrinterSource;
13use crate::client::{LanMqttClient, WatchStep};
14use crate::config::ResolvedTarget;
15use crate::core::status::PrinterStatus;
16
17/// Wait before reconnecting after `monitor` returns (a stall or connect error).
18const RECONNECT_DELAY: Duration = Duration::from_secs(3);
19/// Stall window handed to `monitor`: it returns if no report arrives within this,
20/// then our outer loop reconnects. Generous so a momentarily-quiet printer isn't
21/// dropped mid-print.
22const STALL: Duration = Duration::from_secs(120);
23
24/// A [`PrinterSource`] backed by a live LAN MQTT connection.
25///
26/// A dedicated OS thread runs the blocking [`LanMqttClient::monitor`] loop (which
27/// owns its own current-thread runtime and auto-reconnects internally), turning
28/// each merged report into a [`PrinterStatus`] pushed onto a [`watch`] channel.
29/// The thread reconnects forever until the source and all its subscribers drop.
30pub struct LiveSource {
31 tx: watch::Sender<PrinterStatus>,
32 // Held so the channel keeps a receiver while the source is alive; when the
33 // source (and any WS subscribers) drop, the worker's reconnect loop stops.
34 _keepalive: watch::Receiver<PrinterStatus>,
35}
36
37impl LiveSource {
38 /// Connect to `target` and start streaming live status. `interval` is the
39 /// `pushall` poll period; `None` relies on the printer's autonomous ~2 s push
40 /// (the gentlest option, the default for an always-on dashboard).
41 pub fn connect(target: ResolvedTarget, interval: Option<Duration>) -> Self {
42 Self::spawn(move |tx| {
43 loop {
44 let client = LanMqttClient::new(target.clone()).with_timeout(STALL);
45 let _ = client.monitor(interval, |rs| {
46 // Stop promptly when the source + all subscribers are gone,
47 // instead of running until the next stall/transport break.
48 if tx.send(PrinterStatus::from_state(rs.get())).is_err() {
49 WatchStep::Stop
50 } else {
51 WatchStep::Continue
52 }
53 });
54 if tx.receiver_count() == 0 {
55 break; // the source and all subscribers are gone
56 }
57 thread::sleep(RECONNECT_DELAY);
58 }
59 })
60 }
61
62 /// Spawn the bridge thread around `run`, handing it the channel sender. The
63 /// real MQTT loop lives in [`LiveSource::connect`]; this seam lets tests drive
64 /// the channel without a printer.
65 fn spawn<R>(run: R) -> Self
66 where
67 R: FnOnce(watch::Sender<PrinterStatus>) + Send + 'static,
68 {
69 let (tx, rx) = watch::channel(PrinterStatus::default());
70 let worker = tx.clone();
71 thread::spawn(move || run(worker));
72 Self { tx, _keepalive: rx }
73 }
74}
75
76impl PrinterSource for LiveSource {
77 fn current(&self) -> PrinterStatus {
78 self.tx.borrow().clone()
79 }
80 fn subscribe(&self) -> watch::Receiver<PrinterStatus> {
81 self.tx.subscribe()
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn bridges_published_status_to_current_and_subscribers() {
91 let src = LiveSource::spawn(|tx| {
92 let _ = tx.send(PrinterStatus {
93 gcode_state: Some("RUNNING".to_string()),
94 mc_percent: Some(42),
95 ..Default::default()
96 });
97 });
98 // The worker sends near-instantly; poll up to a generous deadline so the
99 // test is reliable without timing assumptions.
100 let mut got = None;
101 for _ in 0..200 {
102 let s = src.current();
103 if s.gcode_state.is_some() {
104 got = Some(s);
105 break;
106 }
107 thread::sleep(Duration::from_millis(10));
108 }
109 let s = got.expect("worker thread should publish a status");
110 assert_eq!(s.gcode_state.as_deref(), Some("RUNNING"));
111 assert_eq!(s.mc_percent, Some(42));
112 }
113}