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
+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);
}
}
}
}
}