Skip to main content

letratag_protocol/
lib.rs

1//! Offline LetraTag LT-200B wire encoding. Not verified against real hardware.
2
3use std::{error, fmt};
4
5pub const HEAD_ROWS: usize = 32;
6/// Software resource limit, not a claim about the printer's capabilities.
7pub const MAX_COLUMNS: usize = 8000;
8
9const BYTES_PER_COLUMN: usize = HEAD_ROWS / 8;
10const MAX_BODY_LENGTH: usize = MAX_COLUMNS * BYTES_PER_COLUMN + 28;
11const MAGIC: [u8; 2] = [0x12, 0x34];
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Error {
15    InvalidWidth { width: usize },
16    PixelLength { expected: usize, actual: usize },
17    PixelValue { index: usize, value: u8 },
18    PackedLength { expected: usize, actual: usize },
19    HeaderLength { actual: usize },
20    HeaderMagic,
21    HeaderChecksum,
22    BodyTooLarge { length: usize },
23    InvalidWriteLength { max_write: usize },
24    TooManyChunks { count: usize },
25    StatusLength { actual: usize },
26    StatusPrefix,
27    InvalidCassetteId { id: u8 },
28}
29
30impl fmt::Display for Error {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            Self::InvalidWidth { width } => {
34                write!(f, "raster width {width} is outside 1..={MAX_COLUMNS}")
35            }
36            Self::PixelLength { expected, actual } => {
37                write!(f, "expected {expected} pixels, got {actual}")
38            }
39            Self::PixelValue { index, value } => {
40                write!(f, "pixel {index} has non-binary value {value}")
41            }
42            Self::PackedLength { expected, actual } => {
43                write!(f, "expected {expected} packed bytes, got {actual}")
44            }
45            Self::HeaderLength { actual } => {
46                write!(f, "expected a 9-byte header, got {actual} bytes")
47            }
48            Self::HeaderMagic => f.write_str("invalid header preamble or magic"),
49            Self::HeaderChecksum => f.write_str("invalid header checksum"),
50            Self::BodyTooLarge { length } => {
51                write!(f, "body length {length} exceeds {MAX_BODY_LENGTH}")
52            }
53            Self::InvalidWriteLength { max_write } => {
54                write!(f, "usable write length {max_write} is outside 9..=512")
55            }
56            Self::TooManyChunks { count } => {
57                write!(f, "job requires {count} chunks; maximum is 255")
58            }
59            Self::StatusLength { actual } => {
60                write!(f, "expected a 3-byte status, got {actual} bytes")
61            }
62            Self::StatusPrefix => f.write_str("invalid status prefix"),
63            Self::InvalidCassetteId { id } => {
64                write!(f, "cassette id {id} is outside 1..=5")
65            }
66        }
67    }
68}
69
70impl error::Error for Error {}
71
72/// A full 32-row frame, in row-major order, containing only 0 (white) or 1 (black).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Raster {
75    width: usize,
76    pixels: Vec<u8>,
77}
78
79impl Raster {
80    pub fn new(width: usize, pixels: Vec<u8>) -> Result<Self, Error> {
81        validate_width(width)?;
82        let expected = width * HEAD_ROWS;
83        if pixels.len() != expected {
84            return Err(Error::PixelLength {
85                expected,
86                actual: pixels.len(),
87            });
88        }
89        if let Some((index, &value)) = pixels.iter().enumerate().find(|(_, value)| **value > 1) {
90            return Err(Error::PixelValue { index, value });
91        }
92        Ok(Self { width, pixels })
93    }
94
95    pub fn width(&self) -> usize {
96        self.width
97    }
98
99    pub fn pixels(&self) -> &[u8] {
100        &self.pixels
101    }
102
103    /// Out-of-frame coordinates are white, including overflowing coordinates.
104    pub fn get(&self, x: usize, y: usize) -> bool {
105        x < self.width && y < HEAD_ROWS && self.pixels[y * self.width + x] == 1
106    }
107
108    pub fn pack_columns(&self) -> Vec<u8> {
109        let mut packed = vec![0; self.width * BYTES_PER_COLUMN];
110        for x in 0..self.width {
111            for y in 0..HEAD_ROWS {
112                if self.get(x, y) {
113                    packed[x * 4 + 3 - y / 8] |= 1 << (7 - y % 8);
114                }
115            }
116        }
117        packed
118    }
119
120    pub fn from_packed_columns(width: usize, packed: &[u8]) -> Result<Self, Error> {
121        validate_width(width)?;
122        let expected = width * BYTES_PER_COLUMN;
123        if packed.len() != expected {
124            return Err(Error::PackedLength {
125                expected,
126                actual: packed.len(),
127            });
128        }
129        let mut pixels = vec![0; width * HEAD_ROWS];
130        for x in 0..width {
131            for y in 0..HEAD_ROWS {
132                pixels[y * width + x] =
133                    u8::from(packed[x * 4 + 3 - y / 8] & (1 << (7 - y % 8)) != 0);
134            }
135        }
136        Self::new(width, pixels)
137    }
138}
139
140fn validate_width(width: usize) -> Result<(), Error> {
141    if !(1..=MAX_COLUMNS).contains(&width) {
142        return Err(Error::InvalidWidth { width });
143    }
144    Ok(())
145}
146
147#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
148pub enum Profile {
149    /// thermal2026: explicit single copy, 0x81 raster mode, cut-at-end command.
150    #[default]
151    Modern,
152    /// ysfchn: 0x01 raster mode followed by form feed.
153    Legacy,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct Job {
158    body: Vec<u8>,
159}
160
161impl Job {
162    pub fn new(raster: &Raster, profile: Profile) -> Result<Self, Error> {
163        let mut body = Vec::with_capacity(raster.width * BYTES_PER_COLUMN + 28);
164        body.extend_from_slice(&[0x1b, 0x73, 0x9a, 0x02, 0x00, 0x00]);
165        if profile == Profile::Modern {
166            body.extend_from_slice(&[0x1b, 0x23, 0x01]);
167        }
168        let mode = if profile == Profile::Modern {
169            0x81
170        } else {
171            0x01
172        };
173        body.extend_from_slice(&[0x1b, 0x44, mode, 0x02]);
174        body.extend_from_slice(&(raster.width as u32).to_le_bytes());
175        body.extend_from_slice(&(HEAD_ROWS as u32).to_le_bytes());
176        body.extend_from_slice(&raster.pack_columns());
177        match profile {
178            Profile::Modern => body.extend_from_slice(&[0x1b, 0x70, 0x30]),
179            Profile::Legacy => body.extend_from_slice(&[0x1b, 0x45]),
180        }
181        body.extend_from_slice(&[0x1b, 0x41, 0x1b, 0x51]);
182        Ok(Self { body })
183    }
184
185    pub fn body(&self) -> &[u8] {
186        &self.body
187    }
188
189    /// Ordered characteristic values: header first, then indexed body chunks.
190    ///
191    /// `max_write` is the usable characteristic value length, NOT the raw ATT MTU.
192    /// Every chunk reserves one index byte and two final-magic bytes, even when
193    /// non-final. A job may fit the raster limit but exceed the chunk index space.
194    pub fn writes(&self, max_write: usize) -> Result<Vec<Vec<u8>>, Error> {
195        if !(9..=512).contains(&max_write) {
196            return Err(Error::InvalidWriteLength { max_write });
197        }
198        let ceiling = 500.min(max_write - 3);
199        let count = self.body.len().div_ceil(ceiling);
200        if count > 255 {
201            return Err(Error::TooManyChunks { count });
202        }
203        let mut header = vec![0xff, 0xf0, MAGIC[0], MAGIC[1]];
204        header.extend_from_slice(&(self.body.len() as u32).to_le_bytes());
205        header.push(checksum(&header));
206        let mut writes = Vec::with_capacity(count + 1);
207        writes.push(header);
208        for (i, bytes) in self.body.chunks(ceiling).enumerate() {
209            let mut chunk = Vec::with_capacity(bytes.len() + 3);
210            chunk.push(if i >= 27 { (i + 1) as u8 } else { i as u8 });
211            chunk.extend_from_slice(bytes);
212            if i + 1 == count {
213                chunk.extend_from_slice(&MAGIC);
214            }
215            writes.push(chunk);
216        }
217        Ok(writes)
218    }
219}
220
221fn checksum(bytes: &[u8]) -> u8 {
222    bytes.iter().fold(0, |sum, byte| sum.wrapping_add(*byte))
223}
224
225/// Cassette id 3: 12 mm tape (the only width the LT-200B chassis accepts).
226/// The id enum is shared by the `ESC M` directive and the advertising-data
227/// cassette field (S2 docs/protocol/letratag-bt.md): 1=6mm, 2=9mm, 3=12mm,
228/// 4=19mm, 5=24mm.
229pub const CASSETTE_ID_12MM: u8 = 3;
230
231/// Stand-alone set-cassette-type write for the short-command characteristic:
232/// `HEADER + ESC s + ESC M <id> 00 00 00 + ESC Q` as one non-print write
233/// (S2 docs/protocol/letratag-bt.md, "Stand-alone set-cassette-type payload").
234/// The three zero pad bytes are part of the wire format. S1 documents the
235/// same command with a 3-byte `ESC M` and no padding; that variant was
236/// never bench-tested (S1's CLI does not use it), so the S2 form is used.
237pub fn set_cassette_type_write(cassette_id: u8) -> Result<Vec<u8>, Error> {
238    if !(1..=5).contains(&cassette_id) {
239        return Err(Error::InvalidCassetteId { id: cassette_id });
240    }
241    let mut body = Vec::with_capacity(14);
242    body.extend_from_slice(&[0x1b, 0x73, 0x9a, 0x02, 0x00, 0x00]);
243    body.extend_from_slice(&[0x1b, 0x4d, cassette_id, 0x00, 0x00, 0x00]);
244    body.extend_from_slice(&[0x1b, 0x51]);
245    let mut write = vec![0xff, 0xf0, MAGIC[0], MAGIC[1]];
246    write.extend_from_slice(&(body.len() as u32).to_le_bytes());
247    write.push(checksum(&write));
248    write.extend_from_slice(&body);
249    Ok(write)
250}
251
252/// Validate header framing and checksum, returning a software-bounded body length.
253/// This does not validate the body or assert that a length describes a print job.
254pub fn parse_header(bytes: &[u8]) -> Result<usize, Error> {
255    if bytes.len() != 9 {
256        return Err(Error::HeaderLength {
257            actual: bytes.len(),
258        });
259    }
260    if bytes[..4] != [0xff, 0xf0, MAGIC[0], MAGIC[1]] {
261        return Err(Error::HeaderMagic);
262    }
263    if checksum(&bytes[..8]) != bytes[8] {
264        return Err(Error::HeaderChecksum);
265    }
266    let length = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
267    if length > MAX_BODY_LENGTH {
268        return Err(Error::BodyTooLarge { length });
269    }
270    Ok(length)
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum Status {
275    Success,
276    Unconfirmed(u8),
277}
278
279impl Status {
280    /// Only zero is recognized as success. Other codes retain their raw value.
281    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
282        match bytes {
283            [0x1b, 0x52, 0] => Ok(Self::Success),
284            [0x1b, 0x52, raw] => Ok(Self::Unconfirmed(*raw)),
285            [_, _, _] => Err(Error::StatusPrefix),
286            _ => Err(Error::StatusLength {
287                actual: bytes.len(),
288            }),
289        }
290    }
291}