use std::time::Duration;
use rumqttc::{
AsyncClient, Event, EventLoop, MqttOptions, Packet, QoS, TlsConfiguration, Transport,
};
use serde_json::Value;
use crate::config::ResolvedTarget;
use crate::core::command::{Command, SequenceIds};
use crate::core::report::{ReportState, is_full_snapshot_message};
use crate::core::session::VerifySession;
use crate::core::version::DeviceVersion;
pub use crate::core::session::{CommandOutcome, VerifyStage};
const MQTT_PORT: u16 = 8883;
const MQTT_USER: &str = "bblp";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const RECONNECT_DELAY: Duration = Duration::from_secs(2);
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("TLS setup failed: {0}")]
Tls(String),
#[error("MQTT error: {0}")]
Mqtt(String),
#[error("timed out after {0:?} (no snapshot, ACK, or terminal state in time)")]
Timeout(Duration),
#[error("async runtime error: {0}")]
Runtime(String),
}
pub trait StatusSource {
fn fetch_snapshot(&self) -> Result<ReportState, ClientError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchStep {
Continue,
Stop,
}
fn unique_client_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
format!(
"bambu-rs-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
)
}
pub fn report_topic(serial: &str) -> String {
format!("device/{serial}/report")
}
pub fn request_topic(serial: &str) -> String {
format!("device/{serial}/request")
}
pub struct LanMqttClient {
target: ResolvedTarget,
timeout: Duration,
}
impl LanMqttClient {
pub fn new(target: ResolvedTarget) -> Self {
Self {
target,
timeout: DEFAULT_TIMEOUT,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
async fn connect(&self) -> Result<(AsyncClient, EventLoop), ClientError> {
let mut opts = MqttOptions::new(unique_client_id(), &self.target.ip, MQTT_PORT);
opts.set_credentials(MQTT_USER, &self.target.access_code);
opts.set_keep_alive(Duration::from_secs(30));
opts.set_transport(Transport::Tls(tls_config()?));
let (client, eventloop) = AsyncClient::new(opts, 16);
client
.subscribe(report_topic(&self.target.serial), QoS::AtMostOnce)
.await
.map_err(|e| ClientError::Mqtt(e.to_string()))?;
client
.publish(
request_topic(&self.target.serial),
QoS::AtMostOnce,
false,
Command::PushAll.to_payload("0").to_string(),
)
.await
.map_err(|e| ClientError::Mqtt(e.to_string()))?;
Ok((client, eventloop))
}
async fn fetch_async(&self) -> Result<ReportState, ClientError> {
let (_client, mut eventloop) = self.connect().await?;
let mut state = ReportState::new();
loop {
if let Event::Incoming(Packet::Publish(p)) = poll(&mut eventloop).await?
&& let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
{
let full = is_full_snapshot_message(&json);
state.apply(json);
if full {
return Ok(state);
}
}
}
}
async fn fetch_version_async(&self) -> Result<DeviceVersion, ClientError> {
let (client, mut eventloop) = self.connect().await?;
client
.publish(
request_topic(&self.target.serial),
QoS::AtLeastOnce,
false,
Command::GetVersion.to_payload("1").to_string(),
)
.await
.map_err(|e| ClientError::Mqtt(e.to_string()))?;
let mut state = ReportState::new();
loop {
if let Event::Incoming(Packet::Publish(p)) = poll(&mut eventloop).await?
&& let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
{
state.apply(json);
if let Some(info) = state.pointer("/info")
&& info.get("command").and_then(Value::as_str) == Some("get_version")
{
return Ok(DeviceVersion::from_info(info));
}
}
}
}
pub fn fetch_version(&self) -> Result<DeviceVersion, ClientError> {
self.run_with_timeout(self.fetch_version_async())
}
async fn watch_async<F: FnMut(&ReportState) -> WatchStep>(
&self,
interval: Option<Duration>,
reconnect: bool,
stall: Option<Duration>,
mut on_update: F,
) -> Result<ReportState, ClientError> {
let mut state = ReportState::new();
let mut deadline = stall.map(|d| tokio::time::Instant::now() + d);
let stalled =
|dl: Option<tokio::time::Instant>| dl.is_some_and(|d| tokio::time::Instant::now() >= d);
'reconnect: loop {
let (client, mut eventloop) = match self.connect().await {
Ok(c) => c,
Err(e) => {
if reconnect && !stalled(deadline) {
tokio::time::sleep(RECONNECT_DELAY).await;
continue 'reconnect;
}
if reconnect {
return Ok(state); }
return Err(e);
}
};
let mut ticker = interval.map(|d| {
let mut t = tokio::time::interval(d);
t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
t
});
if let Some(t) = ticker.as_mut() {
t.tick().await;
}
loop {
let step = async {
match ticker.as_mut() {
Some(t) => tokio::select! {
ev = poll(&mut eventloop) => Some(ev),
_ = t.tick() => {
let _ = client
.publish(
request_topic(&self.target.serial),
QoS::AtMostOnce,
false,
Command::PushAll.to_payload("0").to_string(),
)
.await;
None
}
},
None => Some(poll(&mut eventloop).await),
}
};
let polled = match deadline {
Some(dl) => match tokio::time::timeout_at(dl, step).await {
Ok(v) => v,
Err(_) => return Ok(state), },
None => step.await,
};
let ev = match polled {
None => continue, Some(Ok(ev)) => ev,
Some(Err(e)) => {
if reconnect && !stalled(deadline) {
tokio::time::sleep(RECONNECT_DELAY).await;
continue 'reconnect;
}
if reconnect {
return Ok(state);
}
return Err(e);
}
};
if let Event::Incoming(Packet::Publish(p)) = ev
&& let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
{
state.apply(json);
deadline = stall.map(|d| tokio::time::Instant::now() + d); if state.pointer("/print").is_some()
&& matches!(on_update(&state), WatchStep::Stop)
{
return Ok(state);
}
}
}
}
}
pub fn watch<F: FnMut(&ReportState) -> WatchStep>(
&self,
interval: Option<Duration>,
on_update: F,
) -> Result<ReportState, ClientError> {
self.run_with_timeout(self.watch_async(interval, false, None, on_update))
}
pub fn monitor<F: FnMut(&ReportState) -> WatchStep>(
&self,
interval: Option<Duration>,
on_update: F,
) -> Result<ReportState, ClientError> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ClientError::Runtime(e.to_string()))?;
rt.block_on(self.watch_async(interval, true, Some(self.timeout), on_update))
}
async fn send_and_watch_async<F: FnMut(&ReportState) -> WatchStep>(
&self,
commands: &[Command],
mut on_update: F,
) -> Result<ReportState, ClientError> {
let (client, mut eventloop) = self.connect().await?;
let mut ids = SequenceIds::new();
let _ = ids.next_id();
for cmd in commands {
client
.publish(
request_topic(&self.target.serial),
QoS::AtLeastOnce, false,
cmd.to_payload(&ids.next_id()).to_string(),
)
.await
.map_err(|e| ClientError::Mqtt(e.to_string()))?;
}
let mut state = ReportState::new();
loop {
if let Event::Incoming(Packet::Publish(p)) = poll(&mut eventloop).await?
&& let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
{
state.apply(json);
if state.pointer("/print").is_some() && matches!(on_update(&state), WatchStep::Stop)
{
return Ok(state);
}
}
}
}
pub fn send_and_watch<F: FnMut(&ReportState) -> WatchStep>(
&self,
commands: &[Command],
on_update: F,
) -> Result<ReportState, ClientError> {
self.run_with_timeout(self.send_and_watch_async(commands, on_update))
}
async fn send_and_verify_async(&self, cmd: &Command) -> Result<CommandOutcome, ClientError> {
let (client, mut eventloop) = self.connect().await?;
let seq = "1";
client
.publish(
request_topic(&self.target.serial),
QoS::AtLeastOnce,
false,
cmd.to_payload(seq).to_string(),
)
.await
.map_err(|e| ClientError::Mqtt(e.to_string()))?;
let mut session = VerifySession::new(cmd.clone(), seq);
let deadline = tokio::time::Instant::now() + self.timeout;
loop {
let ev = match tokio::time::timeout_at(deadline, poll(&mut eventloop)).await {
Err(_) => return Ok(session.timed_out()),
Ok(ev) => ev?,
};
if let Event::Incoming(Packet::Publish(p)) = ev
&& let Ok(json) = serde_json::from_slice::<Value>(&p.payload)
&& let Some(outcome) = session.observe(json)
{
return Ok(outcome);
}
}
}
pub fn send_and_verify(&self, cmd: &Command) -> Result<CommandOutcome, ClientError> {
let net = self.timeout + Duration::from_secs(5);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ClientError::Runtime(e.to_string()))?;
rt.block_on(async {
tokio::time::timeout(net, self.send_and_verify_async(cmd))
.await
.unwrap_or(Err(ClientError::Timeout(net)))
})
}
async fn send_fire_async(&self, cmd: &Command) -> Result<(), ClientError> {
let (client, mut eventloop) = self.connect().await?;
client
.publish(
request_topic(&self.target.serial),
QoS::AtLeastOnce,
false,
cmd.to_payload("1").to_string(),
)
.await
.map_err(|e| ClientError::Mqtt(e.to_string()))?;
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
loop {
match tokio::time::timeout_at(deadline, poll(&mut eventloop)).await {
Err(_) => break, Ok(Ok(_)) => {} Ok(Err(_)) => break, }
}
Ok(())
}
pub fn send_fire(&self, cmd: &Command) -> Result<(), ClientError> {
self.run_with_timeout(self.send_fire_async(cmd))
}
fn run_with_timeout<T, Fut>(&self, fut: Fut) -> Result<T, ClientError>
where
Fut: std::future::Future<Output = Result<T, ClientError>>,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| ClientError::Runtime(e.to_string()))?;
rt.block_on(async {
tokio::time::timeout(self.timeout, fut)
.await
.unwrap_or(Err(ClientError::Timeout(self.timeout)))
})
}
}
impl StatusSource for LanMqttClient {
fn fetch_snapshot(&self) -> Result<ReportState, ClientError> {
self.run_with_timeout(self.fetch_async())
}
}
async fn poll(eventloop: &mut EventLoop) -> Result<Event, ClientError> {
eventloop
.poll()
.await
.map_err(|e| ClientError::Mqtt(e.to_string()))
}
fn tls_config() -> Result<TlsConfiguration, ClientError> {
let config = crate::tls::lan_client_config().map_err(|e| ClientError::Tls(e.to_string()))?;
Ok(TlsConfiguration::Rustls(config))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn topics_are_formatted_per_serial() {
assert_eq!(report_topic("0309FA"), "device/0309FA/report");
assert_eq!(request_topic("0309FA"), "device/0309FA/request");
}
#[test]
fn tls_config_builds() {
assert!(tls_config().is_ok());
}
#[test]
fn client_ids_are_unique_per_connection() {
let a = unique_client_id();
let b = unique_client_id();
assert!(a.starts_with("bambu-rs-"));
assert_ne!(a, b); }
}