thermark 0.36.1

Local sticker printing for pocket thermal printers over BLE/USB — QR links, inventory tags, badges, photos; no vendor app
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Transports: Bluetooth LE (macOS-friendly) and USB serial.
//!
//! Enable with Cargo features:
//! - `ble` — `BleTransport` (btleplug)
//! - `serial` — `SerialTransport`

use crate::errors::{Error, Result};
use crate::packet::{MAX_FRAME_LEN, Packet};
use std::fmt;
use std::time::Duration;
use tracing::debug;

/// Bound one write (including flush) or teardown, not the entire print job.
#[cfg(any(feature = "ble", feature = "serial"))]
pub(crate) const IO_TIMEOUT: Duration = Duration::from_secs(5);

/// A write timeout is a transport failure, not a missing response: bytes may
/// already have reached the printer, so callers must not resend the operation.
#[cfg(any(feature = "ble", feature = "serial"))]
pub(crate) async fn with_timeout<T>(
    operation: &'static str,
    budget: Duration,
    future: impl std::future::Future<Output = Result<T>>,
) -> Result<T> {
    tokio::time::timeout(budget, future)
        .await
        .map_err(|_| Error::transport(format!("{operation} timed out after {budget:?}")))?
}

#[cfg(all(test, any(feature = "ble", feature = "serial")))]
mod timeout_tests {
    use super::*;

    #[tokio::test(start_paused = true)]
    async fn pending_operation_is_bounded_and_reports_transport_failure() {
        let start = tokio::time::Instant::now();
        let error = with_timeout::<()>("test disconnect", IO_TIMEOUT, std::future::pending())
            .await
            .unwrap_err();
        assert!(
            matches!(error, Error::Transport(message) if message.contains("test disconnect timed out"))
        );
        assert_eq!(tokio::time::Instant::now() - start, IO_TIMEOUT);
    }

    #[tokio::test(start_paused = true)]
    async fn operation_results_are_preserved() {
        assert_eq!(
            with_timeout("write", IO_TIMEOUT, async { Ok(42) })
                .await
                .unwrap(),
            42
        );
        let error = with_timeout::<()>("write", IO_TIMEOUT, async {
            Err(Error::transport("link down"))
        })
        .await
        .unwrap_err();
        assert!(matches!(error, Error::Transport(message) if message == "link down"));
    }
}

/// Common packet transport used by [`crate::printer::PrinterClient`].
///
/// Native `async fn` in traits (no `async-trait`); not object-safe by design.
pub trait Transport: Send {
    /// Send all bytes or return a transport error. Native transports bound
    /// writes to five seconds. On timeout, some bytes may already be on the
    /// wire; close the session rather than replaying the failed write.
    fn send_raw(&mut self, data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send;

    /// Return available bytes, waiting up to `wait` when none are available.
    /// Fragments are valid; an empty result means no bytes arrived.
    fn recv_raw(
        &mut self,
        wait: Duration,
    ) -> impl std::future::Future<Output = Result<Vec<u8>>> + Send;

    fn close(&mut self) -> impl std::future::Future<Output = Result<()>> + Send {
        async { Ok(()) }
    }

    fn send_packet(
        &mut self,
        packet: &Packet,
    ) -> impl std::future::Future<Output = Result<()>> + Send {
        async {
            // A print can contain hundreds of row packets. Keep their small,
            // bounded frame storage inline instead of allocating a fresh Vec
            // for every call.
            let mut frame = [0u8; MAX_FRAME_LEN];
            let bytes = packet.encode_into(&mut frame)?;
            debug!(bytes = %hex::encode(bytes), "TX");
            self.send_raw(bytes).await
        }
    }
}

// ─── BLE device selection (pure; no btleplug) ───────────────────────────────

/// How `-a` / `THERMARK_ADDR` is matched to a BLE peripheral.
///
/// Default is [`BleMatchMode::Exact`] so short selectors cannot latch onto the
/// wrong nearby device. Use [`BleMatchMode::Fuzzy`] only when you intentionally
/// want substring matching (`--fuzzy` on the CLI).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BleMatchMode {
    /// Case-insensitive exact advertising name **or** exact peripheral id.
    #[default]
    Exact,
    /// Substring name / partial id. Can match the wrong device.
    Fuzzy,
}

impl BleMatchMode {
    pub fn from_fuzzy(fuzzy: bool) -> Self {
        if fuzzy { Self::Fuzzy } else { Self::Exact }
    }
}

/// A BLE peripheral seen during a scan.
///
/// `name` is `None` when the device advertises no local name — previously a
/// `"(no name)"` sentinel string that had to be excluded from matching at every
/// comparison site, and which the connect path never actually produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BleCandidate {
    pub id: String,
    pub name: Option<String>,
}

impl BleCandidate {
    pub fn new(id: impl Into<String>, name: Option<String>) -> Self {
        let name = name.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
        Self {
            id: id.into(),
            name,
        }
    }

    /// Advertising name, or a placeholder for display only — never for matching.
    pub fn display_name(&self) -> &str {
        self.name.as_deref().unwrap_or("(no name)")
    }

    /// True if the advertised name looks like a pocket label printer.
    ///
    /// Single source of truth for this heuristic: scan filtering, fuzzy-match
    /// scoring, and `scan --save` ranking all consult it.
    pub fn looks_like_label_printer(&self) -> bool {
        self.name
            .as_deref()
            .map(name_looks_like_label_printer)
            .unwrap_or(false)
    }
}

impl fmt::Display for BleCandidate {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.name {
            Some(n) => write!(f, "{n} ({})", self.id),
            None => write!(f, "{}", self.id),
        }
    }
}

/// Name prefixes/substrings used by common pocket thermal label printers.
const PRINTER_NAME_MARKERS: &[&str] = &["b1", "b21", "b18", "d11", "d110", "niim", "jc-"];

/// Whether an advertising name looks like a label printer (case-insensitive).
pub fn name_looks_like_label_printer(name: &str) -> bool {
    let name = name.to_ascii_lowercase();
    PRINTER_NAME_MARKERS.iter().any(|m| name.contains(m))
}

/// Whether a serial device path appears to belong to a configured printer
/// selector. macOS commonly exposes an already-connected BLE printer as a
/// `/dev/cu.<advertising-name>` endpoint.
pub fn serial_path_matches_selector(path: &str, selector: &str) -> bool {
    let path = path.trim().to_ascii_lowercase();
    let selector = selector.trim().to_ascii_lowercase();
    !selector.is_empty() && path.ends_with(&selector)
}

/// Find a serial endpoint that matches a BLE selector, when serial support is
/// available. This is primarily a macOS diagnostic: a matching endpoint can
/// mean another Bluetooth client already owns the printer's BLE session.
#[cfg(all(target_os = "macos", feature = "ble", feature = "serial"))]
pub(crate) fn serial_port_for_selector(selector: &str) -> Option<String> {
    serial::SerialTransport::list_ports()
        .ok()?
        .into_iter()
        .find(|path| serial_path_matches_selector(path, selector))
}

/// Score how well `selector` matches a scanned peripheral. Higher is better.
///
/// Returns `None` if this mode does not treat the device as a match.
pub fn score_ble_candidate(
    selector: &str,
    candidate: &BleCandidate,
    mode: BleMatchMode,
) -> Option<i32> {
    let sel = selector.trim().to_ascii_lowercase();
    if sel.is_empty() {
        return None;
    }
    let id_l = candidate.id.to_ascii_lowercase();
    let name_l = candidate.name.as_ref().map(|n| n.to_ascii_lowercase());

    let id_exact = id_l == sel;
    let name_exact = name_l.as_deref() == Some(sel.as_str());

    match mode {
        BleMatchMode::Exact => {
            if id_exact {
                Some(1000)
            } else if name_exact {
                Some(900)
            } else {
                None
            }
        }
        BleMatchMode::Fuzzy => {
            let mut score = 0i32;
            if id_exact {
                score += 1000;
            }
            if name_exact {
                score += 900;
            }
            if name_l.as_deref().is_some_and(|n| n.contains(&sel)) {
                score += 100;
            }
            // Partial UUID/id only when selector is long enough to be intentional.
            if id_l.contains(&sel) && sel.len() >= 8 {
                score += 50;
            }
            if candidate.looks_like_label_printer() {
                score += 30;
            }
            // Require a real selector hit (not printer-name bonus alone).
            if score < 100 { None } else { Some(score) }
        }
    }
}

/// Choose the best matching candidate.
///
/// Fails when nothing matches, or when two candidates share the top score
/// (ambiguous fuzzy match).
pub fn select_ble_candidate(
    selector: &str,
    candidates: &[BleCandidate],
    mode: BleMatchMode,
) -> Result<BleCandidate> {
    let mut ranked: Vec<(i32, &BleCandidate)> = candidates
        .iter()
        .filter_map(|c| score_ble_candidate(selector, c, mode).map(|s| (s, c)))
        .collect();

    if ranked.is_empty() {
        let hint = match mode {
            BleMatchMode::Exact => "Substring match: add --fuzzy (can pick the wrong device).",
            BleMatchMode::Fuzzy => "No substring matched either.",
        };
        return Err(Error::transport(format!(
            "no BLE device matching '{selector}' ({mode:?} match). Run `thermark scan` first \
             (on macOS the id is a UUID, not a classic MAC). \
             Tip: use the full advertising name, e.g. -a \"B1-YourPrinter\". {hint}"
        )));
    }

    ranked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id)));
    let best = ranked[0].0;
    let ties: Vec<_> = ranked.iter().filter(|(s, _)| *s == best).collect();
    if ties.len() > 1 {
        let list = ties
            .iter()
            .map(|(_, c)| c.to_string())
            .collect::<Vec<_>>()
            .join(", ");
        return Err(Error::transport(format!(
            "ambiguous BLE match for '{selector}' (score {best}): {list}. \
             Use the full unique name or peripheral id."
        )));
    }

    Ok(ranked[0].1.clone())
}

// ─── BLE ────────────────────────────────────────────────────────────────────

#[cfg(feature = "ble")]
mod ble;

#[cfg(feature = "ble")]
pub use ble::{BleDeviceInfo, BleTransport, PRINTER_CHAR, PRINTER_SERVICE, bluetooth_available};

#[cfg(test)]
mod ble_match_tests {
    use super::*;

    fn named(id: &str, name: &str) -> BleCandidate {
        BleCandidate::new(id, Some(name.to_string()))
    }

    fn unnamed(id: &str) -> BleCandidate {
        BleCandidate::new(id, None)
    }

    #[test]
    fn serial_path_matches_configured_printer_name() {
        assert!(serial_path_matches_selector(
            "/dev/cu.B1-I304120661",
            "B1-I304120661"
        ));
        assert!(serial_path_matches_selector(
            "/dev/tty.b1-i304120661",
            "b1-i304120661"
        ));
        assert!(!serial_path_matches_selector(
            "/dev/cu.B1-OtherPrinter",
            "B1-I304120661"
        ));
    }

    #[test]
    fn exact_requires_full_name_or_id() {
        let c = named("uuid-1", "B1-Kitchen");
        assert!(score_ble_candidate("b1", &c, BleMatchMode::Exact).is_none());
        assert_eq!(
            score_ble_candidate("B1-Kitchen", &c, BleMatchMode::Exact),
            Some(900)
        );
        assert_eq!(
            score_ble_candidate("uuid-1", &c, BleMatchMode::Exact),
            Some(1000)
        );
        // Case-insensitive name
        assert_eq!(
            score_ble_candidate("b1-kitchen", &c, BleMatchMode::Exact),
            Some(900)
        );
    }

    #[test]
    fn fuzzy_allows_substring_but_not_printer_bonus_alone() {
        let c = named("uuid-1", "B1-Kitchen");
        assert!(score_ble_candidate("b1", &c, BleMatchMode::Fuzzy).is_some());
        // Printer-like name without selector hit
        assert!(score_ble_candidate("xyz", &c, BleMatchMode::Fuzzy).is_none());
    }

    #[test]
    fn exact_rejects_substring_that_fuzzy_accepts() {
        let cands = [named("id-a", "B1-Kitchen")];
        assert!(select_ble_candidate("B1", &cands, BleMatchMode::Exact).is_err());
        assert!(select_ble_candidate("B1", &cands, BleMatchMode::Fuzzy).is_ok());
    }

    #[test]
    fn ambiguous_fuzzy_errors() {
        let cands = [named("id-a", "B1-One"), named("id-b", "B1-Two")];
        let err = select_ble_candidate("B1", &cands, BleMatchMode::Fuzzy).unwrap_err();
        assert!(err.to_string().contains("ambiguous"), "{err}");
    }

    #[test]
    fn exact_picks_unique_name() {
        let cands = [named("id-a", "B1-One"), named("id-b", "B1-Two")];
        let win = select_ble_candidate("B1-Two", &cands, BleMatchMode::Exact).expect("exact");
        assert_eq!(win.id, "id-b");
        assert_eq!(win.name.as_deref(), Some("B1-Two"));
    }

    #[test]
    fn unnamed_device_matches_only_by_id() {
        let cands = [unnamed("id-a")];
        // The old "(no name)" sentinel was compared as if it were a real name.
        assert!(select_ble_candidate("(no name)", &cands, BleMatchMode::Fuzzy).is_err());
        assert!(select_ble_candidate("id-a", &cands, BleMatchMode::Exact).is_ok());
    }

    #[test]
    fn blank_advertising_name_is_normalized_to_none() {
        assert_eq!(BleCandidate::new("id", Some("   ".into())).name, None);
        assert_eq!(BleCandidate::new("id", Some(String::new())).name, None);
    }

    #[test]
    fn printer_name_heuristic_is_shared() {
        for n in ["B1-Kitchen", "b21-x", "D110", "NIIMBOT-x", "jc-200"] {
            assert!(name_looks_like_label_printer(n), "{n}");
        }
        for n in ["AirPods", "random-watch", ""] {
            assert!(!name_looks_like_label_printer(n), "{n}");
        }
    }
}

// ─── USB serial ─────────────────────────────────────────────────────────────

#[cfg(feature = "serial")]
mod serial;

#[cfg(feature = "serial")]
pub use serial::SerialTransport;