Initial boilerplate scaffold for continuum-common
Adds ts-core (logger, Supabase JWT verification, Elysia error transformers) and rust-core (thiserror error type, tracing init, G-code checksum/header parsing) packages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "continuum-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Shared error handling, tracing setup, and G-code helpers for Continuum Rust services"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,18 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("invalid g-code checksum on line: {line}")]
|
||||
GcodeChecksum { line: String },
|
||||
|
||||
#[error("malformed g-code header: {reason}")]
|
||||
GcodeHeader { reason: String },
|
||||
|
||||
#[error("{0}")]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,185 @@
|
||||
use crate::error::{Error, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// XORs every byte in `data` together, per the standard Marlin/RepRap
|
||||
/// G-code checksum scheme (checksum covers everything before the `*`,
|
||||
/// including any leading `N<n> ` line number).
|
||||
pub fn compute_checksum(data: &[u8]) -> u8 {
|
||||
data.iter().fold(0u8, |acc, &b| acc ^ b)
|
||||
}
|
||||
|
||||
/// Splits a line into its content and trailing `*NN` checksum, if present.
|
||||
pub fn split_checksum(line: &str) -> (&str, Option<u8>) {
|
||||
match line.rsplit_once('*') {
|
||||
Some((content, checksum_str)) => match checksum_str.trim().parse::<u8>() {
|
||||
Ok(value) => (content, Some(value)),
|
||||
Err(_) => (line, None),
|
||||
},
|
||||
None => (line, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies a checksummed G-code line (e.g. `N12 G1 X10 Y20*43`).
|
||||
/// Returns `Err` if the line carries no `*NN` suffix to verify against.
|
||||
pub fn verify_line_checksum(line: &str) -> Result<bool> {
|
||||
let (content, checksum) = split_checksum(line);
|
||||
match checksum {
|
||||
Some(expected) => Ok(compute_checksum(content.as_bytes()) == expected),
|
||||
None => Err(Error::GcodeChecksum {
|
||||
line: line.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a computed `*NN` checksum to a line that doesn't have one yet.
|
||||
pub fn append_checksum(line: &str) -> String {
|
||||
format!("{line}*{}", compute_checksum(line.as_bytes()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct GcodeHeader {
|
||||
pub slicer: Option<String>,
|
||||
pub filament_type: Option<String>,
|
||||
pub nozzle_diameter: Option<String>,
|
||||
pub estimated_print_time_secs: Option<u64>,
|
||||
/// Every `; key = value` comment field found, keyed by `key`.
|
||||
pub raw_fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Parses slicer metadata out of a G-code file's `;`-prefixed comment lines.
|
||||
/// Handles the PrusaSlicer/SuperSlicer/BambuStudio `; key = value` config
|
||||
/// block as well as Cura's `;KEY:value` header and both slicers' free-form
|
||||
/// `generated by` / estimated-time comments.
|
||||
pub fn parse_header(gcode: &str) -> GcodeHeader {
|
||||
let mut header = GcodeHeader::default();
|
||||
|
||||
for raw_line in gcode.lines() {
|
||||
let line = raw_line.trim();
|
||||
let comment = match line.strip_prefix(';') {
|
||||
Some(rest) => rest.trim(),
|
||||
None => continue,
|
||||
};
|
||||
if comment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if header.slicer.is_none() {
|
||||
if let Some(rest) = comment
|
||||
.strip_prefix("generated by ")
|
||||
.or_else(|| comment.strip_prefix("Generated by "))
|
||||
{
|
||||
let name = rest.split(" on ").next().unwrap_or(rest).trim();
|
||||
header.slicer = Some(name.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = comment
|
||||
.strip_prefix("Generated with ")
|
||||
.or_else(|| comment.strip_prefix("generated with "))
|
||||
{
|
||||
header.slicer = Some(rest.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((key, value)) = comment.split_once('=') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim().to_string();
|
||||
if !key.is_empty() && !value.is_empty() {
|
||||
match key.as_str() {
|
||||
"filament_type" => header.filament_type = Some(value.clone()),
|
||||
"nozzle_diameter" => header.nozzle_diameter = Some(value.clone()),
|
||||
_ => {}
|
||||
}
|
||||
if key.starts_with("estimated printing time") {
|
||||
header.estimated_print_time_secs = parse_duration(&value);
|
||||
}
|
||||
header.raw_fields.insert(key, value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = comment.split_once(':') {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
if key.eq_ignore_ascii_case("TIME") {
|
||||
header.estimated_print_time_secs =
|
||||
value.parse::<u64>().ok().or_else(|| parse_duration(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header
|
||||
}
|
||||
|
||||
/// Parses slicer duration strings like `1h 23m 45s` or `45m 2s` into seconds.
|
||||
fn parse_duration(s: &str) -> Option<u64> {
|
||||
let mut total: u64 = 0;
|
||||
let mut digits = String::new();
|
||||
let mut matched_any = false;
|
||||
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_digit() {
|
||||
digits.push(c);
|
||||
} else if matches!(c, 'd' | 'h' | 'm' | 's') {
|
||||
if digits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let n: u64 = digits.parse().ok()?;
|
||||
digits.clear();
|
||||
matched_any = true;
|
||||
total += match c {
|
||||
'd' => n * 86_400,
|
||||
'h' => n * 3_600,
|
||||
'm' => n * 60,
|
||||
's' => n,
|
||||
_ => 0,
|
||||
};
|
||||
} else {
|
||||
digits.clear();
|
||||
}
|
||||
}
|
||||
|
||||
matched_any.then_some(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn checksum_roundtrip() {
|
||||
let line = "N12 G1 X10.0 Y20.0 F1500.0";
|
||||
let with_checksum = append_checksum(line);
|
||||
assert!(verify_line_checksum(&with_checksum).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksum_detects_corruption() {
|
||||
let good = append_checksum("N1 G28");
|
||||
let corrupted = good.replace("G28", "G29");
|
||||
assert!(!verify_line_checksum(&corrupted).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_parses_prusaslicer_style() {
|
||||
let gcode = "\
|
||||
; generated by PrusaSlicer 2.7.0+win64 on 2026-01-01 at 12:00:00
|
||||
; filament_type = PLA
|
||||
; nozzle_diameter = 0.4
|
||||
; estimated printing time (normal mode) = 1h 23m 45s
|
||||
G28
|
||||
";
|
||||
let header = parse_header(gcode);
|
||||
assert_eq!(header.slicer.as_deref(), Some("PrusaSlicer 2.7.0+win64"));
|
||||
assert_eq!(header.filament_type.as_deref(), Some("PLA"));
|
||||
assert_eq!(header.estimated_print_time_secs, Some(5025));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_parses_cura_style() {
|
||||
let gcode = ";Generated with Cura_SteamEngine 5.6.0\n;TIME:3725\nG28\n";
|
||||
let header = parse_header(gcode);
|
||||
assert_eq!(header.slicer.as_deref(), Some("Cura_SteamEngine 5.6.0"));
|
||||
assert_eq!(header.estimated_print_time_secs, Some(3725));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod error;
|
||||
pub mod gcode;
|
||||
pub mod tracing_init;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use tracing_init::init_tracing;
|
||||
@@ -0,0 +1,14 @@
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
/// Initializes a JSON-formatted `tracing` subscriber driven by `RUST_LOG`
|
||||
/// (defaulting to `info`). Call once at process startup.
|
||||
pub fn init_tracing(service_name: &str) {
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt::layer().json().with_target(true))
|
||||
.init();
|
||||
|
||||
tracing::info!(service = service_name, "tracing initialized");
|
||||
}
|
||||
Reference in New Issue
Block a user