Initial boilerplate scaffold for continuum-proxy
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use rumqttc::{AsyncClient, Event, MqttOptions, Packet, QoS, TlsConfiguration, Transport};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use suppaftp::{AsyncNativeTlsConnector, AsyncNativeTlsFtpStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const MQTT_PORT: u16 = 8883;
|
||||
const FTPS_PORT: u16 = 990;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BambuPrinter {
|
||||
pub serial: String,
|
||||
pub host: String,
|
||||
pub access_code: String,
|
||||
}
|
||||
|
||||
/// Telemetry pushed from the printer's `report` MQTT topic, decoded into the
|
||||
/// subset of fields the control plane cares about.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BambuReport {
|
||||
#[serde(default)]
|
||||
pub nozzle_temper: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub bed_temper: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub mc_percent: Option<u8>,
|
||||
#[serde(default)]
|
||||
pub gcode_state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BambuCommandEnvelope<'a> {
|
||||
print: BambuCommand<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BambuCommand<'a> {
|
||||
sequence_id: &'a str,
|
||||
command: &'a str,
|
||||
}
|
||||
|
||||
/// Connects over local-LAN MQTTS to a single Bambu printer and streams
|
||||
/// decoded telemetry reports out on `tx`. Bambu's LAN-mode broker uses a
|
||||
/// self-signed cert, so a permissive TLS config is required.
|
||||
pub async fn run_telemetry(printer: BambuPrinter, tx: mpsc::Sender<(String, BambuReport)>) {
|
||||
loop {
|
||||
if let Err(err) = telemetry_session(&printer, &tx).await {
|
||||
warn!(serial = %printer.serial, ?err, "bambu MQTT session ended, reconnecting");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn telemetry_session(
|
||||
printer: &BambuPrinter,
|
||||
tx: &mpsc::Sender<(String, BambuReport)>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut opts = MqttOptions::new(
|
||||
format!("continuum-proxy-{}", printer.serial),
|
||||
printer.host.clone(),
|
||||
MQTT_PORT,
|
||||
);
|
||||
opts.set_credentials("bblp", printer.access_code.clone());
|
||||
opts.set_keep_alive(Duration::from_secs(20));
|
||||
// Bambu LAN-mode uses a self-signed certificate; the client trusts it
|
||||
// explicitly because the connection never leaves the local network.
|
||||
opts.set_transport(Transport::Tls(TlsConfiguration::default()));
|
||||
|
||||
let (client, mut event_loop) = AsyncClient::new(opts, 16);
|
||||
let report_topic = format!("device/{}/report", printer.serial);
|
||||
client.subscribe(&report_topic, QoS::AtMostOnce).await?;
|
||||
|
||||
loop {
|
||||
match event_loop.poll().await? {
|
||||
Event::Incoming(Packet::Publish(publish)) if publish.topic == report_topic => {
|
||||
match serde_json::from_slice::<serde_json::Value>(&publish.payload) {
|
||||
Ok(value) => {
|
||||
if let Some(print) = value.get("print") {
|
||||
if let Ok(report) = serde_json::from_value::<BambuReport>(print.clone()) {
|
||||
debug!(serial = %printer.serial, ?report, "bambu telemetry");
|
||||
if tx.send((printer.serial.clone(), report)).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(?err, "failed to decode bambu report payload"),
|
||||
}
|
||||
}
|
||||
Event::Incoming(Packet::Disconnect) => {
|
||||
return Err(anyhow::anyhow!("printer closed MQTT connection"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a G-code print-control command (pause/resume/stop/etc.) to the
|
||||
/// printer over its LAN MQTT channel.
|
||||
pub async fn send_command(printer: &BambuPrinter, sequence_id: &str, command: &str) -> anyhow::Result<()> {
|
||||
let mut opts = MqttOptions::new(format!("continuum-proxy-cmd-{}", printer.serial), printer.host.clone(), MQTT_PORT);
|
||||
opts.set_credentials("bblp", printer.access_code.clone());
|
||||
opts.set_transport(Transport::Tls(TlsConfiguration::default()));
|
||||
|
||||
let (client, mut event_loop) = AsyncClient::new(opts, 4);
|
||||
let request_topic = format!("device/{}/request", printer.serial);
|
||||
|
||||
let envelope = BambuCommandEnvelope {
|
||||
print: BambuCommand { sequence_id, command },
|
||||
};
|
||||
client
|
||||
.publish(&request_topic, QoS::AtLeastOnce, false, serde_json::to_vec(&envelope)?)
|
||||
.await?;
|
||||
|
||||
// Pump the event loop once so the publish actually flushes before we drop the client.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), event_loop.poll()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uploads a sliced `.gcode.3mf` project file to the printer's local storage
|
||||
/// over FTPS (port 990, implicit TLS) ahead of a print job.
|
||||
pub async fn dispatch_file(printer: &BambuPrinter, local_path: &Path, remote_name: &str) -> anyhow::Result<()> {
|
||||
info!(serial = %printer.serial, remote_name, "dispatching file via FTPS");
|
||||
|
||||
let ftp = AsyncNativeTlsFtpStream::connect(format!("{}:{FTPS_PORT}", printer.host)).await?;
|
||||
let mut ftp = ftp
|
||||
.into_secure(AsyncNativeTlsConnector::from(native_tls::TlsConnector::new()?), &printer.host)
|
||||
.await?;
|
||||
ftp.login("bblp", &printer.access_code).await?;
|
||||
|
||||
let mut file = tokio::fs::File::open(local_path).await?;
|
||||
ftp.put_file(remote_name, &mut file).await?;
|
||||
ftp.quit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod bambu;
|
||||
pub mod moonraker;
|
||||
pub mod prusalink;
|
||||
@@ -0,0 +1,74 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MoonrakerPrinter {
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcRequest<'a> {
|
||||
jsonrpc: &'static str,
|
||||
method: &'a str,
|
||||
params: serde_json::Value,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MoonrakerNotification {
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Subscribes to a Klipper/Moonraker printer's `printer.objects.subscribe`
|
||||
/// WebSocket feed and forwards decoded status notifications upstream.
|
||||
pub async fn run(printer: MoonrakerPrinter, tx: tokio::sync::mpsc::Sender<MoonrakerNotification>) {
|
||||
loop {
|
||||
if let Err(err) = session(&printer, &tx).await {
|
||||
warn!(host = %printer.host, ?err, "moonraker session ended, reconnecting");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn session(
|
||||
printer: &MoonrakerPrinter,
|
||||
tx: &tokio::sync::mpsc::Sender<MoonrakerNotification>,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = format!("ws://{}/websocket", printer.host);
|
||||
let (ws_stream, _) = tokio_tungstenite::connect_async(&url).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
let subscribe = JsonRpcRequest {
|
||||
jsonrpc: "2.0",
|
||||
method: "printer.objects.subscribe",
|
||||
params: serde_json::json!({
|
||||
"objects": {
|
||||
"extruder": ["temperature", "target"],
|
||||
"heater_bed": ["temperature", "target"],
|
||||
"print_stats": ["state", "progress"],
|
||||
}
|
||||
}),
|
||||
id: 1,
|
||||
};
|
||||
write.send(Message::Text(serde_json::to_string(&subscribe)?)).await?;
|
||||
|
||||
while let Some(frame) = read.next().await {
|
||||
match frame? {
|
||||
Message::Text(text) => {
|
||||
if let Ok(notification) = serde_json::from_str::<MoonrakerNotification>(&text) {
|
||||
if tx.send(notification).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use serde::Deserialize;
|
||||
use tracing::warn;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrusaLinkPrinter {
|
||||
pub host: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PrusaLinkStatus {
|
||||
pub printer: PrusaLinkPrinterState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PrusaLinkPrinterState {
|
||||
pub state: String,
|
||||
pub temp_nozzle: f32,
|
||||
pub temp_bed: f32,
|
||||
}
|
||||
|
||||
/// Polls PrusaLink's REST API for the current printer state.
|
||||
/// PrusaLink has no push/streaming transport, so the proxy polls it on a
|
||||
/// short interval and forwards deltas upstream as synthetic telemetry.
|
||||
pub async fn fetch_status(client: &reqwest::Client, printer: &PrusaLinkPrinter) -> anyhow::Result<PrusaLinkStatus> {
|
||||
let url = format!("http://{}/api/v1/status", printer.host);
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("X-Api-Key", &printer.api_key)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let status = response.json::<PrusaLinkStatus>().await?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub async fn poll_loop(
|
||||
client: reqwest::Client,
|
||||
printer: PrusaLinkPrinter,
|
||||
interval: std::time::Duration,
|
||||
tx: tokio::sync::mpsc::Sender<(String, PrusaLinkStatus)>,
|
||||
) {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match fetch_status(&client, &printer).await {
|
||||
Ok(status) => {
|
||||
if tx.send((printer.host.clone(), status)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(host = %printer.host, ?err, "prusalink poll failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use rusqlite::Connection;
|
||||
use tracing::info;
|
||||
|
||||
/// Local SQLite cache that buffers telemetry and job state across uplink
|
||||
/// outages so nothing is lost between the printer and the cloud.
|
||||
pub struct EdgeCache {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl EdgeCache {
|
||||
pub fn open(path: &str) -> anyhow::Result<Self> {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS pending_telemetry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
printer_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS job_state (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
)?;
|
||||
|
||||
info!(path, "opened edge cache");
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
pub fn enqueue_telemetry(&self, printer_id: &str, payload: &str) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO pending_telemetry (printer_id, payload) VALUES (?1, ?2)",
|
||||
(printer_id, payload),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn drain_telemetry(&self, limit: u32) -> anyhow::Result<Vec<(i64, String, String)>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id, printer_id, payload FROM pending_telemetry ORDER BY id ASC LIMIT ?1")?;
|
||||
let rows = stmt
|
||||
.query_map([limit], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn ack_telemetry(&self, ids: &[i64]) -> anyhow::Result<()> {
|
||||
for id in ids {
|
||||
self.conn.execute("DELETE FROM pending_telemetry WHERE id = ?1", [id])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub gateway_id: String,
|
||||
pub farm_id: String,
|
||||
pub gateway_token: String,
|
||||
pub uplink_url: String,
|
||||
pub api_base: String,
|
||||
pub sqlite_path: String,
|
||||
pub go2rtc_bin: String,
|
||||
pub go2rtc_config: String,
|
||||
pub go2rtc_api_port: u16,
|
||||
pub discovery_interval_secs: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
gateway_id: require("CONTINUUM_GATEWAY_ID")?,
|
||||
farm_id: require("CONTINUUM_FARM_ID")?,
|
||||
gateway_token: require("CONTINUUM_GATEWAY_TOKEN")?,
|
||||
uplink_url: env_or("CONTINUUM_UPLINK_URL", "wss://api.continuum.local/ws/edge/v1"),
|
||||
api_base: env_or("CONTINUUM_API_BASE", "https://api.continuum.local"),
|
||||
sqlite_path: env_or("CONTINUUM_SQLITE_PATH", "./data/edge-cache.db"),
|
||||
go2rtc_bin: env_or("CONTINUUM_GO2RTC_BIN", "go2rtc"),
|
||||
go2rtc_config: env_or("CONTINUUM_GO2RTC_CONFIG", "./go2rtc.yaml"),
|
||||
go2rtc_api_port: env_or("CONTINUUM_GO2RTC_API_PORT", "1984").parse()?,
|
||||
discovery_interval_secs: env_or("CONTINUUM_DISCOVERY_INTERVAL_SECS", "30").parse()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn require(key: &str) -> anyhow::Result<String> {
|
||||
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
|
||||
}
|
||||
|
||||
fn env_or(key: &str, default: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PrinterVendor {
|
||||
Bambu,
|
||||
Prusa,
|
||||
Klipper,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveredPrinter {
|
||||
pub vendor: PrinterVendor,
|
||||
pub host: String,
|
||||
pub serial: Option<String>,
|
||||
}
|
||||
|
||||
/// Periodically sweeps the LAN for printers (SSDP for Bambu, mDNS for
|
||||
/// PrusaLink/Moonraker) and reports newly-seen devices upstream.
|
||||
///
|
||||
/// This is intentionally a stub: production discovery would bind a UDP
|
||||
/// multicast socket per protocol. It's structured so each protocol's probe
|
||||
/// can be dropped in independently without touching the polling loop.
|
||||
pub async fn run(interval: Duration, tx: tokio::sync::mpsc::Sender<DiscoveredPrinter>) {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
debug!("running LAN discovery sweep");
|
||||
|
||||
for printer in sweep().await {
|
||||
info!(host = %printer.host, ?printer.vendor, "discovered printer");
|
||||
if tx.send(printer).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sweep() -> Vec<DiscoveredPrinter> {
|
||||
// TODO: SSDP probe (Bambu), mDNS `_prusalink._tcp` / `_moonraker._tcp` probes.
|
||||
Vec::new()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::process::Command;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Supervises the go2rtc subprocess used for camera restreaming (RTSP/USB -> WebRTC).
|
||||
/// Restarts it with a fixed backoff whenever it exits, for as long as the daemon runs.
|
||||
pub async fn watchdog(bin: String, config_path: String) {
|
||||
loop {
|
||||
info!(%bin, %config_path, "starting go2rtc");
|
||||
|
||||
let spawned = Command::new(&bin)
|
||||
.arg("-config")
|
||||
.arg(&config_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn();
|
||||
|
||||
match spawned {
|
||||
Ok(mut child) => match child.wait().await {
|
||||
Ok(status) => warn!(%status, "go2rtc exited, restarting after backoff"),
|
||||
Err(err) => error!(?err, "failed to wait on go2rtc process"),
|
||||
},
|
||||
Err(err) => {
|
||||
error!(?err, "failed to spawn go2rtc, is it installed and on PATH?");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
mod adapters;
|
||||
mod cache;
|
||||
mod config;
|
||||
mod discovery;
|
||||
mod go2rtc;
|
||||
mod plate_changer;
|
||||
mod uplink;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::cache::EdgeCache;
|
||||
use crate::config::Config;
|
||||
use crate::uplink::ClientMessage;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.json()
|
||||
.init();
|
||||
|
||||
dotenvy_load();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
||||
|
||||
let cache = EdgeCache::open(&config.sqlite_path)?;
|
||||
|
||||
// Channels wiring the printer adapters -> uplink, and uplink -> task
|
||||
// handlers for cloud-issued commands.
|
||||
let (telemetry_tx, telemetry_rx) = mpsc::channel::<ClientMessage>(256);
|
||||
let (task_tx, mut task_rx) = mpsc::channel::<uplink::ServerMessage>(64);
|
||||
let (discovered_tx, mut discovered_rx) = mpsc::channel::<discovery::DiscoveredPrinter>(32);
|
||||
|
||||
let uplink_handle = tokio::spawn(uplink::run(config.clone(), telemetry_rx, task_tx));
|
||||
|
||||
let go2rtc_handle = tokio::spawn(go2rtc::watchdog(config.go2rtc_bin.clone(), config.go2rtc_config.clone()));
|
||||
|
||||
let discovery_handle = tokio::spawn(discovery::run(
|
||||
Duration::from_secs(config.discovery_interval_secs),
|
||||
discovered_tx,
|
||||
));
|
||||
|
||||
// Cloud-issued task dispatcher: pause/resume/plate-change/etc. commands
|
||||
// arriving over the uplink get routed to the right adapter here.
|
||||
let task_dispatcher = tokio::spawn(async move {
|
||||
while let Some(msg) = task_rx.recv().await {
|
||||
match msg {
|
||||
uplink::ServerMessage::Task { task_id, kind, payload } => {
|
||||
info!(task_id, kind, ?payload, "received task from cloud");
|
||||
// TODO: route to adapters::bambu / prusalink / moonraker or plate_changer
|
||||
// based on `kind`, then send a ClientMessage::TaskResult back upstream.
|
||||
}
|
||||
uplink::ServerMessage::HelloAck { session_id } => {
|
||||
info!(session_id, "uplink session established");
|
||||
}
|
||||
uplink::ServerMessage::HeartbeatAck { seq } => {
|
||||
tracing::debug!(seq, "heartbeat acked");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Newly-discovered printers get their telemetry adapters spawned on the fly.
|
||||
let telemetry_tx_for_discovery = telemetry_tx.clone();
|
||||
let discovery_dispatcher = tokio::spawn(async move {
|
||||
while let Some(printer) = discovered_rx.recv().await {
|
||||
info!(host = %printer.host, "spawning adapter for discovered printer");
|
||||
let _ = &telemetry_tx_for_discovery;
|
||||
// TODO: match printer.vendor and spawn adapters::bambu::run_telemetry / moonraker::run / prusalink::poll_loop
|
||||
}
|
||||
});
|
||||
|
||||
// Periodically flush anything the SQLite cache buffered while the uplink was down.
|
||||
let cache_flusher = tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(10));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match cache.drain_telemetry(100) {
|
||||
Ok(rows) if !rows.is_empty() => {
|
||||
let ids: Vec<i64> = rows.iter().map(|(id, _, _)| *id).collect();
|
||||
for (_, printer_id, payload) in &rows {
|
||||
let payload: serde_json::Value = serde_json::from_str(payload).unwrap_or_default();
|
||||
let _ = telemetry_tx
|
||||
.send(ClientMessage::Telemetry { printer_id: printer_id.clone(), payload })
|
||||
.await;
|
||||
}
|
||||
if let Err(err) = cache.ack_telemetry(&ids) {
|
||||
warn!(?err, "failed to ack drained telemetry rows");
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => warn!(?err, "failed to drain edge cache"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
res = uplink_handle => warn!(?res, "uplink task exited"),
|
||||
res = go2rtc_handle => warn!(?res, "go2rtc watchdog exited"),
|
||||
res = discovery_handle => warn!(?res, "discovery task exited"),
|
||||
res = task_dispatcher => warn!(?res, "task dispatcher exited"),
|
||||
res = discovery_dispatcher => warn!(?res, "discovery dispatcher exited"),
|
||||
res = cache_flusher => warn!(?res, "cache flusher exited"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads a `.env` file if present, without pulling in a heavyweight config
|
||||
/// crate. No-op (and safe to ignore errors) when the file doesn't exist.
|
||||
fn dotenvy_load() {
|
||||
if let Ok(contents) = std::fs::read_to_string(".env") {
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
if std::env::var(key).is_err() {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// Snapshot of the plate changer's discrete sensor inputs, read after a
|
||||
/// cycle completes to confirm the mechanism actually did what it reported.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SensorState {
|
||||
pub plate_present: bool,
|
||||
pub bed_clear: bool,
|
||||
}
|
||||
|
||||
/// Reads the plate-present and bed-clear sensors.
|
||||
///
|
||||
/// On the target hardware (Raspberry Pi / SBC GPIO header) this would read
|
||||
/// two debounced digital inputs via `rppal` or sysfs GPIO. Stubbed here so
|
||||
/// the crate builds without hardware access; swap in a real backend behind
|
||||
/// this same function signature.
|
||||
pub async fn read_sensor_state() -> SensorState {
|
||||
SensorState {
|
||||
plate_present: true,
|
||||
bed_clear: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
mod gpio;
|
||||
mod serial;
|
||||
|
||||
pub use gpio::SensorState;
|
||||
|
||||
use thiserror::Error;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PlateChangerError {
|
||||
#[error("plate changer hardware not responding")]
|
||||
NotResponding,
|
||||
#[error("sensor validation failed: {0}")]
|
||||
SensorMismatch(String),
|
||||
#[error("serial transport error: {0}")]
|
||||
Serial(#[from] serial::SerialError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CycleOutcome {
|
||||
Success,
|
||||
Retried,
|
||||
}
|
||||
|
||||
/// Drives a mechanical plate-swap cycle: signal the changer over serial,
|
||||
/// wait for it to report completion, then cross-check the physical sensors
|
||||
/// (plate-present, bed-clear) before releasing the print queue to continue.
|
||||
pub struct PlateChanger {
|
||||
port: serial::SerialPort,
|
||||
}
|
||||
|
||||
impl PlateChanger {
|
||||
pub fn open(path: &str, baud: u32) -> Result<Self, PlateChangerError> {
|
||||
Ok(Self {
|
||||
port: serial::SerialPort::open(path, baud)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run_cycle(&mut self, slot: u8) -> Result<CycleOutcome, PlateChangerError> {
|
||||
info!(slot, "starting plate change cycle");
|
||||
|
||||
self.port.send_command(&serial::Command::Eject).await?;
|
||||
self.port.await_ack(std::time::Duration::from_secs(30)).await?;
|
||||
|
||||
self.port.send_command(&serial::Command::LoadSlot(slot)).await?;
|
||||
self.port.await_ack(std::time::Duration::from_secs(30)).await?;
|
||||
|
||||
match self.validate_sensors().await {
|
||||
Ok(()) => Ok(CycleOutcome::Success),
|
||||
Err(err) => {
|
||||
warn!(?err, slot, "sensor validation failed, retrying cycle once");
|
||||
self.port.send_command(&serial::Command::LoadSlot(slot)).await?;
|
||||
self.port.await_ack(std::time::Duration::from_secs(30)).await?;
|
||||
self.validate_sensors().await?;
|
||||
Ok(CycleOutcome::Retried)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_sensors(&mut self) -> Result<(), PlateChangerError> {
|
||||
let state = gpio::read_sensor_state().await;
|
||||
if !state.plate_present {
|
||||
return Err(PlateChangerError::SensorMismatch("plate not detected on bed".into()));
|
||||
}
|
||||
if !state.bed_clear {
|
||||
return Err(PlateChangerError::SensorMismatch("bed obstruction detected".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_serial::SerialPortBuilderExt;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SerialError {
|
||||
#[error("failed to open serial port: {0}")]
|
||||
Open(#[from] tokio_serial::Error),
|
||||
#[error("i/o error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("timed out waiting for hardware acknowledgement")]
|
||||
Timeout,
|
||||
#[error("hardware reported a fault: {0}")]
|
||||
Fault(String),
|
||||
}
|
||||
|
||||
pub enum Command {
|
||||
Eject,
|
||||
LoadSlot(u8),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
fn encode(&self) -> String {
|
||||
match self {
|
||||
Command::Eject => "EJECT\n".to_string(),
|
||||
Command::LoadSlot(slot) => format!("LOAD {slot}\n"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin line-protocol wrapper around the plate changer's serial control
|
||||
/// board (an ASCII command set over a USB-serial link, e.g. an Arduino/RP2040
|
||||
/// running the changer firmware).
|
||||
pub struct SerialPort {
|
||||
inner: tokio_serial::SerialStream,
|
||||
}
|
||||
|
||||
impl SerialPort {
|
||||
pub fn open(path: &str, baud: u32) -> Result<Self, SerialError> {
|
||||
let inner = tokio_serial::new(path, baud).timeout(Duration::from_millis(500)).open_native_async()?;
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
pub async fn send_command(&mut self, command: &Command) -> Result<(), SerialError> {
|
||||
self.inner.write_all(command.encode().as_bytes()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn await_ack(&mut self, timeout: Duration) -> Result<(), SerialError> {
|
||||
let mut buf = [0u8; 64];
|
||||
let read = tokio::time::timeout(timeout, self.inner.read(&mut buf))
|
||||
.await
|
||||
.map_err(|_| SerialError::Timeout)??;
|
||||
|
||||
let response = String::from_utf8_lossy(&buf[..read]);
|
||||
if response.trim() == "OK" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SerialError::Fault(response.trim().to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
mod protocol;
|
||||
|
||||
pub use protocol::{ClientMessage, ServerMessage};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const MIN_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Runs the resilient uplink loop forever: connect, authenticate, exchange
|
||||
/// heartbeats and telemetry/task messages, and reconnect with exponential
|
||||
/// backoff (plus jitter) whenever the connection drops.
|
||||
pub async fn run(
|
||||
config: Config,
|
||||
mut telemetry_rx: mpsc::Receiver<ClientMessage>,
|
||||
task_tx: mpsc::Sender<ServerMessage>,
|
||||
) {
|
||||
let mut backoff = MIN_BACKOFF;
|
||||
|
||||
loop {
|
||||
info!(url = %config.uplink_url, "connecting to cloud uplink");
|
||||
|
||||
match connect_and_serve(&config, &mut telemetry_rx, &task_tx).await {
|
||||
Ok(()) => {
|
||||
info!("uplink connection closed cleanly");
|
||||
backoff = MIN_BACKOFF;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(?err, backoff_secs = backoff.as_secs(), "uplink connection failed, retrying");
|
||||
}
|
||||
}
|
||||
|
||||
let jitter = Duration::from_millis(rand::random::<u64>() % 500);
|
||||
tokio::time::sleep(backoff + jitter).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_serve(
|
||||
config: &Config,
|
||||
telemetry_rx: &mut mpsc::Receiver<ClientMessage>,
|
||||
task_tx: &mpsc::Sender<ServerMessage>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (ws_stream, _resp) = tokio_tungstenite::connect_async(&config.uplink_url).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
send(&mut write, &ClientMessage::Hello {
|
||||
gateway_id: config.gateway_id.clone(),
|
||||
farm_id: config.farm_id.clone(),
|
||||
token: config.gateway_token.clone(),
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
let mut seq: u64 = 0;
|
||||
heartbeat.tick().await; // consume the immediate first tick
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = heartbeat.tick() => {
|
||||
seq += 1;
|
||||
send(&mut write, &ClientMessage::Heartbeat { seq }).await?;
|
||||
}
|
||||
|
||||
Some(msg) = telemetry_rx.recv() => {
|
||||
send(&mut write, &msg).await?;
|
||||
}
|
||||
|
||||
frame = read.next() => {
|
||||
match frame {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(server_msg) => {
|
||||
if task_tx.send(server_msg).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(?err, "failed to decode server message"),
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
write.send(Message::Pong(payload)).await?;
|
||||
}
|
||||
Some(Ok(Message::Close(frame))) => {
|
||||
info!(?frame, "server closed uplink");
|
||||
return Ok(());
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(err)) => return Err(err.into()),
|
||||
None => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(
|
||||
write: &mut (impl SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin),
|
||||
msg: &ClientMessage,
|
||||
) -> anyhow::Result<()> {
|
||||
let payload = serde_json::to_string(msg)?;
|
||||
write.send(Message::Text(payload)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Messages sent from this gateway up to `continuum-backend`'s `/ws/edge/v1` route.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
Hello {
|
||||
gateway_id: String,
|
||||
farm_id: String,
|
||||
token: String,
|
||||
version: &'static str,
|
||||
},
|
||||
Heartbeat {
|
||||
seq: u64,
|
||||
},
|
||||
Telemetry {
|
||||
printer_id: String,
|
||||
payload: serde_json::Value,
|
||||
},
|
||||
TaskResult {
|
||||
task_id: String,
|
||||
ok: bool,
|
||||
error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Messages received from `continuum-backend` over the same connection.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
HelloAck {
|
||||
session_id: String,
|
||||
},
|
||||
HeartbeatAck {
|
||||
seq: u64,
|
||||
},
|
||||
Task {
|
||||
task_id: String,
|
||||
kind: String,
|
||||
payload: serde_json::Value,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user