Initial boilerplate scaffold for continuum-proxy

This commit is contained in:
2026-08-28 16:25:26 +00:00
commit 2807b2b067
18 changed files with 1002 additions and 0 deletions
+139
View File
@@ -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(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod bambu;
pub mod moonraker;
pub mod prusalink;
+74
View File
@@ -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(())
}
+56
View File
@@ -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"),
}
}
}
+59
View File
@@ -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(())
}
}
+40
View File
@@ -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())
}
+44
View File
@@ -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()
}
+33
View File
@@ -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
View File
@@ -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);
}
}
}
}
}
+20
View File
@@ -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,
}
}
+70
View File
@@ -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(())
}
}
+64
View File
@@ -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()))
}
}
}
+113
View File
@@ -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(())
}
+42
View File
@@ -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,
},
}