Skip to main content

hardless_core/
device.rs

1//! The device plugin interface (FR-01).
2
3use std::fmt;
4
5use bytes::Bytes;
6use serde::{Deserialize, Serialize};
7
8use crate::fault::{FaultId, FaultOptions};
9use crate::ids::{DeviceId, JobId};
10use crate::nfc::{NdefRecord, NfcEvent};
11use crate::time::Millis;
12
13/// A device type that can be instantiated from JSON config, e.g. `printer-escpos`.
14///
15/// Register plugins with [`Registry::register`](crate::Registry::register).
16pub trait DevicePlugin: Send + Sync + 'static {
17    /// The device kind this plugin creates, e.g. `"printer-escpos"`.
18    fn kind(&self) -> &'static str;
19
20    /// JSON Schema for the `config` accepted by [`DevicePlugin::create`].
21    fn config_schema(&self) -> schemars::Schema;
22
23    /// Every fault a device of this kind can support, e.g. for listing faults by kind. A
24    /// particular instance may support fewer, see [`Device::faults`].
25    fn faults(&self) -> Vec<FaultId>;
26
27    /// Creates a device instance. Invalid config is a [`ConfigError`], never a panic.
28    fn create(
29        &self,
30        id: DeviceId,
31        config: serde_json::Value,
32    ) -> Result<Box<dyn Device>, ConfigError>;
33}
34
35/// A device instance.
36///
37/// Sans-IO: a device is a pure state machine. Transports own all I/O and time, and pass the
38/// current time in as `now`. A device asks to be woken by returning a deadline from
39/// [`Device::next_deadline`]; the caller then calls [`Device::on_tick`] at or after it.
40pub trait Device: Send {
41    /// The device kind, matching its plugin's [`DevicePlugin::kind`].
42    fn kind(&self) -> &'static str;
43
44    /// A JSON snapshot of the device's state, shown in the dashboard and APIs.
45    fn state(&self) -> serde_json::Value;
46
47    /// Host -> device bytes. A command may be split across calls.
48    fn on_host_bytes(&mut self, data: &[u8], now: Millis) -> Vec<DeviceEvent>;
49
50    /// A control action from the dashboard, API, CLI or a scenario: scan, tap, close drawer.
51    fn on_control(&mut self, cmd: ControlCommand, now: Millis) -> Vec<DeviceEvent>;
52
53    /// Time has reached (or passed) [`Device::next_deadline`]: pulses, idle job close.
54    fn on_tick(&mut self, now: Millis) -> Vec<DeviceEvent>;
55
56    /// When the device next needs [`Device::on_tick`], if ever. A deadline at or before the
57    /// current time means "as soon as possible".
58    fn next_deadline(&self) -> Option<Millis>;
59
60    /// The faults that apply to this instance, which may depend on its config (a serial
61    /// printer has `baud-mismatch`, a USB one has `stall`). The registry rejects any other
62    /// fault with [`RegistryError::UnsupportedFault`](crate::RegistryError::UnsupportedFault).
63    fn faults(&self) -> Vec<FaultId>;
64
65    /// Turns a fault on or off at `now`, returning what that caused, e.g.
66    /// [`DeviceEvent::Disconnected`] for `offline`. Turning on a fault that is already on
67    /// replaces its options.
68    fn set_fault(
69        &mut self,
70        fault: FaultId,
71        active: bool,
72        opts: FaultOptions,
73        now: Millis,
74    ) -> Vec<DeviceEvent>;
75
76    /// Back to the freshly created state at `now`: clears buffers, jobs and faults. Returns
77    /// what that caused, e.g. [`DeviceEvent::Connected`] if the device was offline.
78    fn reset(&mut self, now: Millis) -> Vec<DeviceEvent>;
79
80    /// The device this one is attached to as a peripheral, e.g. a cash drawer's printer.
81    /// [`DeviceEvent::Peripheral`] signals travel between attached devices.
82    fn attached_to(&self) -> Option<DeviceId> {
83        None
84    }
85
86    /// A [`PeripheralSignal`] from an attached device: from a peripheral to the device it is
87    /// attached to (e.g. the drawer's open sensor), or from that device to its peripherals
88    /// (e.g. a drawer-kick pulse).
89    fn on_peripheral(
90        &mut self,
91        from: &DeviceId,
92        signal: PeripheralSignal,
93        now: Millis,
94    ) -> Vec<DeviceEvent> {
95        let _ = (from, signal, now);
96        Vec::new()
97    }
98}
99
100/// A signal on the wires between a device and its peripherals, modelled on the drawer-kick
101/// connector of receipt printers.
102///
103/// ```
104/// use hardless_core::PeripheralSignal;
105/// let kick = PeripheralSignal::Pulse { pin: 2, on_ms: 50, off_ms: 500 };
106/// assert_eq!(
107///     serde_json::to_value(&kick).unwrap(),
108///     serde_json::json!({ "type": "pulse", "pin": 2, "onMs": 50, "offMs": 500 })
109/// );
110/// ```
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
113#[serde(tag = "type", rename_all = "kebab-case")]
114pub enum PeripheralSignal {
115    /// A drawer-kick pulse: `pin` (2 or 5) is driven for `on_ms`, then released for `off_ms`.
116    #[serde(rename_all = "camelCase")]
117    Pulse {
118        /// Connector pin, 2 or 5.
119        pin: u8,
120        /// Pulse on time in milliseconds.
121        on_ms: u64,
122        /// Pulse off time in milliseconds.
123        off_ms: u64,
124    },
125    /// The drawer-open sensor (connector pin 3): `true` while the drawer is open.
126    Sensor {
127        /// Whether the drawer is open.
128        open: bool,
129    },
130}
131
132/// Something a device did, returned from every [`Device`] call.
133#[derive(Clone, Debug, PartialEq)]
134pub enum DeviceEvent {
135    /// Device -> host bytes (status replies).
136    ToHost(Bytes),
137    /// [`Device::state`] changed.
138    StateChanged,
139    /// A print job with its AST.
140    JobCompleted(Job),
141    /// The device became reachable by the host.
142    Connected,
143    /// The device stopped being reachable by the host.
144    Disconnected,
145    /// A diagnostic message, e.g. an unknown command.
146    Log(LogEntry),
147    /// A signal to attached devices; the registry delivers it (see [`Device::attached_to`]).
148    Peripheral(PeripheralSignal),
149    /// Keystrokes a keyboard-wedge scanner types into the page (PRD section 8.4). The
150    /// transport hands them to the shim, which presses the keys like a keyboard driver.
151    Wedge(WedgeScan),
152    /// An NFC reader read a card, failed to read one, or answered a write from the app (PRD
153    /// section 8.3). The transport hands reads to the shim as `reading` / `readingerror`.
154    Nfc(NfcEvent),
155}
156
157/// What a keyboard-wedge scanner types for one scan: the characters, how fast, and the key
158/// that ends it. On the bridge this is the `wedge-scan` message.
159///
160/// ```
161/// use hardless_core::{Terminator, WedgeScan};
162/// let scan = WedgeScan { code: "4006381333931".into(), delay_ms: 8, terminator: Terminator::Enter };
163/// assert_eq!(
164///     serde_json::to_value(&scan).unwrap(),
165///     serde_json::json!({ "code": "4006381333931", "delayMs": 8, "terminator": "enter" })
166/// );
167/// assert_eq!(scan.keys(), 14, "13 characters and Enter");
168/// ```
169#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
171#[serde(rename_all = "camelCase")]
172pub struct WedgeScan {
173    /// The characters to type, including any prefix and suffix.
174    pub code: String,
175    /// Milliseconds between key presses.
176    pub delay_ms: u64,
177    /// The key pressed after the characters.
178    pub terminator: Terminator,
179}
180
181impl WedgeScan {
182    /// How many keys the scan presses: one per character, plus the terminator.
183    pub fn keys(&self) -> u64 {
184        let terminator = u64::from(self.terminator != Terminator::None);
185        self.code.chars().count() as u64 + terminator
186    }
187
188    /// How long typing takes, from the first key press to the last.
189    pub fn typing_ms(&self) -> u64 {
190        self.keys().saturating_sub(1).saturating_mul(self.delay_ms)
191    }
192}
193
194/// A completed print job.
195///
196/// The `ast` is device-specific JSON (the ESC/POS AST for receipt printers), so the core does
197/// not depend on any command set. On the wire this is the REST job shape
198/// `{ id, at, ast, text, bytesHex }`.
199#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
200#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
201#[serde(rename_all = "camelCase")]
202pub struct Job {
203    /// Job ID, unique per device.
204    pub id: JobId,
205    /// When the job closed.
206    pub at: Millis,
207    /// The parsed command AST.
208    pub ast: serde_json::Value,
209    /// Plain-text rendering, for assertions.
210    pub text: String,
211    /// The raw bytes of the job.
212    #[serde(rename = "bytesHex", with = "crate::hex::serde_bytes")]
213    #[cfg_attr(feature = "ts", ts(rename = "bytesHex", type = "string"))]
214    pub bytes: Bytes,
215}
216
217/// Severity of a [`LogEntry`].
218#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
220#[serde(rename_all = "lowercase")]
221pub enum LogLevel {
222    /// Detailed tracing.
223    Debug,
224    /// Normal operation.
225    Info,
226    /// Something the app under test probably did wrong, e.g. an unknown command.
227    Warn,
228    /// The device could not do what was asked.
229    Error,
230}
231
232/// A diagnostic message from a device.
233///
234/// ```
235/// use hardless_core::{LogEntry, LogLevel};
236/// let entry = LogEntry::new(LogLevel::Warn, "unknown command 1b 7f");
237/// assert_eq!(entry.message, "unknown command 1b 7f");
238/// ```
239#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
240#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
241pub struct LogEntry {
242    /// Severity.
243    pub level: LogLevel,
244    /// Human-readable message.
245    pub message: String,
246}
247
248impl LogEntry {
249    /// A log entry.
250    pub fn new(level: LogLevel, message: impl Into<String>) -> Self {
251        Self {
252            level,
253            message: message.into(),
254        }
255    }
256}
257
258/// What a keyboard-wedge scanner sends after the code (PRD section 8.4).
259#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
260#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
261#[serde(rename_all = "lowercase")]
262pub enum Terminator {
263    /// `Enter` (the default).
264    #[default]
265    Enter,
266    /// `Tab`.
267    Tab,
268    /// No terminator.
269    None,
270}
271
272/// A control action sent to a device: by a person, test or agent (scan, tap, close drawer),
273/// or by the app through a browser API that does not carry bytes (an NFC write).
274///
275/// ```
276/// use hardless_core::ControlCommand;
277/// let cmd: ControlCommand =
278///     serde_json::from_str(r#"{ "type": "scan", "code": "4006381333931" }"#).unwrap();
279/// assert!(matches!(cmd, ControlCommand::Scan { .. }));
280/// ```
281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
282#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
283#[serde(tag = "type", rename_all = "kebab-case")]
284pub enum ControlCommand {
285    /// Scan a barcode (`POST /devices/:id/scan`).
286    #[serde(rename_all = "camelCase")]
287    Scan {
288        /// The barcode content.
289        code: String,
290        /// Overrides the device's terminator.
291        #[serde(default, skip_serializing_if = "Option::is_none")]
292        terminator: Option<Terminator>,
293        /// Overrides the device's inter-key delay.
294        #[serde(default, skip_serializing_if = "Option::is_none")]
295        delay_ms: Option<u64>,
296    },
297    /// Tap an NFC card (`POST /devices/:id/tap`).
298    Tap {
299        /// Card UID as hex, e.g. `04a2b3c4d5e6f7`.
300        uid: String,
301        /// The card's NDEF records. Without them the reader uses what it remembers of this
302        /// card (including anything the app wrote to it), or an empty card.
303        #[serde(default, skip_serializing_if = "Option::is_none")]
304        #[cfg_attr(feature = "ts", ts(optional))]
305        records: Option<Vec<NdefRecord>>,
306    },
307    /// The app writes records to the card on the reader (`NDEFReader.write()`).
308    NfcWrite {
309        /// The records to write.
310        records: Vec<NdefRecord>,
311        /// Whether existing data may be replaced (Web NFC's `overwrite`, default `true`).
312        #[serde(default = "yes")]
313        overwrite: bool,
314    },
315    /// The app makes the card on the reader read-only (`NDEFReader.makeReadOnly()`).
316    NfcMakeReadOnly,
317    /// Close an open cash drawer by hand.
318    CloseDrawer,
319    /// A device-specific command for plugins.
320    Custom {
321        /// Command name.
322        name: String,
323        /// Command arguments.
324        #[serde(default)]
325        payload: serde_json::Value,
326    },
327}
328
329fn yes() -> bool {
330    true
331}
332
333/// A device config was rejected by [`DevicePlugin::create`].
334///
335/// ```
336/// use hardless_core::ConfigError;
337/// let err = ConfigError::new("pin must be 2 or 5").at("/pin");
338/// assert_eq!(err.to_string(), "/pin: pin must be 2 or 5");
339/// ```
340#[derive(Clone, Debug, PartialEq, Eq)]
341pub struct ConfigError {
342    message: String,
343    pointer: Option<String>,
344}
345
346impl ConfigError {
347    /// A config error with a message.
348    pub fn new(message: impl Into<String>) -> Self {
349        Self {
350            message: message.into(),
351            pointer: None,
352        }
353    }
354
355    /// Points the error at a JSON Pointer in the config, e.g. `/pin`.
356    pub fn at(mut self, pointer: impl Into<String>) -> Self {
357        self.pointer = Some(pointer.into());
358        self
359    }
360
361    /// The message.
362    pub fn message(&self) -> &str {
363        &self.message
364    }
365
366    /// The JSON Pointer to the bad value, if known.
367    pub fn pointer(&self) -> Option<&str> {
368        self.pointer.as_deref()
369    }
370}
371
372impl fmt::Display for ConfigError {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        match &self.pointer {
375            Some(p) => write!(f, "{p}: {}", self.message),
376            None => f.write_str(&self.message),
377        }
378    }
379}
380
381impl std::error::Error for ConfigError {}