Skip to main content

letratag_protocol/
lib.rs

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