Skip to main content

hardless_core/
fault.rs

1//! Faults: [`FaultId`], [`FaultOptions`] and the catalogue from PRD section 10.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use serde::{Deserialize, Serialize};
8
9use crate::ids::InvalidId;
10
11/// Options for a fault, as a JSON object (e.g. `{ "afterBytes": 64 }`).
12pub type FaultOptions = serde_json::Map<String, serde_json::Value>;
13
14/// A fault identifier in kebab-case, e.g. `paper-out`.
15///
16/// The IDs from PRD section 10 are associated constants. Plugins may define their own IDs;
17/// any lowercase kebab-case string is accepted.
18///
19/// ```
20/// use hardless_core::FaultId;
21/// let id: FaultId = "paper-out".parse().unwrap();
22/// assert_eq!(id, FaultId::PAPER_OUT);
23/// assert!("Paper_Out".parse::<FaultId>().is_err());
24/// ```
25#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
27#[serde(try_from = "String", into = "String")]
28pub struct FaultId(#[cfg_attr(feature = "ts", ts(as = "String"))] Cow<'static, str>);
29
30macro_rules! fault_ids {
31    ($($(#[$doc:meta])* $name:ident = $id:literal;)*) => {
32        impl FaultId {
33            $($(#[$doc])* pub const $name: FaultId = FaultId(Cow::Borrowed($id));)*
34        }
35    };
36}
37
38fault_ids! {
39    /// Printers: prints rejected; status reports paper end.
40    PAPER_OUT = "paper-out";
41    /// Printers: prints succeed; status reports near end.
42    PAPER_NEAR_END = "paper-near-end";
43    /// Printers: prints rejected; status reports cover open.
44    COVER_OPEN = "cover-open";
45    /// Printers: serial/USB disconnect; HTTP refuses connections.
46    OFFLINE = "offline";
47    /// Serial and USB printers: disconnect after `afterBytes` bytes of the next job.
48    DISCONNECT_MID_WRITE = "disconnect-mid-write";
49    /// Serial and USB printers: delay each chunk acknowledgement by `msPerChunk`.
50    SLOW_WRITE = "slow-write";
51    /// Serial printers: received bytes are scrambled.
52    BAUD_MISMATCH = "baud-mismatch";
53    /// All printers: random byte corruption at `rate`.
54    GARBLED = "garbled";
55    /// HTTP printers: response delayed past the request's `timeout`.
56    TIMEOUT = "timeout";
57    /// HTTP printers: CORS headers omitted.
58    CORS_BLOCKED = "cors-blocked";
59    /// USB printers: `transferOut` returns `status: "stall"`.
60    STALL = "stall";
61    /// USB printers: `claimInterface` rejects.
62    INTERFACE_BUSY = "interface-busy";
63    /// Serial, USB and NFC: the next `request*` call behaves as a user cancel.
64    PICKER_CANCEL = "picker-cancel";
65    /// NFC: `scan()` rejects with `NotAllowedError`.
66    PERMISSION_DENIED = "permission-denied";
67    /// NFC: the next tap fires `readingerror`.
68    READ_ERROR = "read-error";
69    /// Drawer: kick received, drawer stays closed.
70    JAMMED = "jammed";
71    /// Drawer: kick ignored; status shows no drawer.
72    NOT_CONNECTED = "not-connected";
73    /// Scanner: each scan is sent twice, `gapMs` apart.
74    DOUBLE_SCAN = "double-scan";
75    /// Scanner: terminator dropped.
76    PARTIAL_SCAN = "partial-scan";
77}
78
79impl FaultId {
80    /// The ID as a string slice.
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84
85    /// The catalogue entry for this ID, if it is one of the built-in faults.
86    ///
87    /// ```
88    /// use hardless_core::FaultId;
89    /// assert_eq!(FaultId::lookup("garbled").unwrap().options[0].name, "rate");
90    /// assert!(FaultId::lookup("made-up").is_none());
91    /// ```
92    pub fn lookup(id: &str) -> Option<&'static FaultSpec> {
93        CATALOGUE.iter().find(|spec| spec.id.as_str() == id)
94    }
95}
96
97fn is_kebab_case(s: &str) -> bool {
98    !s.is_empty()
99        && s.len() <= 64
100        && !s.starts_with('-')
101        && !s.ends_with('-')
102        && !s.contains("--")
103        && s.bytes()
104            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
105}
106
107impl FromStr for FaultId {
108    type Err = InvalidId;
109
110    fn from_str(s: &str) -> Result<Self, Self::Err> {
111        if let Some(spec) = FaultId::lookup(s) {
112            return Ok(spec.id.clone());
113        }
114        if is_kebab_case(s) {
115            Ok(FaultId(Cow::Owned(s.to_owned())))
116        } else {
117            Err(InvalidId {
118                what: "fault id",
119                value: s.to_owned(),
120            })
121        }
122    }
123}
124
125impl TryFrom<String> for FaultId {
126    type Error = InvalidId;
127
128    fn try_from(s: String) -> Result<Self, Self::Error> {
129        s.parse()
130    }
131}
132
133impl From<FaultId> for String {
134    fn from(id: FaultId) -> String {
135        id.0.into_owned()
136    }
137}
138
139impl fmt::Display for FaultId {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        f.write_str(&self.0)
142    }
143}
144
145impl schemars::JsonSchema for FaultId {
146    fn schema_name() -> Cow<'static, str> {
147        "FaultId".into()
148    }
149
150    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
151        schemars::json_schema!({
152            "type": "string",
153            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
154            "maxLength": 64
155        })
156    }
157}
158
159/// The default value of a fault option.
160#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
161#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
162#[serde(untagged)]
163pub enum OptionDefault {
164    /// A whole number, e.g. a byte count or milliseconds.
165    Integer(u64),
166    /// A real number, e.g. a rate between 0 and 1.
167    Number(f64),
168}
169
170/// One option a fault accepts.
171#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
172#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
173pub struct FaultOptionSpec {
174    /// Option name in the options object, e.g. `afterBytes`.
175    pub name: &'static str,
176    /// Value used when the option is not given.
177    pub default: OptionDefault,
178    /// What the option controls.
179    pub description: &'static str,
180}
181
182/// A catalogue entry: one row of PRD section 10.
183#[derive(Clone, Debug, PartialEq, Serialize)]
184#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
185#[serde(rename_all = "camelCase")]
186pub struct FaultSpec {
187    /// The fault ID.
188    pub id: FaultId,
189    /// The devices it applies to, as written in the catalogue (e.g. `serial, USB printers`).
190    pub applies_to: &'static str,
191    /// The observable effect.
192    pub effect: &'static str,
193    /// Accepted options, empty if none.
194    pub options: &'static [FaultOptionSpec],
195}
196
197const fn spec(
198    id: FaultId,
199    applies_to: &'static str,
200    effect: &'static str,
201    options: &'static [FaultOptionSpec],
202) -> FaultSpec {
203    FaultSpec {
204        id,
205        applies_to,
206        effect,
207        options,
208    }
209}
210
211const fn opt(
212    name: &'static str,
213    default: OptionDefault,
214    description: &'static str,
215) -> FaultOptionSpec {
216    FaultOptionSpec {
217        name,
218        default,
219        description,
220    }
221}
222
223static CATALOGUE: [FaultSpec; 19] = [
224    spec(
225        FaultId::PAPER_OUT,
226        "printers",
227        "Prints rejected; status reports paper end",
228        &[],
229    ),
230    spec(
231        FaultId::PAPER_NEAR_END,
232        "printers",
233        "Prints succeed; status reports near end",
234        &[],
235    ),
236    spec(
237        FaultId::COVER_OPEN,
238        "printers",
239        "Prints rejected; status reports cover open",
240        &[],
241    ),
242    spec(
243        FaultId::OFFLINE,
244        "printers",
245        "Serial/USB: device disconnects. HTTP: connection refused",
246        &[],
247    ),
248    spec(
249        FaultId::DISCONNECT_MID_WRITE,
250        "serial, USB printers",
251        "Disconnects after N bytes of the next job",
252        &[opt(
253            "afterBytes",
254            OptionDefault::Integer(64),
255            "Bytes of the next job before the disconnect",
256        )],
257    ),
258    spec(
259        FaultId::SLOW_WRITE,
260        "serial, USB printers",
261        "Delays each chunk ack",
262        &[opt(
263            "msPerChunk",
264            OptionDefault::Integer(200),
265            "Delay before each chunk is acknowledged",
266        )],
267    ),
268    spec(
269        FaultId::BAUD_MISMATCH,
270        "serial printers",
271        "Received bytes are scrambled",
272        &[],
273    ),
274    spec(
275        FaultId::GARBLED,
276        "all printers",
277        "Random byte corruption",
278        &[opt(
279            "rate",
280            OptionDefault::Number(0.05),
281            "Probability (0 to 1) that a byte is corrupted",
282        )],
283    ),
284    spec(
285        FaultId::TIMEOUT,
286        "HTTP printers",
287        "Response delayed past the request's timeout",
288        &[],
289    ),
290    spec(
291        FaultId::CORS_BLOCKED,
292        "HTTP printers",
293        "CORS headers omitted",
294        &[],
295    ),
296    spec(
297        FaultId::STALL,
298        "USB printers",
299        "transferOut returns status: \"stall\"",
300        &[],
301    ),
302    spec(
303        FaultId::INTERFACE_BUSY,
304        "USB printers",
305        "claimInterface rejects",
306        &[],
307    ),
308    spec(
309        FaultId::PICKER_CANCEL,
310        "serial, USB, NFC",
311        "Next request* call behaves as user cancel",
312        &[],
313    ),
314    spec(
315        FaultId::PERMISSION_DENIED,
316        "NFC",
317        "scan() rejects NotAllowedError",
318        &[],
319    ),
320    spec(
321        FaultId::READ_ERROR,
322        "NFC",
323        "Next tap fires readingerror",
324        &[],
325    ),
326    spec(
327        FaultId::JAMMED,
328        "drawer",
329        "Kick received, drawer stays closed",
330        &[],
331    ),
332    spec(
333        FaultId::NOT_CONNECTED,
334        "drawer",
335        "Kick ignored; status shows no drawer",
336        &[],
337    ),
338    spec(
339        FaultId::DOUBLE_SCAN,
340        "scanner",
341        "Each scan is sent twice",
342        &[opt(
343            "gapMs",
344            OptionDefault::Integer(30),
345            "Delay between the two scans",
346        )],
347    ),
348    spec(FaultId::PARTIAL_SCAN, "scanner", "Terminator dropped", &[]),
349];
350
351/// The built-in fault catalogue (PRD section 10), in table order.
352///
353/// ```
354/// let ids: Vec<_> = hardless_core::catalogue().iter().map(|f| f.id.as_str()).collect();
355/// assert_eq!(ids[0], "paper-out");
356/// ```
357pub fn catalogue() -> &'static [FaultSpec] {
358    &CATALOGUE
359}