# hardless-core
The sans-IO core of [Hardless](https://github.com/hardlessdev/hardless), an open-source
POS hardware emulator.
It defines what a device is and keeps track of the devices that exist. It does no I/O and
reads no clock: devices are pure state machines that receive bytes, control commands and a
`now` timestamp from the caller. Transports and the server own all I/O and time.
| Device plugin interface | FR-01 | `DevicePlugin`, `Device`, `DeviceEvent`, `ControlCommand`, `Job` |
| Device registry and event bus | FR-02 | `Registry`, `BusEvent`, `DeviceSummary`, `RegistryError` |
| Event log | FR-03 | `EventLog`, `EventRecord`, `EventKind` |
| Faults | section 10 | `FaultId`, `FaultOptions`, `FaultSpec`, `catalogue()` |
| Scenario runner | FR-04 | `Scenario`, `Step`, `ScenarioRun`, `ScenarioReport` |
| Deterministic clock | FR-05 | `Clock`, `FakeClock`, `Millis` |
## Usage
```rust
use hardless_core::{
ConfigError, ControlCommand, Device, DeviceEvent, DeviceId, DevicePlugin, FakeClock,
FaultId, FaultOptions, Millis, Registry,
};
/// A device that answers every byte it receives with `0x06` (ACK).
struct Echo;
impl Device for Echo {
fn kind(&self) -> &'static str { "echo" }
fn state(&self) -> serde_json::Value { serde_json::json!({}) }
fn on_host_bytes(&mut self, data: &[u8], _now: Millis) -> Vec<DeviceEvent> {
vec![DeviceEvent::ToHost(vec![0x06; data.len()].into())]
}
fn on_control(&mut self, _cmd: ControlCommand, _now: Millis) -> Vec<DeviceEvent> { vec![] }
fn on_tick(&mut self, _now: Millis) -> Vec<DeviceEvent> { vec![] }
fn next_deadline(&self) -> Option<Millis> { None }
fn faults(&self) -> Vec<FaultId> { vec![] }
fn set_fault(&mut self, _fault: FaultId, _active: bool, _opts: FaultOptions, _now: Millis)
-> Vec<DeviceEvent> { vec![] }
fn reset(&mut self, _now: Millis) -> Vec<DeviceEvent> { vec![] }
}
struct EchoPlugin;
impl DevicePlugin for EchoPlugin {
fn kind(&self) -> &'static str { "echo" }
fn config_schema(&self) -> schemars::Schema { schemars::json_schema!({ "type": "object" }) }
fn faults(&self) -> Vec<FaultId> { vec![] }
fn create(&self, _id: DeviceId, _config: serde_json::Value)
-> Result<Box<dyn Device>, ConfigError> {
Ok(Box::new(Echo))
}
}
let clock = FakeClock::new();
let mut registry = Registry::new(clock.clone());
registry.register(EchoPlugin).unwrap();
let id = registry.create("echo", Some("echo-1".parse().unwrap()), serde_json::json!({})).unwrap();
clock.advance(5);
let events = registry.host_bytes(&id, b"hi").unwrap();
assert!(matches!(&events[0], DeviceEvent::ToHost(b) if b.as_ref() == [0x06, 0x06]));
// Everything that happened is in the device's event log, stamped by the injected clock.
let log = registry.events(&id, None).unwrap();
assert_eq!(log.last().unwrap().at, Millis(5));
// Observers (dashboard, MCP) read the event bus.
let bus = registry.drain_bus();
assert_eq!(bus.len(), 1); // device:added
```
## Scenarios (FR-04)
A `Scenario` is a JSON list of steps: `scan`, `tap`, `wait` (`ms`), `setFault`, `clearFault`
and `expect`. An `expect` checks a device: `state` (a subset match), `receiptContains` and
`jobs`, optionally waiting up to `withinMs` for them to become true. A run stops at the first
failing step; the rest are reported as skipped.
`ScenarioRun::advance` runs every step it can at the registry's time and returns either
`Progress::WaitUntil(t)` or `Progress::Done(report)`, plus device events the caller should
deliver (wedge scans, bytes for the app). It never sleeps: the server waits in real time,
tests move a `FakeClock`.
## Design notes
- **Sans-IO.** The registry reads time only through the `Clock` it was given; the server
passes a system clock, tests pass `FakeClock`. No API in this crate blocks or sleeps.
- **Two outputs per call.** Methods that feed a device return its raw `DeviceEvent`s so the
caller (a transport) can route `ToHost` bytes and `Wedge` scans back to the app. The same events are recorded
in the event log, and the ones observers care about are put on the event bus.
- **Timestamps are monotonic.** If an injected clock goes backwards, the registry keeps the
last timestamp it handed out.
- **Faults are open.** `FaultId` is a string so plugins can define their own faults; the IDs in
PRD section 10 are provided as constants and in `catalogue()`.
## Features
- `ts`: derives TypeScript bindings with `ts-rs`. Used by `cargo xtask gen-types`, which
writes `packages/types`.
## License
Apache-2.0