use crate::errors::{Error, Result};
use crate::packet::{MAX_FRAME_LEN, Packet};
use std::fmt;
use std::time::Duration;
use tracing::debug;
pub trait Transport: Send {
fn send_raw(&mut self, data: &[u8]) -> impl std::future::Future<Output = Result<()>> + Send;
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 {
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
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BleMatchMode {
#[default]
Exact,
Fuzzy,
}
impl BleMatchMode {
pub fn from_fuzzy(fuzzy: bool) -> Self {
if fuzzy { Self::Fuzzy } else { Self::Exact }
}
}
#[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,
}
}
pub fn display_name(&self) -> &str {
self.name.as_deref().unwrap_or("(no name)")
}
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),
}
}
}
const PRINTER_NAME_MARKERS: &[&str] = &["b1", "b21", "b18", "d11", "d110", "niim", "jc-"];
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))
}
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)
}
#[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))
}
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;
}
if id_l.contains(&sel) && sel.len() >= 8 {
score += 50;
}
if candidate.looks_like_label_printer() {
score += 30;
}
if score < 100 { None } else { Some(score) }
}
}
}
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())
}
#[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)
);
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());
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")];
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}");
}
}
}
#[cfg(feature = "serial")]
mod serial;
#[cfg(feature = "serial")]
pub use serial::SerialTransport;