1use 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
25pub 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);
32const RECONNECT_DELAY: Duration = Duration::from_secs(2);
34
35#[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
48pub trait StatusSource {
51 fn fetch_snapshot(&self) -> Result<ReportState, ClientError>;
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum WatchStep {
57 Continue,
58 Stop,
59}
60
61fn 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
80pub fn report_topic(serial: &str) -> String {
82 format!("device/{serial}/report")
83}
84
85pub fn request_topic(serial: &str) -> String {
87 format!("device/{serial}/request")
88}
89
90pub 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 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 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 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 let (client, mut eventloop) = self.connect().await?;
157 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 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 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 let mut state = ReportState::new();
200 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); }
219 return Err(e);
220 }
221 };
222
223 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 if let Some(t) = ticker.as_mut() {
234 t.tick().await;
235 }
236
237 loop {
238 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), },
264 None => step.await,
265 };
266 let ev = match polled {
267 None => continue, 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); 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 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 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 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, 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 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 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 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 pub fn send_and_verify(&self, cmd: &Command) -> Result<CommandOutcome, ClientError> {
414 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 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 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, Ok(Ok(_)) => {} Ok(Err(_)) => break, }
451 }
452 Ok(())
453 }
454
455 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
484async 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
492fn 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); }
520}