Files
Dylan M. Taylor 5375f49ffc Add craft-plotter (HP-GL, DMPL, GP-GL); craft-gcode: bare output and Grbl replies (#863)
* craft-gcode: bare output for line-by-line senders, and Grbl replies

Writer::bare writes the same moves as Writer::new with every line a
command: no comments, coordinates at up to three decimals without
trailing zeros, M5 at the start, and the return to the origin at the
end. A sender that waits for an ok after each line can stream it as is.
Writer::set_feed changes the feed rate between contours.

The new grbl module has the real-time command bytes (status query, feed
hold, cycle start, soft reset) and the parsers of the controller's
answers: the state of a status report (read up to MAX_REPLY_LEN bytes)
and the ok / error / ALARM acknowledgement of a line. No I/O.

* Add craft-plotter: HP-GL, DMPL and GP-GL from a job in millimetres

One crate for the command languages of vinyl cutters and pen plotters,
one module per language: hpgl, dmpl, gpgl (plain) and gpgl::silhouette
(the Silhouette dialect in its three generations, with registration
marks, the device queries and the parsers of their replies).

A Job is ordered operations: polylines in millimetres plus tool
settings. Each encode gives a Program: the commands, their framing, and
where a sender waits for the registration marks. The crate does no I/O
and builds for wasm.

A job is refused past 64 operations, 2,000,000 points, 10 passes or a
2 mm overcut, or when a value is not finite. Replies are read up to 256
bytes and report at most 8 tool holders. Every limit has a test, and
tests/golden.rs fixes the exact commands of each language.

The README records where the protocol facts come from.

* craft-plotter: optional serde for Mat, as its short name

A caller that keeps a mat in a settings file or a machine table needs to
read and write it. The serde feature serializes Mat as its short name
("12x24") and refuses an unknown name. Off by default; the crate still
has thiserror as its only required dependency.
2026-11-11 17:29:14 -05:00

141 lines
5.2 KiB
Rust

//! Talking to a Grbl controller: the real-time command bytes and the parsers of what it answers.
//! No I/O happens here; a sender writes the bytes and hands the replies in.
//!
//! Facts from the Grbl v1.1 interface documentation
//! (<https://github.com/gnea/grbl/wiki/Grbl-v1.1-Interface>): a line is answered with `ok` or
//! `error:<n>`, a fault with `ALARM:<n>`, and `?` with a status report `<State|…>`.
/// Real-time command: ask for a status report ([`parse_status`] reads it).
pub const STATUS_QUERY: u8 = b'?';
/// Real-time command: feed hold (pause).
pub const FEED_HOLD: u8 = b'!';
/// Real-time command: cycle start (resume).
pub const CYCLE_START: u8 = b'~';
/// Real-time command: soft reset (Ctrl-X). Cancels the job.
pub const SOFT_RESET: u8 = 0x18;
/// The line that switches the laser or spindle off; send it when a job is abandoned mid-stream.
pub const TOOL_OFF: &str = "M5";
/// Most bytes of a status report [`parse_status`] looks at. Grbl's own lines are well under 100
/// bytes.
pub const MAX_REPLY_LEN: usize = 256;
/// The machine state a status report starts with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
Idle,
Run,
Hold,
Jog,
Alarm,
Door,
Check,
Home,
Sleep,
/// A state word this crate does not know, as sent.
Other(String),
}
/// The first `MAX_REPLY_LEN` bytes of `reply`, cut at a character boundary.
fn capped(reply: &str) -> &str {
let mut end = reply.len().min(MAX_REPLY_LEN);
while !reply.is_char_boundary(end) {
end -= 1;
}
reply.get(..end).unwrap_or("")
}
/// The state of a status report (`<Idle|MPos:…>`, `<Hold:0|…>`). `None` when `reply` is not a
/// status report.
pub fn parse_status(reply: &str) -> Option<State> {
let body = capped(reply).trim().strip_prefix('<')?;
Some(match body.split(['|', '>', ':']).next().unwrap_or("") {
"Idle" => State::Idle,
"Run" => State::Run,
"Hold" => State::Hold,
"Jog" => State::Jog,
"Alarm" => State::Alarm,
"Door" => State::Door,
"Check" => State::Check,
"Home" => State::Home,
"Sleep" => State::Sleep,
other => State::Other(other.to_string()),
})
}
/// What the controller said about the line it was sent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ack {
/// `ok`: the line was accepted; send the next.
Ok,
/// `error:<n>` or `ALARM:<n>`: stop sending and switch the tool off.
Rejected,
}
/// Reads what has arrived since a line was sent. `None` while the answer is still incomplete: read
/// more and ask again with everything received so far. A rejection anywhere in the reply wins over
/// an `ok`. The whole reply is searched, without copying it: unrelated lines may arrive before the
/// answer, and how much to wait for is the sender's limit to set.
pub fn parse_ack(reply: &str) -> Option<Ack> {
if reply.contains("error") || reply.contains("ALARM") {
Some(Ack::Rejected)
} else if reply.contains("ok") {
Some(Ack::Ok)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn real_time_bytes_are_the_documented_ones() {
assert_eq!([STATUS_QUERY, FEED_HOLD, CYCLE_START, SOFT_RESET], *b"?!~\x18");
}
#[test]
fn status_reports_give_their_state() {
assert_eq!(parse_status("<Idle|MPos:0,0,0>"), Some(State::Idle));
assert_eq!(parse_status("<Hold:0|MPos:0,0,0>"), Some(State::Hold));
assert_eq!(parse_status(" <Door:3|WPos:1,2,3|FS:0,0>\r\n"), Some(State::Door));
for (word, state) in [
("Run", State::Run),
("Jog", State::Jog),
("Alarm", State::Alarm),
("Check", State::Check),
("Home", State::Home),
("Sleep", State::Sleep),
] {
assert_eq!(parse_status(&format!("<{word}>")), Some(state));
}
assert_eq!(parse_status("<Tool|MPos:0,0,0>"), Some(State::Other("Tool".into())));
assert_eq!(parse_status("<"), Some(State::Other(String::new())));
assert_eq!(parse_status("ok"), None);
assert_eq!(parse_status(""), None);
}
#[test]
fn acks_are_ok_rejected_or_not_yet() {
assert_eq!(parse_ack("ok\r\n"), Some(Ack::Ok));
assert_eq!(parse_ack("error:22\r\n"), Some(Ack::Rejected));
assert_eq!(parse_ack("ALARM:1\r\n"), Some(Ack::Rejected));
assert_eq!(parse_ack("ok\r\nerror:9\r\n"), Some(Ack::Rejected));
assert_eq!(parse_ack("o"), None);
assert_eq!(parse_ack(""), None);
}
#[test]
fn a_status_report_is_read_only_up_to_the_cap() {
let pad = " ".repeat(MAX_REPLY_LEN - 2);
// An acknowledgement is found wherever it is.
assert_eq!(parse_ack(&format!("{pad}{pad}ok")), Some(Ack::Ok));
assert_eq!(parse_status(&format!("{pad}<Idle>")), Some(State::Other("I".into())));
assert_eq!(parse_status(&format!("{pad} <Idle>")), None);
// A long unknown state word is kept only up to the cap; a cut inside a character backs up.
assert_eq!(parse_status(&format!("<{}", "x".repeat(1000))), Some(State::Other("x".repeat(MAX_REPLY_LEN - 1))));
assert_eq!(parse_status(&format!("<{}", "é".repeat(1000))), Some(State::Other("é".repeat(127))));
}
}