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 {}