thermark 0.33.0

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
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! Command-line surface: the clap types and shared argument groups.

use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use thermark::config::{Config, ConnPref, MAX_SCAN_SECS};
use thermark::label::{TextAlign, TextSide};
use thermark::print_task::PrintTask;
use thermark::protocol::Model;
#[cfg(feature = "ble")]
use thermark::transport::BleMatchMode;
use thermark::types::{Density, Rotation, Threshold};
use thermark::wifi::WifiSecurity;

#[derive(Parser, Debug)]
#[command(
    name = "thermark",
    version,
    about = "Local sticker printing over BLE/USB — guest Wi‑Fi, QR, inventory (no vendor app)"
)]
pub struct Cli {
    /// Verbose logging (`RUST_LOG` still overrides when set)
    #[arg(short, long, global = true)]
    pub verbose: bool,

    #[command(subcommand)]
    pub command: Commands,
}

// ─── Shared argument groups ─────────────────────────────────────────────────

/// How to reach the printer. Flattened into every command that connects.
#[derive(Debug, Clone, clap::Args)]
pub struct ConnArgs {
    /// Connection type (default: config or ble)
    #[arg(short = 'c', long, value_enum)]
    pub conn: Option<ConnPref>,
    /// BLE advertising name or peripheral id (exact match), or serial path
    /// (default: config / THERMARK_ADDR). Use the full name from `thermark scan`.
    #[arg(short, long)]
    pub addr: Option<String>,
    /// BLE scan time before connect (seconds; default: config or 4)
    #[arg(long, value_parser = parse_scan_seconds)]
    pub scan_secs: Option<u64>,
    /// Allow substring BLE name matching (default: exact name or id only)
    #[arg(long, default_value_t = false)]
    pub fuzzy: bool,
}

/// Which on-wire print sequence to use. Flattened into every printing command.
#[derive(Debug, Clone, clap::Args)]
pub struct TaskArgs {
    /// Print task override: b1, d11v1, d110, or d110mv4
    #[arg(long, value_enum)]
    pub task: Option<PrintTask>,
    /// Allow a profile/task/connection path not hardware-tested by thermark
    #[arg(long, default_value_t = false)]
    pub allow_experimental: bool,
}

/// Text rendering options. Flattened into the label-composing commands.
#[derive(Debug, Clone, clap::Args)]
pub struct FontArgs {
    /// Path to a .ttf / .ttc font file
    #[arg(long)]
    pub font: Option<PathBuf>,
    /// Named system font: helvetica, times, arial, courier, …
    #[arg(long)]
    pub font_name: Option<String>,
    /// Text size in px (e.g. 11 = small). Default: auto-fit largest.
    #[arg(long)]
    pub font_size: Option<f32>,
}

/// Connection settings after applying config / env defaults.
#[cfg_attr(not(any(feature = "ble", feature = "serial")), allow(dead_code))]
pub struct ResolvedConn {
    pub conn: ConnPref,
    pub addr: String,
    #[cfg(feature = "ble")]
    pub scan_secs: u64,
    #[cfg(feature = "ble")]
    pub match_mode: BleMatchMode,
}

impl ConnArgs {
    pub fn resolve(&self, cfg: &Config) -> Result<ResolvedConn> {
        Ok(ResolvedConn {
            conn: cfg.resolve_connection(self.conn),
            addr: cfg.resolve_addr(self.addr.as_deref())?,
            #[cfg(feature = "ble")]
            scan_secs: cfg.resolve_scan_secs(self.scan_secs),
            #[cfg(feature = "ble")]
            match_mode: BleMatchMode::from_fuzzy(self.fuzzy),
        })
    }
}

// ─── Value parsers ──────────────────────────────────────────────────────────

pub fn parse_density(s: &str) -> std::result::Result<Density, String> {
    s.parse::<Density>().map_err(|e| e.to_string())
}

pub fn parse_rotation(s: &str) -> std::result::Result<Rotation, String> {
    s.parse::<Rotation>().map_err(|e| e.to_string())
}

pub fn parse_threshold(s: &str) -> std::result::Result<Threshold, String> {
    s.parse::<Threshold>().map_err(|e| e.to_string())
}

pub fn parse_scan_seconds(s: &str) -> std::result::Result<u64, String> {
    let seconds = s
        .parse::<u64>()
        .map_err(|_| "scan time must be an integer number of seconds".to_string())?;
    if !(1..=MAX_SCAN_SECS).contains(&seconds) {
        return Err(format!(
            "scan time must be between 1 and {MAX_SCAN_SECS} seconds"
        ));
    }
    Ok(seconds)
}

// ─── Commands ───────────────────────────────────────────────────────────────

#[derive(Debug, clap::Args)]
pub struct PrintCommand {
    #[command(flatten)]
    pub conn: ConnArgs,
    #[command(flatten)]
    pub task: TaskArgs,
    /// Image path
    #[arg(short, long)]
    pub image: PathBuf,
    /// Printer model (default: config or b1)
    #[arg(short, long, value_enum)]
    pub model: Option<Model>,
    /// Print density 1..=5 (default 3 = normal; use 4 for denser/darker)
    #[arg(short, long, default_value = "3", value_parser = parse_density)]
    pub density: Density,
    /// Rotate clockwise: 0, 90, 180, 270
    #[arg(short, long, default_value = "0", value_parser = parse_rotation)]
    pub rotate: Rotation,
    /// Black/white threshold after invert (0–255)
    #[arg(long, default_value = "127", value_parser = parse_threshold)]
    pub threshold: Threshold,
    /// Scale image down to fit printhead width only
    #[arg(long, default_value_t = false)]
    pub fit: bool,
    /// Physical label size in mm, e.g. 50x30 (width x height). Scales content to this canvas.
    #[arg(long)]
    pub label: Option<String>,
    /// Cover the label (may crop). Default on. Use --no-fill to fit the whole image centered.
    #[arg(long, default_value_t = true)]
    pub fill: bool,
    /// Fit the whole image on the label with white margins (no crop). Best for photos.
    #[arg(long, default_value_t = false)]
    pub no_fill: bool,
    /// White margin inset in pixels (each side). Avoids edge bleed; good with photos.
    #[arg(long, default_value_t = 0)]
    pub margin: u32,
    /// Floyd–Steinberg dither instead of hard B/W (recommended for photographs)
    #[arg(long, default_value_t = false)]
    pub dither: bool,
    /// Keep the image's own white border instead of cropping it.
    /// Hard-threshold printing trims every edge; dithering trims only top/bottom.
    #[arg(long, default_value_t = false)]
    pub no_trim: bool,
    /// Ignore the configured registration inset and use the whole canvas.
    /// A charged B1 can address the full canvas; edge registration may vary.
    #[arg(long, default_value_t = false)]
    pub full_bleed: bool,
    /// Write the final monochrome print pixels to this PNG and do not print.
    /// Lets you check placement without a printer or a wasted label.
    #[arg(long)]
    pub preview: Option<PathBuf>,
}

#[derive(Debug, clap::Args)]
pub struct CalibrateCommand {
    #[command(flatten)]
    pub conn: ConnArgs,
    #[command(flatten)]
    pub task: TaskArgs,
    /// Printer model (default: config or b1)
    #[arg(short, long, value_enum)]
    pub model: Option<Model>,
    /// Label size mm, e.g. 50x30 (default: config, else 50x30)
    #[arg(long)]
    pub label: Option<String>,
    /// Density 1..=5 (default 4 = darker for full-bleed calibration)
    #[arg(short, long, default_value = "4", value_parser = parse_density)]
    pub density: Density,
    /// Print the boundary probe instead: one numbered bar per millimetre,
    /// so the last one you can see is exactly where the printer stops.
    #[arg(long, default_value_t = false)]
    pub boundary: bool,
}

#[derive(Debug, clap::Args)]
pub struct TextCommand {
    #[command(flatten)]
    pub conn: ConnArgs,
    #[command(flatten)]
    pub task: TaskArgs,
    #[command(flatten)]
    pub font: FontArgs,
    /// Printer model (default: config or b1)
    #[arg(short, long, value_enum)]
    pub model: Option<Model>,
    /// Text to print (use \\n for new lines)
    #[arg(long)]
    pub text: String,
    /// Horizontal alignment: left, center (default), right
    #[arg(long, value_enum, default_value_t = TextAlign::Center)]
    pub align: TextAlign,
    /// Label size mm, e.g. 50x30 (default: config, else 50x30)
    #[arg(long)]
    pub label: Option<String>,
    /// Draw a 1px outer border
    #[arg(long, default_value_t = false)]
    pub border: bool,
    /// Density 1..=5 (default 4 = darker for crisp text)
    #[arg(short, long, default_value = "4", value_parser = parse_density)]
    pub density: Density,
    /// Also save PNG to this path
    #[arg(long)]
    pub save: Option<PathBuf>,
    /// Render without printing; combine with --save to keep the PNG
    #[arg(long, default_value_t = false)]
    pub no_print: bool,
}

#[derive(Debug, clap::Args)]
pub struct QrCommand {
    #[command(flatten)]
    pub conn: ConnArgs,
    #[command(flatten)]
    pub task: TaskArgs,
    #[command(flatten)]
    pub font: FontArgs,
    /// Printer model (default: config or b1)
    #[arg(short, long, value_enum)]
    pub model: Option<Model>,
    /// URL or text encoded in the QR
    #[arg(long, default_value = "https://example.com")]
    pub url: String,
    /// Text drawn beside the QR (use \\n for new lines)
    #[arg(long, default_value = "ABC\nHELLO")]
    pub text: String,
    /// Put text on left or right of the square QR
    #[arg(long, value_enum, default_value_t = TextSide::Right)]
    pub text_side: TextSide,
    /// Label size mm, e.g. 50x30 (default: config, else 50x30)
    #[arg(long)]
    pub label: Option<String>,
    /// Draw a 1px outer border (usually unnecessary)
    #[arg(long, default_value_t = false)]
    pub border: bool,
    /// Density 1..=5 (default 4 = darker for small QR/text)
    #[arg(short, long, default_value = "4", value_parser = parse_density)]
    pub density: Density,
    /// Also save PNG to this path
    #[arg(long)]
    pub save: Option<PathBuf>,
    /// Render without printing; combine with --save to keep the PNG
    #[arg(long, default_value_t = false)]
    pub no_print: bool,
}

#[derive(Debug, clap::Args)]
pub struct WifiCommand {
    #[command(flatten)]
    pub conn: ConnArgs,
    #[command(flatten)]
    pub task: TaskArgs,
    #[command(flatten)]
    pub font: FontArgs,
    /// Printer model (default: config or b1)
    #[arg(short, long, value_enum)]
    pub model: Option<Model>,
    /// Network name (SSID) — shown on the sticker
    #[arg(long)]
    pub ssid: String,
    /// WPA/WEP password (or set THERMARK_WIFI_PASSWORD — preferred)
    #[arg(long, default_value = "")]
    pub password: String,
    /// Security: wpa (default), wep, nopass
    #[arg(long, value_enum, default_value_t = WifiSecurity::Wpa)]
    pub security: WifiSecurity,
    /// Hidden SSID
    #[arg(long, default_value_t = false)]
    pub hidden: bool,
    /// Also print a WPA/WEP password in cleartext under the SSID (less secure)
    #[arg(long, default_value_t = false)]
    pub show_password: bool,
    /// Put text on the left or right of the square QR
    #[arg(long, value_enum, default_value_t = TextSide::Right)]
    pub text_side: TextSide,
    /// Label size in mm, e.g. 50x30 (default: config, else 50x30)
    #[arg(long)]
    pub label: Option<String>,
    /// Draw a 1px outer border (usually unnecessary)
    #[arg(long, default_value_t = false)]
    pub border: bool,
    /// Density 1..=5 (default 4 = darker for small QR/text)
    #[arg(short, long, default_value = "4", value_parser = parse_density)]
    pub density: Density,
    /// Save PNG (use gitignored local/prints/ for real credentials)
    #[arg(long)]
    pub save: Option<PathBuf>,
    /// Render without printing; combine with --save to keep the PNG
    #[arg(long, default_value_t = false)]
    pub no_print: bool,
}

#[derive(Debug, clap::Args)]
pub struct DoctorCommand {
    /// BLE name / id, or serial path (default: saved config / THERMARK_ADDR; omit for host-only)
    #[arg(short, long)]
    pub addr: Option<String>,
    /// Connection type when connecting
    #[arg(short = 'c', long, value_enum)]
    pub conn: Option<ConnPref>,
    /// Printer model (default: config or b1)
    #[arg(short, long, value_enum)]
    pub model: Option<Model>,
    /// Print task to report on (default: model's default)
    #[arg(long, value_enum)]
    pub task: Option<PrintTask>,
    /// BLE scan seconds
    #[arg(short, long, default_value_t = 5, value_parser = parse_scan_seconds)]
    pub seconds: u64,
    /// Use saved default printer even without -a (connect + sensors)
    #[arg(long, default_value_t = false)]
    pub use_config: bool,
    /// Allow substring BLE name matching when connecting (default: exact only)
    #[arg(long, default_value_t = false)]
    pub fuzzy: bool,
    /// Emit a privacy-safe JSON support report (identifiers and local paths omitted)
    #[arg(long, default_value_t = false)]
    pub json: bool,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    /// Scan for thermal label printers over Bluetooth LE
    Scan {
        /// How long to scan (seconds)
        #[arg(short, long, default_value_t = 5, value_parser = parse_scan_seconds)]
        seconds: u64,
        /// Save the best match into config.json as the default printer
        #[arg(long, default_value_t = false)]
        save: bool,
        /// When saving, prefer a device whose name contains this (case-insensitive)
        #[arg(long)]
        name: Option<String>,
    },
    /// List USB serial ports
    Ports,
    /// Query printer info (serial, battery, versions)
    Info {
        #[command(flatten)]
        conn: ConnArgs,
    },
    /// Identify the connected printer and resolve its capability profile
    Identify {
        #[command(flatten)]
        conn: ConnArgs,
        /// Emit stable machine-readable output for bug reports and captures
        #[arg(long, default_value_t = false)]
        json: bool,
    },
    /// Print an image (PNG/JPEG/…)
    Print(PrintCommand),
    /// Print a full-bleed calibration pattern for a label size (find true print area)
    Calibrate(CalibrateCommand),
    /// Print a text-only sticker (no QR) — auto-fitted to fill the label
    Text(TextCommand),
    /// Design + print a square QR with side text (fills the label)
    Qr(QrCommand),
    /// Guest Wi‑Fi sticker: scan-to-join QR + clear network name
    ///
    /// QR uses the standard WIFI: payload (phones join on scan). Side text shows
    /// the SSID large; password stays in the QR unless --show-password.
    /// Do not commit real credentials — print directly or save under the
    /// gitignored `local/prints/` directory.
    Wifi(WifiCommand),
    /// List system fonts this tool can use
    Fonts,
    /// Show print-task / hardware support matrix
    Tasks,
    /// Show / set saved default printer (config.json)
    Config {
        #[command(subcommand)]
        action: ConfigCmd,
    },
    /// Diagnose host + printer readiness (Bluetooth, scan, sensors)
    Doctor(DoctorCommand),
    /// Encode a packet to hex (debug)
    Encode {
        /// Command byte (hex, e.g. 1a)
        cmd: String,
        /// Data bytes as hex (e.g. 01)
        #[arg(default_value = "")]
        data: String,
    },
}

#[derive(Subcommand, Debug)]
pub enum ConfigCmd {
    /// Print path + current saved values
    Show {
        /// Emit raw JSON only (no labels)
        #[arg(long, default_value_t = false)]
        json: bool,
    },
    /// Print config file path only
    Path,
    /// Update saved defaults (only provided fields change)
    Set {
        /// BLE name / UUID or serial path
        #[arg(short, long)]
        addr: Option<String>,
        /// Connection type
        #[arg(short = 'c', long, value_enum)]
        conn: Option<ConnPref>,
        /// Default model
        #[arg(short, long, value_enum)]
        model: Option<Model>,
        /// Default BLE scan seconds before connect
        #[arg(long, value_parser = parse_scan_seconds)]
        scan_secs: Option<u64>,
        /// Default label size, e.g. 50x30 — saves repeating --label
        #[arg(short, long)]
        label: Option<String>,
    },
    /// Save content/registration insets measured with `thermark calibrate`
    ///
    /// Values are millimetres from each edge. Omitted edges keep their current
    /// value. These are placement margins, not assumed hardware limits.
    SafeArea {
        /// Millimetre mark of the LAST ruler tick that printed, from
        /// `thermark calibrate`. Sets the bottom inset for you.
        #[arg(long, conflicts_with = "bottom")]
        last_tick: Option<f64>,
        /// Label size the reading came from (needed with --last-tick)
        #[arg(long)]
        label: Option<String>,
        #[arg(long)]
        top: Option<f64>,
        #[arg(long)]
        bottom: Option<f64>,
        #[arg(long)]
        left: Option<f64>,
        #[arg(long)]
        right: Option<f64>,
        /// Forget the saved value and use the built-in default
        #[arg(long, default_value_t = false)]
        reset: bool,
    },
    /// Remove the config file
    Clear,
}