Skip to main content

lager/
wire.rs

1//! Sans-io wire layer: request builders and response parsers for the Lager
2//! box HTTP API on port 9000.
3//!
4//! Everything in this module is pure data-in/data-out. Both the blocking
5//! client ([`crate::LagerBox`]) and the async client run the exact same
6//! builders and parsers, so the two transports cannot drift apart.
7
8use std::time::Duration;
9
10use serde::de::DeserializeOwned;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Map, Value};
13
14use crate::error::{Error, Result};
15
16/// Default port of the box HTTP server (`box_http_server.py`).
17pub const DEFAULT_PORT: u16 = 9000;
18
19/// Port of the box debug service (`lager.debug.service`), published on the
20/// box host. Debug nets talk here rather than to the port-9000 server.
21pub const DEBUG_SERVICE_PORT: u16 = 8765;
22
23/// Default HTTP timeout for quick net commands, matching the Lager CLI's
24/// 10-second budget.
25pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
26
27/// HTTP method of a request.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Method {
30    /// HTTP GET.
31    Get,
32    /// HTTP POST with a JSON body.
33    Post,
34    /// HTTP PUT with a JSON body (`/nets/<name>/safety-limits`).
35    Put,
36}
37
38/// Client-side timeout policy for one request.
39///
40/// Mirrors the Lager CLI's budgets: quick commands get the 10s default,
41/// while actions that block on the box for a caller-controlled duration
42/// (watt/energy integration windows, `wait_for_level`) widen or drop the
43/// client timeout so a healthy request is never aborted mid-measurement.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Timeout {
46    /// Use the client's configured default (10s unless overridden).
47    Default,
48    /// Use this specific timeout.
49    After(Duration),
50    /// No client-side timeout at all (e.g. an unbounded `wait_for_level`).
51    Unbounded,
52}
53
54/// One fully-described HTTP request to the box, transport-agnostic.
55#[derive(Debug, Clone)]
56pub struct HttpRequest {
57    /// HTTP method.
58    pub method: Method,
59    /// Path relative to the box base URL, e.g. `"/net/command"`.
60    pub path: String,
61    /// JSON body for POST requests.
62    pub body: Option<Value>,
63    /// Client-side timeout policy.
64    pub timeout: Timeout,
65}
66
67/// A complete operation: the request to send plus the parser that turns the
68/// box's response envelope into a typed value. Net handles build these; the
69/// sync/async clients only execute them.
70pub struct Op<T> {
71    /// The request to send.
72    pub req: HttpRequest,
73    /// Parser applied to the successful response envelope.
74    pub parse: fn(CommandResponse) -> Result<T>,
75}
76
77// ---------------------------------------------------------------------------
78// Response envelope
79// ---------------------------------------------------------------------------
80
81/// The common response envelope every box command endpoint returns:
82/// `{"success": bool, "action": ..., "message": ..., "value": ..., "state": ...}`.
83#[derive(Debug, Clone, Deserialize)]
84pub struct CommandResponse {
85    /// Whether the box executed the command.
86    #[serde(default)]
87    pub success: bool,
88    /// Echo of the action that ran.
89    #[serde(default)]
90    pub action: Option<String>,
91    /// Human-readable result message (what the CLI prints).
92    #[serde(default)]
93    pub message: Option<String>,
94    /// Structured result value, shape depends on the action.
95    #[serde(default)]
96    pub value: Option<Value>,
97    /// Structured state object (supply/battery `state`, usb port state).
98    #[serde(default)]
99    pub state: Option<Value>,
100    /// Error message when `success` is false.
101    #[serde(default)]
102    pub error: Option<String>,
103    /// Any endpoint-specific extra fields (e.g. SPI's top-level `word_size`).
104    #[serde(flatten)]
105    pub extra: Map<String, Value>,
106}
107
108/// Interpret a raw `(status, body)` pair as the command envelope, applying
109/// the box's error conventions:
110///
111/// - 200 + `success: true` is the only success shape.
112/// - 501 means the box image predates the endpoint ([`Error::UnsupportedByBox`]).
113/// - Everything else (including `success: false` with HTTP 200, which the box
114///   uses for cross-role instrument conflicts) is [`Error::Box`].
115pub fn parse_command(status: u16, body: Value) -> Result<CommandResponse> {
116    let resp: CommandResponse = serde_json::from_value(body)
117        .map_err(|e| Error::Decode(format!("invalid response envelope: {e}")))?;
118    if status == 200 && resp.success {
119        return Ok(resp);
120    }
121    let message = resp
122        .error
123        .or(resp.message)
124        .unwrap_or_else(|| format!("HTTP {status}"));
125    if status == 501 {
126        return Err(Error::UnsupportedByBox { message });
127    }
128    Err(Error::Box { status, message })
129}
130
131// ---------------------------------------------------------------------------
132// Generic request builders
133// ---------------------------------------------------------------------------
134
135/// Build a `POST /net/command` request (gpio, adc, dac, thermocouple,
136/// watt-meter, eload, spi, i2c, energy-analyzer).
137///
138/// The `role` is sent as a hint; the box resolves the authoritative role from
139/// its `saved_nets.json` and verifies the hint, so a typo'd net name or a
140/// role mismatch fails loudly instead of driving the wrong instrument.
141///
142/// `role: None` omits the hint entirely and defers fully to the box's
143/// resolution. Used for roles with saved-record aliases (router nets may be
144/// saved as `"router"` or the legacy `"mikrotik"`; the box dispatches both
145/// to the same handler, but a hint must match the saved string exactly).
146pub fn net_command(
147    netname: &str,
148    role: impl Into<Option<&'static str>>,
149    action: &str,
150    params: Value,
151    timeout: Timeout,
152) -> HttpRequest {
153    let mut body = json!({
154        "netname": netname,
155        "action": action,
156        "params": params,
157    });
158    if let Some(role) = role.into() {
159        body["role"] = json!(role);
160    }
161    HttpRequest {
162        method: Method::Post,
163        path: "/net/command".to_string(),
164        body: Some(body),
165        timeout,
166    }
167}
168
169/// Build a `POST /supply/command` request.
170pub fn supply_command(netname: &str, action: &str, params: Value) -> HttpRequest {
171    HttpRequest {
172        method: Method::Post,
173        path: "/supply/command".to_string(),
174        body: Some(json!({
175            "netname": netname,
176            "action": action,
177            "params": params,
178        })),
179        timeout: Timeout::Default,
180    }
181}
182
183/// Build a `POST /battery/command` request.
184pub fn battery_command(netname: &str, action: &str, params: Value) -> HttpRequest {
185    HttpRequest {
186        method: Method::Post,
187        path: "/battery/command".to_string(),
188        body: Some(json!({
189            "netname": netname,
190            "action": action,
191            "params": params,
192        })),
193        timeout: Timeout::Default,
194    }
195}
196
197/// Build a `POST /usb/command` request (no `params` object on this endpoint).
198pub fn usb_command(netname: &str, action: &str) -> HttpRequest {
199    HttpRequest {
200        method: Method::Post,
201        path: "/usb/command".to_string(),
202        body: Some(json!({
203            "netname": netname,
204            "action": action,
205        })),
206        timeout: Timeout::Default,
207    }
208}
209
210/// Build a `POST /usb/command` cycle request (box >= 0.39.0). `off_time` is
211/// the unpowered window in seconds (box default 1s, range 0.5-10); it rides
212/// in the body only when set, so an older box that predates the key never
213/// sees it. The client timeout is widened past the off window plus the
214/// box-side re-enumeration watch, so a healthy slow cold boot is not
215/// aborted mid-cycle.
216pub fn usb_cycle(netname: &str, off_time: Option<f64>) -> HttpRequest {
217    let mut body = json!({
218        "netname": netname,
219        "action": "cycle",
220    });
221    if let Some(secs) = off_time {
222        body["off_time"] = json!(secs);
223    }
224    HttpRequest {
225        method: Method::Post,
226        path: "/usb/command".to_string(),
227        body: Some(body),
228        timeout: Timeout::After(Duration::from_secs_f64(30.0 + off_time.unwrap_or(1.0))),
229    }
230}
231
232/// Build a box-level command request (`POST /ble/command`, `/wifi/command`,
233/// `/blufi/command`). These endpoints drive the box's own hardware (its
234/// Bluetooth adapter or wlan interface) rather than a saved net, so the body
235/// carries only `{action, params}` — no `netname`.
236pub fn box_command(path: &str, action: &str, params: Value, timeout: Timeout) -> HttpRequest {
237    HttpRequest {
238        method: Method::Post,
239        path: path.to_string(),
240        body: Some(json!({
241            "action": action,
242            "params": params,
243        })),
244        timeout,
245    }
246}
247
248/// Build a plain GET request (discovery/health endpoints).
249pub fn get(path: &str) -> HttpRequest {
250    HttpRequest {
251        method: Method::Get,
252        path: path.to_string(),
253        body: None,
254        timeout: Timeout::Default,
255    }
256}
257
258/// Normalize a user-supplied host string into a base URL.
259///
260/// Accepts a bare host/IP (`"192.168.1.42"`, `"mybox.tailnet.ts.net"`), a
261/// `host:port` pair, or a full URL. The scheme defaults to `http` and the
262/// port to [`DEFAULT_PORT`] (9000).
263pub(crate) fn base_url(host: &str) -> Result<String> {
264    base_url_with_port(host, DEFAULT_PORT)
265}
266
267/// Like [`base_url`] but uses `default_port` when the host has no explicit
268/// port. Used for the debug-service URL, which defaults to 8765.
269pub(crate) fn base_url_with_port(host: &str, default_port: u16) -> Result<String> {
270    let input = host.trim();
271    let (scheme, rest) = match input.split_once("://") {
272        Some((scheme, rest)) => (scheme, rest),
273        None => ("http", input),
274    };
275    let rest = rest.trim_end_matches('/');
276    if scheme.is_empty() || rest.is_empty() {
277        return Err(Error::Config(format!("invalid box host '{host}'")));
278    }
279    if rest.contains(':') {
280        Ok(format!("{scheme}://{rest}"))
281    } else {
282        Ok(format!("{scheme}://{rest}:{default_port}"))
283    }
284}
285
286/// Derive a sibling service base URL on the same host but a different port,
287/// e.g. turn `http://192.168.1.42:9000` into `http://192.168.1.42:8765` for
288/// the debug service. `base` is expected to be normalized by [`base_url`].
289pub(crate) fn service_base(base: &str, port: u16) -> String {
290    let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
291    let host = rest.rsplit_once(':').map(|(h, _)| h).unwrap_or(rest);
292    format!("{scheme}://{host}:{port}")
293}
294
295// ---------------------------------------------------------------------------
296// Debug service (:8765) — separate protocol from the :9000 command envelope
297// ---------------------------------------------------------------------------
298
299/// Build a `POST /debug/<op>` request for the debug service.
300pub fn debug_request(path: &str, body: Value, timeout: Timeout) -> HttpRequest {
301    HttpRequest {
302        method: Method::Post,
303        path: path.to_string(),
304        body: Some(body),
305        timeout,
306    }
307}
308
309/// Interpret a debug-service `(status, body)` pair. The debug service does
310/// not use the `success` envelope: 200 is success, and errors arrive as
311/// `{"error": msg, "status": "error"}` with a 4xx/5xx code.
312pub fn parse_debug(status: u16, body: Value) -> Result<Value> {
313    if status == 200 {
314        return Ok(body);
315    }
316    let message = body
317        .get("error")
318        .and_then(Value::as_str)
319        .or_else(|| body.get("message").and_then(Value::as_str))
320        .unwrap_or("debug service request failed")
321        .to_string();
322    Err(match status {
323        // The service's per-probe lock gave up waiting (box >= 0.51.0).
324        503 if message.contains("busy") => Error::ProbeBusy { message },
325        // The net's safety settings refused a flash or erase.
326        403 => Error::SafetyRefused { message },
327        _ => Error::Box { status, message },
328    })
329}
330
331/// `GET /health` on the debug service.
332pub fn debug_health_request() -> HttpRequest {
333    get("/health")
334}
335
336/// Response of the debug service's `GET /health`.
337#[derive(Debug, Clone, Deserialize)]
338#[non_exhaustive]
339pub struct DebugHealth {
340    /// `"healthy"` when the service is up.
341    #[serde(default)]
342    pub status: String,
343    /// Service version string.
344    #[serde(default)]
345    pub version: Option<String>,
346    /// What the service can do beyond its original request shapes, such as
347    /// `"erase_range"` (box >= 0.50.0) and `"halt"`. Empty on a box that
348    /// predates the list.
349    #[serde(default)]
350    pub features: Vec<String>,
351}
352
353impl DebugHealth {
354    /// Whether the service lists `feature`.
355    pub fn supports(&self, feature: &str) -> bool {
356        self.features.iter().any(|f| f == feature)
357    }
358}
359
360/// Interpret the debug service's `/health`, and refuse unless it lists
361/// `feature`. `since` names the box versions that have it (`">= 0.50.0"`).
362pub fn require_debug_feature(status: u16, body: Value, feature: &str, since: &str) -> Result<()> {
363    let health: DebugHealth = serde_json::from_value(parse_debug(status, body)?)?;
364    if health.supports(feature) {
365        Ok(())
366    } else {
367        Err(Error::UnsupportedByBox {
368            message: format!(
369                "the box debug service does not support '{feature}' (box software {since})"
370            ),
371        })
372    }
373}
374
375/// The flash range a debug `erase` covered, as the box reports it.
376#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
377#[non_exhaustive]
378pub struct EraseRange {
379    /// First erased address.
380    pub start: u64,
381    /// Last erased address (inclusive).
382    pub end: u64,
383    /// Number of bytes erased.
384    pub length: u64,
385    /// Where the range came from: `"request"` (the caller), `"script"` (a
386    /// `LAGER_ERASE_RANGE` line in the net's J-Link script) or `"default"`
387    /// (the part family's default).
388    pub source: String,
389    /// The range as text, e.g. `"0x16000000-0x160FFFFF (1024 KiB)"`.
390    #[serde(default)]
391    pub text: String,
392}
393
394/// Result of a debug `erase`.
395#[derive(Debug, Clone, Deserialize)]
396#[non_exhaustive]
397pub struct EraseReport {
398    /// The backend's output.
399    #[serde(default)]
400    pub output: String,
401    /// Debug backend (`"jlink"` or `"openocd"`); `None` from a box before
402    /// 0.50.0.
403    #[serde(default)]
404    pub backend: Option<String>,
405    /// The range erased. `None` for a full-chip erase, and from a box before
406    /// 0.50.0.
407    #[serde(default)]
408    pub erase_range: Option<EraseRange>,
409}
410
411/// Result of a debug `flash` that programmed the target.
412#[derive(Debug, Clone)]
413#[non_exhaustive]
414pub struct FlashReport {
415    /// The backend's output, one entry per line or step.
416    pub output: Vec<String>,
417    /// Debug backend (`"jlink"` or `"openocd"`), when the box reports it.
418    pub backend: Option<String>,
419    /// The box's verdict: `Some(true)` from box >= 0.51.0, `None` from an
420    /// older box, which reports no verdict. A `false` verdict is never
421    /// returned here; it is [`Error::FlashFailed`].
422    pub programmed: Option<bool>,
423}
424
425/// The `output` of a debug response: a list of lines, or one string.
426fn output_lines(body: &Value) -> Vec<String> {
427    match body.get("output") {
428        Some(Value::Array(items)) => items
429            .iter()
430            .map(|v| match v {
431                Value::String(s) => s.clone(),
432                other => other.to_string(),
433            })
434            .collect(),
435        Some(Value::String(s)) => s.lines().map(str::to_string).collect(),
436        _ => Vec::new(),
437    }
438}
439
440/// Interpret a successful `/debug/flash` body. The box answers 200 even when
441/// a J-Link flash programmed nothing, and says so in `programmed` / `error`
442/// (box >= 0.51.0); that is turned into [`Error::FlashFailed`].
443pub fn parse_flash(body: Value) -> Result<FlashReport> {
444    let output = output_lines(&body);
445    let programmed = body.get("programmed").and_then(Value::as_bool);
446    if programmed == Some(false) {
447        let error = body
448            .get("error")
449            .and_then(Value::as_str)
450            .unwrap_or("the probe reported that nothing was programmed")
451            .to_string();
452        return Err(Error::FlashFailed { error, output });
453    }
454    Ok(FlashReport {
455        output,
456        backend: body
457            .get("backend")
458            .and_then(Value::as_str)
459            .map(str::to_string),
460        programmed,
461    })
462}
463
464/// GDB-server sub-object of a [`DebugConnection`].
465#[derive(Debug, Clone, Deserialize)]
466pub struct GdbServer {
467    /// Server state, e.g. `"started"` or `"already_running"`.
468    #[serde(default)]
469    pub status: Option<String>,
470    /// GDB protocol port.
471    #[serde(default, deserialize_with = "lenient::opt_i64")]
472    pub gdb_port: Option<i64>,
473    /// SWO output port (J-Link only).
474    #[serde(default, deserialize_with = "lenient::opt_i64")]
475    pub swo_port: Option<i64>,
476    /// Telnet I/O port.
477    #[serde(default, deserialize_with = "lenient::opt_i64")]
478    pub telnet_port: Option<i64>,
479    /// TCL/RPC port (OpenOCD only).
480    #[serde(default, deserialize_with = "lenient::opt_i64")]
481    pub tcl_port: Option<i64>,
482    /// RTT telnet port.
483    #[serde(default, deserialize_with = "lenient::opt_i64")]
484    pub rtt_telnet_port: Option<i64>,
485    /// Server process id.
486    #[serde(default, deserialize_with = "lenient::opt_i64")]
487    pub pid: Option<i64>,
488}
489
490/// Result of a debug `connect`.
491#[derive(Debug, Clone, Deserialize)]
492pub struct DebugConnection {
493    /// Connection status, e.g. `"connected"`.
494    #[serde(default)]
495    pub status: Option<String>,
496    /// Resolved device/target type.
497    #[serde(default)]
498    pub device: Option<String>,
499    /// Probe instrument name.
500    #[serde(default)]
501    pub probe: Option<String>,
502    /// Probe serial.
503    #[serde(default)]
504    pub serial: Option<String>,
505    /// Debug backend that started (`"jlink"` or `"openocd"`).
506    #[serde(default)]
507    pub backend: Option<String>,
508    /// Human-readable message.
509    #[serde(default)]
510    pub message: Option<String>,
511    /// Backend process id.
512    #[serde(default, deserialize_with = "lenient::opt_i64")]
513    pub pid: Option<i64>,
514    /// GDB-server details, if a server was started.
515    #[serde(default)]
516    pub gdb_server: Option<GdbServer>,
517}
518
519/// Result of a debug `info` query.
520#[derive(Debug, Clone, Deserialize)]
521#[non_exhaustive]
522pub struct DebugInfo {
523    /// Net name.
524    #[serde(default)]
525    pub net_name: Option<String>,
526    /// Resolved device/target type.
527    #[serde(default)]
528    pub device: Option<String>,
529    /// Target architecture.
530    #[serde(default)]
531    pub arch: Option<String>,
532    /// Probe instrument name.
533    #[serde(default)]
534    pub probe: Option<String>,
535    /// Probe serial.
536    #[serde(default)]
537    pub serial: Option<String>,
538    /// Debug backend (`"jlink"` or `"openocd"`).
539    #[serde(default)]
540    pub backend: Option<String>,
541    /// Whether a gdbserver/daemon is currently running for this probe.
542    /// Deprecated by the box in favor of [`DebugInfo::gdbserver_running`],
543    /// and kept with this meaning.
544    #[serde(default)]
545    pub connected: bool,
546    /// Whether a gdbserver/daemon is running (box >= 0.44.0; `None` before).
547    #[serde(default, deserialize_with = "lenient::opt_bool")]
548    pub gdbserver_running: Option<bool>,
549    /// Whether the target answers through the probe (box >= 0.44.0).
550    /// `Some(false)` is definite: no server, or its log shows the attach
551    /// failed. `None` means not checked; [`crate::DebugNet::info_probed`]
552    /// reads the target to settle it.
553    #[serde(default, deserialize_with = "lenient::opt_bool")]
554    pub target_attached: Option<bool>,
555}
556
557/// Result of a debug `status` query.
558#[derive(Debug, Clone, Deserialize)]
559#[non_exhaustive]
560pub struct DebugStatus {
561    /// Whether a gdbserver/daemon is currently running for this probe.
562    /// Deprecated by the box in favor of
563    /// [`DebugStatus::gdbserver_running`], and kept with this meaning.
564    #[serde(default)]
565    pub connected: bool,
566    /// Whether a gdbserver/daemon is running (box >= 0.44.0; `None` before).
567    #[serde(default, deserialize_with = "lenient::opt_bool")]
568    pub gdbserver_running: Option<bool>,
569    /// Whether the target answers through the probe (box >= 0.44.0).
570    /// `Some(false)` is definite: no server, or its log shows the attach
571    /// failed. `None` means not checked; [`crate::DebugNet::status_probed`]
572    /// reads the target to settle it.
573    #[serde(default, deserialize_with = "lenient::opt_bool")]
574    pub target_attached: Option<bool>,
575    /// Backend process id, when connected.
576    #[serde(default, deserialize_with = "lenient::opt_i64")]
577    pub pid: Option<i64>,
578    /// Probe serial.
579    #[serde(default)]
580    pub serial: Option<String>,
581    /// Debug backend (`"jlink"` or `"openocd"`).
582    #[serde(default)]
583    pub backend: Option<String>,
584}
585
586/// Extract the hex `data` field from a `/debug/memrd` response into bytes.
587pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
588    let hex = body
589        .get("data")
590        .and_then(Value::as_str)
591        .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
592    decode_hex(hex)
593}
594
595/// Decode a hex string into bytes.
596pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
597    let s = s.trim();
598    if s.len() % 2 != 0 {
599        return Err(Error::Decode("odd-length hex string".to_string()));
600    }
601    (0..s.len())
602        .step_by(2)
603        .map(|i| {
604            u8::from_str_radix(&s[i..i + 2], 16)
605                .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
606        })
607        .collect()
608}
609
610// ---------------------------------------------------------------------------
611// Lenient deserialization helpers
612// ---------------------------------------------------------------------------
613//
614// Instrument drivers occasionally hand numbers back as strings (SCPI query
615// results). These helpers accept a JSON number, a numeric string, or null.
616
617pub(crate) fn as_f64(v: &Value) -> Option<f64> {
618    match v {
619        Value::Number(n) => n.as_f64(),
620        Value::String(s) => s.trim().parse().ok(),
621        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
622        _ => None,
623    }
624}
625
626pub(crate) fn as_i64(v: &Value) -> Option<i64> {
627    match v {
628        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
629        Value::String(s) => s
630            .trim()
631            .parse::<i64>()
632            .ok()
633            .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
634        Value::Bool(b) => Some(i64::from(*b)),
635        _ => None,
636    }
637}
638
639pub(crate) fn as_bool(v: &Value) -> Option<bool> {
640    match v {
641        Value::Bool(b) => Some(*b),
642        Value::Number(n) => n.as_f64().map(|f| f != 0.0),
643        Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
644            "true" | "on" | "1" | "yes" | "enabled" => Some(true),
645            "false" | "off" | "0" | "no" | "disabled" => Some(false),
646            _ => None,
647        },
648        _ => None,
649    }
650}
651
652pub(crate) mod lenient {
653    //! `deserialize_with` adapters for fields that may arrive as numbers,
654    //! numeric strings, or null.
655
656    use serde::{Deserialize, Deserializer};
657    use serde_json::Value;
658
659    pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
660        let v = Option::<Value>::deserialize(d)?;
661        Ok(v.as_ref().and_then(super::as_f64))
662    }
663
664    pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
665        let v = Option::<Value>::deserialize(d)?;
666        Ok(v.as_ref().and_then(super::as_i64))
667    }
668
669    pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
670        let v = Option::<Value>::deserialize(d)?;
671        Ok(v.as_ref().and_then(super::as_bool))
672    }
673}
674
675// ---------------------------------------------------------------------------
676// Typed extraction from the envelope
677// ---------------------------------------------------------------------------
678
679/// Extract the `value` field as an `f64` (accepting numeric strings).
680pub fn value_f64(resp: CommandResponse) -> Result<f64> {
681    resp.value
682        .as_ref()
683        .and_then(as_f64)
684        .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
685}
686
687/// Extract the `value` field as an `i64` (accepting numeric strings).
688pub fn value_i64(resp: CommandResponse) -> Result<i64> {
689    resp.value
690        .as_ref()
691        .and_then(as_i64)
692        .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
693}
694
695/// Extract the `value` field as a list of integers (I2C bytes/addresses,
696/// SPI words).
697pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
698    let arr = resp
699        .value
700        .as_ref()
701        .and_then(Value::as_array)
702        .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
703    arr.iter()
704        .map(|v| {
705            as_i64(v)
706                .and_then(|n| u32::try_from(n).ok())
707                .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
708        })
709        .collect()
710}
711
712/// Deserialize the `value` field into a typed struct.
713pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
714    let v = resp
715        .value
716        .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
717    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
718}
719
720/// The `warnings` a box attaches to a successful command (box >= 0.49.0;
721/// empty when it has nothing to say).
722pub fn warnings(resp: &CommandResponse) -> Vec<String> {
723    match resp.extra.get("warnings") {
724        Some(Value::Array(items)) => items
725            .iter()
726            .map(|v| match v {
727                Value::String(s) => s.clone(),
728                other => other.to_string(),
729            })
730            .collect(),
731        _ => Vec::new(),
732    }
733}
734
735/// Reclassify the 400 an older box answers for an action it does not know
736/// (`Unknown action '<action>' for <role>`) as [`Error::UnsupportedByBox`],
737/// naming the box version that added it.
738pub(crate) fn unknown_action_compat(err: Error, action: &str, since: &str) -> Error {
739    match err {
740        Error::Box {
741            status: 400,
742            ref message,
743        } if message.contains(&format!("Unknown action '{action}'")) => Error::UnsupportedByBox {
744            message: format!("the '{action}' action requires box software {since}"),
745        },
746        other => other,
747    }
748}
749
750/// Deserialize the `state` field into a typed struct.
751pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
752    let v = resp
753        .state
754        .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
755    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
756}
757
758/// Interpret a nets-list body into raw JSON values. `/nets/list` returns a
759/// bare array; the older `/uart/nets/list` wraps it in `{"nets": [...]}`.
760pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
761    match body {
762        Value::Array(a) => a,
763        Value::Object(mut o) => match o.remove("nets") {
764            Some(Value::Array(a)) => a,
765            _ => vec![],
766        },
767        _ => vec![],
768    }
769}
770
771/// Interpret a nets-list body into typed [`NetRecord`]s.
772pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
773    let list = Value::Array(nets_list_values(body));
774    serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
775}
776
777/// Discard the payload, keeping only success/failure.
778pub fn unit(_resp: CommandResponse) -> Result<()> {
779    Ok(())
780}
781
782/// Keep the whole envelope (for callers that want `message` etc.).
783pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
784    Ok(resp)
785}
786
787// ---------------------------------------------------------------------------
788// Shared response types
789// ---------------------------------------------------------------------------
790
791/// One saved-net record from `GET /nets/list` (the box's `saved_nets.json`).
792#[derive(Debug, Clone, Deserialize)]
793pub struct NetRecord {
794    /// Net name, e.g. `"supply1"`.
795    #[serde(default)]
796    pub name: String,
797    /// Role string, e.g. `"power-supply"`, `"gpio"`, `"adc"`.
798    #[serde(default)]
799    pub role: String,
800    /// Instrument backing this net, e.g. `"Rigol DP832"`, `"LabJack T7"`.
801    #[serde(default)]
802    pub instrument: Option<String>,
803    /// Pin / channel identifier (number or string depending on role).
804    #[serde(default)]
805    pub pin: Option<Value>,
806    /// Channel (multi-channel instruments).
807    #[serde(default)]
808    pub channel: Option<Value>,
809    /// VISA or device address.
810    #[serde(default)]
811    pub address: Option<String>,
812    /// Role-specific saved parameters (SPI mode, UART baudrate, ...).
813    #[serde(default)]
814    pub params: Option<Map<String, Value>>,
815    /// Safety ceilings the box enforces on this net (box >= 0.35.0).
816    /// `None` means unrestricted.
817    #[serde(default)]
818    pub safety_limits: Option<SafetyLimits>,
819    /// Everything else in the record (mappings, scope_points, ...).
820    #[serde(flatten)]
821    pub extra: Map<String, Value>,
822}
823
824/// Response of `GET /health`.
825#[derive(Debug, Clone, Deserialize)]
826pub struct Health {
827    /// `"healthy"` when the box HTTP server is up.
828    #[serde(default)]
829    pub status: String,
830    /// Service identifier.
831    #[serde(default)]
832    pub service: Option<String>,
833    /// Service version string.
834    #[serde(default)]
835    pub version: Option<String>,
836}
837
838/// One net summary inside [`BoxStatus`].
839#[derive(Debug, Clone, Deserialize)]
840pub struct NetSummary {
841    /// Net name.
842    #[serde(default)]
843    pub name: String,
844    /// Net type name (e.g. `"PowerSupply"`, `"GPIO"`).
845    #[serde(default, rename = "type")]
846    pub net_type: String,
847}
848
849/// Capabilities advertised by the box in `GET /status`.
850#[derive(Debug, Clone, Default, Deserialize)]
851#[non_exhaustive]
852pub struct BoxCapabilities {
853    /// Whether the box serves `POST /net/command` (Tier-1 nets over HTTP).
854    /// When false the box software predates this crate's contract.
855    #[serde(default, rename = "netCommand")]
856    pub net_command: bool,
857    /// Roles served by `POST /net/command` on this box. Empty on box images
858    /// that predate role advertising (which still serve the original Tier-1
859    /// roles when `net_command` is true).
860    #[serde(default, rename = "netCommandRoles")]
861    pub net_command_roles: Vec<String>,
862    /// Whether the box serves `POST /ble/command`.
863    #[serde(default, rename = "bleCommand")]
864    pub ble_command: bool,
865    /// Whether the box serves `POST /wifi/command`.
866    #[serde(default, rename = "wifiCommand")]
867    pub wifi_command: bool,
868    /// Whether the box serves `POST /blufi/command`.
869    #[serde(default, rename = "blufiCommand")]
870    pub blufi_command: bool,
871    /// Whether the box serves the `/custom-devices/*` endpoints (the
872    /// `lager nets assign` backend). Not used by this crate; surfaced for
873    /// callers probing box features.
874    #[serde(default, rename = "customDevices")]
875    pub custom_devices: bool,
876    /// Whether the box serves `/binaries/*` and `/download-file`. Not used
877    /// by this crate; surfaced for callers probing box features.
878    #[serde(default)]
879    pub binaries: bool,
880    /// Whether the box serves `PUT /nets/<name>/safety-limits` (box >=
881    /// 0.35.0). The flag mirrors route registration rather than the version
882    /// string, so a box whose nets handler failed to import reads `false`
883    /// here even if its version says otherwise.
884    #[serde(default, rename = "safetyLimits")]
885    pub safety_limits: bool,
886    /// Whether `/net/command` runs custom driver roles, which are then
887    /// listed in [`BoxCapabilities::net_command_roles`] (box >= 0.59.0).
888    /// Drive them with [`crate::LagerBox::custom`].
889    #[serde(default, rename = "customDrivers")]
890    pub custom_drivers: bool,
891    /// Whether a custom driver can declare Python packages for the box to
892    /// install (box >= 0.60.0). Not used by this crate.
893    #[serde(default, rename = "customDriverDeps")]
894    pub custom_driver_deps: bool,
895    /// Whether the box serves `GET|PUT /nets/<name>/metadata` (box >=
896    /// 0.46.0). Not used by this crate.
897    #[serde(default, rename = "netMetadataSync")]
898    pub net_metadata_sync: bool,
899    /// Whether the box serves `GET|PUT /box-metadata` (box >= 0.46.0). Not
900    /// used by this crate.
901    #[serde(default, rename = "boxMetadataSync")]
902    pub box_metadata_sync: bool,
903    /// Whether the box serves the `GET /bench` manifest (box >= 0.50.0). Not
904    /// used by this crate.
905    #[serde(default, rename = "benchManifest")]
906    pub bench_manifest: bool,
907    /// Whether `/nets/state` entries carry `enabled` (box >= 0.53.0); see
908    /// [`NetState::enabled`].
909    #[serde(default, rename = "netsStateEnabled")]
910    pub nets_state_enabled: bool,
911    /// Whether `/nets/state?stream=1` streams results as they arrive (box
912    /// >= 0.58.0). Not used by this crate, which reads the whole answer.
913    #[serde(default, rename = "netsStateStream")]
914    pub nets_state_stream: bool,
915}
916
917/// Response of `GET /status`.
918#[derive(Debug, Clone, Deserialize)]
919#[non_exhaustive]
920pub struct BoxStatus {
921    /// Whether the box reports itself healthy.
922    #[serde(default)]
923    pub healthy: bool,
924    /// Box software version.
925    #[serde(default)]
926    pub version: String,
927    /// Configured nets (name + type only; use `nets()` for full records).
928    #[serde(default)]
929    pub nets: Vec<NetSummary>,
930    /// Endpoint capabilities.
931    #[serde(default)]
932    pub capabilities: BoxCapabilities,
933}
934
935/// Structured power-supply state from the `state` action on
936/// `POST /supply/command`. Fields are `None` when the individual read failed
937/// (the box returns a full-shaped dict even on a degraded read; `error`
938/// carries the reason).
939#[derive(Debug, Clone, Deserialize)]
940pub struct SupplyState {
941    /// Net name echo.
942    #[serde(default)]
943    pub netname: Option<String>,
944    /// Instrument channel driving this net.
945    #[serde(default, deserialize_with = "lenient::opt_i64")]
946    pub channel: Option<i64>,
947    /// Transport-level error when the whole state gather failed.
948    #[serde(default)]
949    pub error: Option<String>,
950    /// Measured output voltage (V).
951    #[serde(default, deserialize_with = "lenient::opt_f64")]
952    pub voltage: Option<f64>,
953    /// Measured output current (A).
954    #[serde(default, deserialize_with = "lenient::opt_f64")]
955    pub current: Option<f64>,
956    /// Measured output power (W).
957    #[serde(default, deserialize_with = "lenient::opt_f64")]
958    pub power: Option<f64>,
959    /// Whether the output is enabled.
960    #[serde(default, deserialize_with = "lenient::opt_bool")]
961    pub enabled: Option<bool>,
962    /// Operating mode (e.g. `"CV"`, `"CC"`).
963    #[serde(default)]
964    pub mode: Option<String>,
965    /// Voltage setpoint (V).
966    #[serde(default, deserialize_with = "lenient::opt_f64")]
967    pub voltage_set: Option<f64>,
968    /// Current limit setpoint (A).
969    #[serde(default, deserialize_with = "lenient::opt_f64")]
970    pub current_set: Option<f64>,
971    /// Hardware maximum voltage (V).
972    #[serde(default, deserialize_with = "lenient::opt_f64")]
973    pub voltage_max: Option<f64>,
974    /// Hardware maximum current (A).
975    #[serde(default, deserialize_with = "lenient::opt_f64")]
976    pub current_max: Option<f64>,
977    /// Over-current protection limit (A).
978    #[serde(default, deserialize_with = "lenient::opt_f64")]
979    pub ocp_limit: Option<f64>,
980    /// Whether OCP has tripped.
981    #[serde(default, deserialize_with = "lenient::opt_bool")]
982    pub ocp_tripped: Option<bool>,
983    /// Over-voltage protection limit (V).
984    #[serde(default, deserialize_with = "lenient::opt_f64")]
985    pub ovp_limit: Option<f64>,
986    /// Whether OVP has tripped.
987    #[serde(default, deserialize_with = "lenient::opt_bool")]
988    pub ovp_tripped: Option<bool>,
989}
990
991/// Structured battery-simulator state from the `state` action on
992/// `POST /battery/command`. Fields are `None` when the individual read
993/// failed.
994#[derive(Debug, Clone, Deserialize)]
995pub struct BatteryState {
996    /// Net name echo.
997    #[serde(default)]
998    pub netname: Option<String>,
999    /// Instrument channel driving this net.
1000    #[serde(default, deserialize_with = "lenient::opt_i64")]
1001    pub channel: Option<i64>,
1002    /// Transport-level error when the whole state gather failed.
1003    #[serde(default)]
1004    pub error: Option<String>,
1005    /// Simulated terminal voltage (V).
1006    #[serde(default, deserialize_with = "lenient::opt_f64")]
1007    pub terminal_voltage: Option<f64>,
1008    /// Measured current (A).
1009    #[serde(default, deserialize_with = "lenient::opt_f64")]
1010    pub current: Option<f64>,
1011    /// Equivalent series resistance (ohm).
1012    #[serde(default, deserialize_with = "lenient::opt_f64")]
1013    pub esr: Option<f64>,
1014    /// State of charge (%).
1015    #[serde(default, deserialize_with = "lenient::opt_f64")]
1016    pub soc: Option<f64>,
1017    /// Open-circuit voltage (V).
1018    #[serde(default, deserialize_with = "lenient::opt_f64")]
1019    pub voc: Option<f64>,
1020    /// Whether the simulator output is enabled.
1021    #[serde(default, deserialize_with = "lenient::opt_bool")]
1022    pub enabled: Option<bool>,
1023    /// Simulation mode (`"static"` / `"dynamic"`).
1024    #[serde(default)]
1025    pub mode: Option<String>,
1026    /// Battery model / part number.
1027    #[serde(default)]
1028    pub model: Option<String>,
1029    /// Battery capacity (Ah).
1030    #[serde(default, deserialize_with = "lenient::opt_f64")]
1031    pub capacity: Option<f64>,
1032    /// Current limit (A).
1033    #[serde(default, deserialize_with = "lenient::opt_f64")]
1034    pub current_limit: Option<f64>,
1035    /// Over-current protection limit (A).
1036    #[serde(default, deserialize_with = "lenient::opt_f64")]
1037    pub ocp_limit: Option<f64>,
1038    /// Over-voltage protection limit (V).
1039    #[serde(default, deserialize_with = "lenient::opt_f64")]
1040    pub ovp_limit: Option<f64>,
1041    /// Voltage considered "full" (V).
1042    #[serde(default, deserialize_with = "lenient::opt_f64")]
1043    pub volt_full: Option<f64>,
1044    /// Voltage considered "empty" (V).
1045    #[serde(default, deserialize_with = "lenient::opt_f64")]
1046    pub volt_empty: Option<f64>,
1047    /// Whether OCP has tripped.
1048    #[serde(default, deserialize_with = "lenient::opt_bool")]
1049    pub ocp_tripped: Option<bool>,
1050    /// Whether OVP has tripped.
1051    #[serde(default, deserialize_with = "lenient::opt_bool")]
1052    pub ovp_tripped: Option<bool>,
1053}
1054
1055/// Structured e-load state from the `state` action on `POST /net/command`.
1056#[derive(Debug, Clone, Deserialize)]
1057pub struct EloadState {
1058    /// Active mode (`"cc"`, `"cv"`, `"cr"`, `"cp"`).
1059    #[serde(default)]
1060    pub mode: Option<String>,
1061    /// Whether the load input is enabled.
1062    #[serde(default, deserialize_with = "lenient::opt_bool")]
1063    pub input_enabled: Option<bool>,
1064    /// Measured voltage (V).
1065    #[serde(default, deserialize_with = "lenient::opt_f64")]
1066    pub measured_voltage: Option<f64>,
1067    /// Measured current (A).
1068    #[serde(default, deserialize_with = "lenient::opt_f64")]
1069    pub measured_current: Option<f64>,
1070    /// Measured power (W).
1071    #[serde(default, deserialize_with = "lenient::opt_f64")]
1072    pub measured_power: Option<f64>,
1073    /// Any extra driver-specific fields.
1074    #[serde(flatten)]
1075    pub extra: Map<String, Value>,
1076}
1077
1078/// Combined current/voltage/power reading from a watt-meter `all` action.
1079#[derive(Debug, Clone, Deserialize)]
1080pub struct WattReading {
1081    /// Mean current over the window (A).
1082    #[serde(default, deserialize_with = "lenient::opt_f64")]
1083    pub current: Option<f64>,
1084    /// Mean voltage over the window (V).
1085    #[serde(default, deserialize_with = "lenient::opt_f64")]
1086    pub voltage: Option<f64>,
1087    /// Mean power over the window (W).
1088    #[serde(default, deserialize_with = "lenient::opt_f64")]
1089    pub power: Option<f64>,
1090    /// Measurement window (s).
1091    #[serde(default, deserialize_with = "lenient::opt_f64")]
1092    pub duration_s: Option<f64>,
1093}
1094
1095/// Integrated energy reading from an energy-analyzer `read_energy` action.
1096#[derive(Debug, Clone, Deserialize)]
1097pub struct EnergyReading {
1098    /// Integrated energy (J).
1099    #[serde(default, deserialize_with = "lenient::opt_f64")]
1100    pub energy_j: Option<f64>,
1101    /// Integrated charge (C).
1102    #[serde(default, deserialize_with = "lenient::opt_f64")]
1103    pub charge_c: Option<f64>,
1104    /// Actual integration window (s).
1105    #[serde(default, deserialize_with = "lenient::opt_f64")]
1106    pub duration_s: Option<f64>,
1107    /// Any extra analyzer-specific fields.
1108    #[serde(flatten)]
1109    pub extra: Map<String, Value>,
1110}
1111
1112/// Statistical summary of one signal inside [`EnergyStats`].
1113#[derive(Debug, Clone, Default, Deserialize)]
1114pub struct StatSummary {
1115    /// Mean value.
1116    #[serde(default, deserialize_with = "lenient::opt_f64")]
1117    pub mean: Option<f64>,
1118    /// Minimum value.
1119    #[serde(default, deserialize_with = "lenient::opt_f64")]
1120    pub min: Option<f64>,
1121    /// Maximum value.
1122    #[serde(default, deserialize_with = "lenient::opt_f64")]
1123    pub max: Option<f64>,
1124    /// Standard deviation.
1125    #[serde(default, deserialize_with = "lenient::opt_f64")]
1126    pub std: Option<f64>,
1127    /// Any extra analyzer-specific fields.
1128    #[serde(flatten)]
1129    pub extra: Map<String, Value>,
1130}
1131
1132/// Statistics from an energy-analyzer `read_stats` action.
1133#[derive(Debug, Clone, Deserialize)]
1134pub struct EnergyStats {
1135    /// Current statistics (A).
1136    #[serde(default)]
1137    pub current: Option<StatSummary>,
1138    /// Voltage statistics (V).
1139    #[serde(default)]
1140    pub voltage: Option<StatSummary>,
1141    /// Power statistics (W).
1142    #[serde(default)]
1143    pub power: Option<StatSummary>,
1144    /// Any extra analyzer-specific fields.
1145    #[serde(flatten)]
1146    pub extra: Map<String, Value>,
1147}
1148
1149// ---------------------------------------------------------------------------
1150// Arm / webcam / router / BLE / WiFi / BluFi response types
1151// ---------------------------------------------------------------------------
1152
1153/// Cartesian position of a robot arm's end effector (mm), from the `value:
1154/// [x, y, z]` list the arm actions return.
1155#[derive(Debug, Clone, Copy, PartialEq)]
1156pub struct ArmPosition {
1157    /// X coordinate (mm).
1158    pub x: f64,
1159    /// Y coordinate (mm).
1160    pub y: f64,
1161    /// Z coordinate (mm).
1162    pub z: f64,
1163}
1164
1165/// Parse an arm response's `value: [x, y, z]` into an [`ArmPosition`].
1166pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
1167    let arr = resp
1168        .value
1169        .as_ref()
1170        .and_then(Value::as_array)
1171        .ok_or_else(|| {
1172            Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value))
1173        })?;
1174    match arr.as_slice() {
1175        [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
1176            (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
1177            _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
1178        },
1179        _ => Err(Error::Decode(format!(
1180            "expected 3-element arm position, got {} elements",
1181            arr.len()
1182        ))),
1183    }
1184}
1185
1186/// Result of starting a webcam stream (`start` action).
1187#[derive(Debug, Clone, Deserialize)]
1188#[non_exhaustive]
1189pub struct WebcamStream {
1190    /// MJPEG stream URL viewers should open.
1191    #[serde(default)]
1192    pub url: String,
1193    /// TCP port the stream is served on.
1194    #[serde(default, deserialize_with = "lenient::opt_i64")]
1195    pub port: Option<i64>,
1196    /// `true` when a stream was already up and the box reused it.
1197    #[serde(default)]
1198    pub already_running: bool,
1199}
1200
1201/// Current state of a webcam stream (`status`/`url` actions).
1202#[derive(Debug, Clone, Deserialize)]
1203#[non_exhaustive]
1204pub struct WebcamStatus {
1205    /// Whether a stream is currently running for this net.
1206    #[serde(default)]
1207    pub running: bool,
1208    /// Stream URL, when running.
1209    #[serde(default)]
1210    pub url: Option<String>,
1211    /// Stream TCP port, when running.
1212    #[serde(default, deserialize_with = "lenient::opt_i64")]
1213    pub port: Option<i64>,
1214    /// Backing video device (e.g. `/dev/video0`), when running.
1215    #[serde(default)]
1216    pub video_device: Option<String>,
1217    /// Which surface started the stream (`"cli"`, or what a client passed
1218    /// as `source`), when running (box >= 0.47.0).
1219    #[serde(default)]
1220    pub source: Option<String>,
1221    /// Who the starter said it was, when it said (box >= 0.47.0).
1222    #[serde(default)]
1223    pub started_by: Option<String>,
1224}
1225
1226/// System information for a router net (`system_info` action).
1227#[derive(Debug, Clone, Deserialize)]
1228pub struct RouterSystemInfo {
1229    /// Router identity name.
1230    #[serde(default)]
1231    pub name: Option<String>,
1232    /// RouterOS version.
1233    #[serde(default)]
1234    pub version: Option<String>,
1235    /// Board model, e.g. `"hAP ac^2"`.
1236    #[serde(default)]
1237    pub board: Option<String>,
1238    /// CPU architecture.
1239    #[serde(default)]
1240    pub architecture: Option<String>,
1241    /// Uptime string, e.g. `"1w2d3h"`.
1242    #[serde(default)]
1243    pub uptime: Option<String>,
1244    /// CPU load (%).
1245    #[serde(default, deserialize_with = "lenient::opt_i64")]
1246    pub cpu_load: Option<i64>,
1247    /// Free RAM (bytes).
1248    #[serde(default, deserialize_with = "lenient::opt_i64")]
1249    pub free_memory: Option<i64>,
1250    /// Total RAM (bytes).
1251    #[serde(default, deserialize_with = "lenient::opt_i64")]
1252    pub total_memory: Option<i64>,
1253    /// Free storage (bytes).
1254    #[serde(default, deserialize_with = "lenient::opt_i64")]
1255    pub free_hdd_space: Option<i64>,
1256    /// Any extra fields.
1257    #[serde(flatten)]
1258    pub extra: Map<String, Value>,
1259}
1260
1261/// One device found by a BLE or BluFi scan.
1262#[derive(Debug, Clone, Deserialize)]
1263pub struct BleDevice {
1264    /// Advertised name (falls back to the address when unnamed).
1265    #[serde(default)]
1266    pub name: String,
1267    /// BLE MAC address, `XX:XX:XX:XX:XX:XX`.
1268    #[serde(default)]
1269    pub address: String,
1270    /// Signal strength (dBm).
1271    #[serde(default, deserialize_with = "lenient::opt_i64")]
1272    pub rssi: Option<i64>,
1273    /// Advertised service UUIDs.
1274    #[serde(default)]
1275    pub uuids: Vec<String>,
1276}
1277
1278/// One GATT characteristic inside a [`BleService`].
1279#[derive(Debug, Clone, Deserialize)]
1280pub struct BleCharacteristic {
1281    /// Characteristic UUID.
1282    #[serde(default)]
1283    pub uuid: String,
1284    /// Human-readable description, when known.
1285    #[serde(default)]
1286    pub description: Option<String>,
1287    /// Supported operations, e.g. `["read", "notify"]`.
1288    #[serde(default)]
1289    pub properties: Vec<String>,
1290}
1291
1292/// One GATT service enumerated from a connected BLE device.
1293#[derive(Debug, Clone, Deserialize)]
1294pub struct BleService {
1295    /// Service UUID.
1296    #[serde(default)]
1297    pub uuid: String,
1298    /// Human-readable description, when known.
1299    #[serde(default)]
1300    pub description: Option<String>,
1301    /// Characteristics under this service.
1302    #[serde(default)]
1303    pub characteristics: Vec<BleCharacteristic>,
1304}
1305
1306/// Result of a BLE `info`/`connect`: the device's GATT database.
1307#[derive(Debug, Clone, Deserialize)]
1308pub struct BleDeviceInfo {
1309    /// Device address.
1310    #[serde(default)]
1311    pub address: String,
1312    /// Whether the box reached the device.
1313    #[serde(default)]
1314    pub connected: bool,
1315    /// Enumerated GATT services.
1316    #[serde(default)]
1317    pub services: Vec<BleService>,
1318}
1319
1320/// Status of one wireless interface on the box (`wifi status`).
1321#[derive(Debug, Clone, Deserialize)]
1322pub struct WifiInterface {
1323    /// Interface name, e.g. `"wlan0"`.
1324    #[serde(default)]
1325    pub interface: String,
1326    /// Connected SSID, or a placeholder like `"Not Connected"`.
1327    #[serde(default)]
1328    pub ssid: String,
1329    /// Connection state, e.g. `"Connected"` / `"Disconnected"`.
1330    #[serde(default)]
1331    pub state: String,
1332}
1333
1334/// One access point found by a box-side WiFi scan.
1335#[derive(Debug, Clone, Deserialize)]
1336pub struct WifiAccessPoint {
1337    /// Network SSID (`"Hidden"` for hidden networks).
1338    #[serde(default)]
1339    pub ssid: Option<String>,
1340    /// BSSID / AP MAC address.
1341    #[serde(default)]
1342    pub address: Option<String>,
1343    /// Signal strength (approximate %, 0-100).
1344    #[serde(default, deserialize_with = "lenient::opt_i64")]
1345    pub strength: Option<i64>,
1346    /// `"Open"` or `"Secured"`.
1347    #[serde(default)]
1348    pub security: Option<String>,
1349}
1350
1351/// Result of connecting the box to a WiFi network.
1352#[derive(Debug, Clone, Deserialize)]
1353pub struct WifiConnection {
1354    /// SSID the box joined.
1355    #[serde(default)]
1356    pub ssid: String,
1357    /// Whether the connection succeeded (always true in a success envelope).
1358    #[serde(default)]
1359    pub connected: bool,
1360    /// Interface used, e.g. `"wlan0"`.
1361    #[serde(default)]
1362    pub interface: Option<String>,
1363    /// Connection method, e.g. `"nmcli"` or `"wpa_supplicant"`.
1364    #[serde(default)]
1365    pub method: Option<String>,
1366}
1367
1368/// WiFi state reported by a BluFi target device (`status`, and embedded in
1369/// `connect`). Codes follow the ESP32 BluFi protocol.
1370#[derive(Debug, Clone, Deserialize)]
1371pub struct BlufiStatus {
1372    /// Target device name echo.
1373    #[serde(default)]
1374    pub device_name: Option<String>,
1375    /// Operation mode code (0 NULL, 1 STA, 2 SoftAP, 3 STA+SoftAP).
1376    #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
1377    pub op_mode: Option<i64>,
1378    /// Human-readable operation mode.
1379    #[serde(default, rename = "opModeName")]
1380    pub op_mode_name: Option<String>,
1381    /// Station connection code (0 connected, 1 failed, 2 connecting, 3 no IP).
1382    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1383    pub sta_conn: Option<i64>,
1384    /// Human-readable station connection state.
1385    #[serde(default, rename = "staConnName")]
1386    pub sta_conn_name: Option<String>,
1387    /// SoftAP connection count/state code.
1388    #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
1389    pub soft_ap_conn: Option<i64>,
1390}
1391
1392/// Result of a BluFi `connect`: firmware version plus WiFi state.
1393#[derive(Debug, Clone, Deserialize)]
1394pub struct BlufiDeviceInfo {
1395    /// Target firmware version, when the device reports one.
1396    #[serde(default)]
1397    pub version: Option<String>,
1398    /// WiFi state of the target.
1399    #[serde(flatten)]
1400    pub status: BlufiStatus,
1401}
1402
1403/// Result of a BluFi `provision`.
1404#[derive(Debug, Clone, Deserialize)]
1405pub struct BlufiProvisionResult {
1406    /// Target device name echo.
1407    #[serde(default)]
1408    pub device_name: Option<String>,
1409    /// SSID the target was provisioned onto.
1410    #[serde(default)]
1411    pub ssid: String,
1412    /// Station connection code after provisioning (0 = connected).
1413    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1414    pub sta_conn: Option<i64>,
1415    /// Human-readable station connection state.
1416    #[serde(default, rename = "staConnName")]
1417    pub sta_conn_name: Option<String>,
1418}
1419
1420/// One network seen by a BluFi target's own WiFi scan (`wifi_scan`).
1421#[derive(Debug, Clone, Deserialize)]
1422pub struct BlufiNetwork {
1423    /// Network SSID.
1424    #[serde(default)]
1425    pub ssid: String,
1426    /// Signal strength at the target (dBm).
1427    #[serde(default, deserialize_with = "lenient::opt_i64")]
1428    pub rssi: Option<i64>,
1429}
1430
1431/// Extract a named list field out of the `value` object (e.g. `devices`
1432/// from a BLE scan, `access_points` from a WiFi scan).
1433pub(crate) fn value_list_field<T: DeserializeOwned>(
1434    resp: CommandResponse,
1435    field: &str,
1436) -> Result<Vec<T>> {
1437    let list = resp
1438        .value
1439        .as_ref()
1440        .and_then(|v| v.get(field))
1441        .cloned()
1442        .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
1443    serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
1444}
1445
1446// ---------------------------------------------------------------------------
1447// USB bus enumeration (GET /usb/devices)
1448// ---------------------------------------------------------------------------
1449
1450/// Optional filters for [`crate::LagerBox::usb_devices_matching`], applied
1451/// box-side. `vid`/`pid` are hex strings (with or without `0x`); `serial`
1452/// is an exact iSerial match. An empty filter returns every device.
1453#[derive(Debug, Clone, Default)]
1454pub struct UsbDeviceFilter {
1455    /// USB vendor id, hex (e.g. `"0483"`).
1456    pub vid: Option<String>,
1457    /// USB product id, hex (e.g. `"df11"`).
1458    pub pid: Option<String>,
1459    /// Exact iSerial string.
1460    pub serial: Option<String>,
1461}
1462
1463/// One USB device on the box's bus, read from sysfs by `GET /usb/devices`.
1464/// String fields are `None` when the device does not expose the descriptor
1465/// (e.g. `serial` on devices with no iSerial).
1466#[derive(Debug, Clone, Deserialize)]
1467pub struct UsbDeviceInfo {
1468    /// sysfs entry name, e.g. `"1-1.4"`.
1469    #[serde(default)]
1470    pub sysfs_name: String,
1471    /// Vendor id, lowercase hex (e.g. `"0483"`).
1472    #[serde(default)]
1473    pub vid: Option<String>,
1474    /// Product id, lowercase hex (e.g. `"df11"`).
1475    #[serde(default)]
1476    pub pid: Option<String>,
1477    /// iSerial descriptor.
1478    #[serde(default)]
1479    pub serial: Option<String>,
1480    /// Product descriptor string.
1481    #[serde(default)]
1482    pub product: Option<String>,
1483    /// Manufacturer descriptor string.
1484    #[serde(default)]
1485    pub manufacturer: Option<String>,
1486    /// Bus number.
1487    #[serde(default)]
1488    pub busnum: Option<String>,
1489    /// Device number on the bus (changes on re-enumeration).
1490    #[serde(default)]
1491    pub devnum: Option<String>,
1492    /// Hub port path, e.g. `"1.4"`.
1493    #[serde(default)]
1494    pub devpath: Option<String>,
1495    /// bDeviceClass, hex.
1496    #[serde(default)]
1497    pub device_class: Option<String>,
1498    /// Negotiated speed in Mbps (`"1.5"`, `"12"`, `"480"`, ...).
1499    #[serde(default)]
1500    pub speed: Option<String>,
1501}
1502
1503/// Percent-encode one query-string value (RFC 3986 unreserved set).
1504fn encode_query_value(s: &str) -> String {
1505    let mut out = String::with_capacity(s.len());
1506    for b in s.bytes() {
1507        match b {
1508            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1509                out.push(b as char)
1510            }
1511            _ => out.push_str(&format!("%{b:02X}")),
1512        }
1513    }
1514    out
1515}
1516
1517/// Build a `GET /usb/devices` request with optional box-side filters.
1518pub fn usb_devices(filter: &UsbDeviceFilter) -> HttpRequest {
1519    let mut query = Vec::new();
1520    for (key, value) in [
1521        ("vid", &filter.vid),
1522        ("pid", &filter.pid),
1523        ("serial", &filter.serial),
1524    ] {
1525        if let Some(value) = value {
1526            query.push(format!("{key}={}", encode_query_value(value)));
1527        }
1528    }
1529    let path = if query.is_empty() {
1530        "/usb/devices".to_string()
1531    } else {
1532        format!("/usb/devices?{}", query.join("&"))
1533    };
1534    HttpRequest {
1535        method: Method::Get,
1536        path,
1537        body: None,
1538        timeout: Timeout::Default,
1539    }
1540}
1541
1542/// Parse a `GET /usb/devices` response into device records.
1543pub fn parse_usb_devices(status: u16, body: Value) -> Result<Vec<UsbDeviceInfo>> {
1544    if status == 404 {
1545        return Err(usb_devices_unsupported());
1546    }
1547    let resp = parse_command(status, body)?;
1548    let devices = resp
1549        .extra
1550        .get("devices")
1551        .cloned()
1552        .ok_or_else(|| Error::Decode("response has no 'devices' list".to_string()))?;
1553    serde_json::from_value(devices)
1554        .map_err(|e| Error::Decode(format!("invalid 'devices' shape: {e}")))
1555}
1556
1557pub(crate) fn usb_devices_unsupported() -> Error {
1558    Error::UnsupportedByBox {
1559        message: "this box does not serve GET /usb/devices (requires box software >= 0.33.0)"
1560            .to_string(),
1561    }
1562}
1563
1564/// Map a route-missing 404 (Flask's non-JSON default page) to
1565/// [`Error::UnsupportedByBox`] with an endpoint-specific message. Real box
1566/// errors carry JSON bodies and pass through untouched.
1567pub(crate) fn map_route_missing(err: Error, unsupported: fn() -> Error) -> Error {
1568    match err {
1569        Error::Box {
1570            status: 404,
1571            ref message,
1572        } if message.contains("non-JSON") => unsupported(),
1573        other => other,
1574    }
1575}
1576
1577// ---------------------------------------------------------------------------
1578// DFU (POST /usb/dfu)
1579// ---------------------------------------------------------------------------
1580
1581/// One device reported by `dfu-util -l` (via [`crate::nets::dfu::Dfu::list`]).
1582#[derive(Debug, Clone, Deserialize)]
1583pub struct DfuDevice {
1584    /// `"DFU"` (in DFU mode) or `"Runtime"` (app running, DFU-capable).
1585    #[serde(default)]
1586    pub mode: String,
1587    /// Vendor id, lowercase hex.
1588    #[serde(default)]
1589    pub vid: String,
1590    /// Product id, lowercase hex.
1591    #[serde(default)]
1592    pub pid: String,
1593    /// Device number on the bus.
1594    #[serde(default, deserialize_with = "lenient::opt_i64")]
1595    pub devnum: Option<i64>,
1596    /// Configuration index.
1597    #[serde(default, deserialize_with = "lenient::opt_i64")]
1598    pub cfg: Option<i64>,
1599    /// Interface index.
1600    #[serde(default, deserialize_with = "lenient::opt_i64")]
1601    pub intf: Option<i64>,
1602    /// Alternate setting index.
1603    #[serde(default, deserialize_with = "lenient::opt_i64")]
1604    pub alt: Option<i64>,
1605    /// Interface name (e.g. `"@Internal Flash /0x08000000/..."`).
1606    #[serde(default)]
1607    pub name: Option<String>,
1608    /// Device serial.
1609    #[serde(default)]
1610    pub serial: Option<String>,
1611    /// Hub port path, e.g. `"1-1.4"`.
1612    #[serde(default)]
1613    pub path: Option<String>,
1614}
1615
1616/// Captured output of one box-side `dfu-util` run.
1617#[derive(Debug, Clone, Deserialize)]
1618pub struct DfuOutput {
1619    /// dfu-util exit code (0 on the success envelope).
1620    #[serde(default, deserialize_with = "lenient::opt_i64")]
1621    pub exit_code: Option<i64>,
1622    /// Captured stdout (progress, and a raw download's device status).
1623    #[serde(default)]
1624    pub stdout: String,
1625    /// Captured stderr (warnings, and a DfuSe download's device status).
1626    #[serde(default)]
1627    pub stderr: String,
1628}
1629
1630/// Interpret a `POST /usb/dfu` response. A run that reached dfu-util and
1631/// exited non-zero (the failure envelope still carries its output in
1632/// `value`) is [`Error::DfuFailed`] with both streams; everything else,
1633/// including a box with no dfu-util and a timeout, goes through
1634/// [`parse_command`] unchanged.
1635pub fn parse_dfu(status: u16, body: Value) -> Result<CommandResponse> {
1636    let failed = !(status == 200 && body.get("success").and_then(Value::as_bool) == Some(true));
1637    if failed {
1638        if let Some(output) = body
1639            .get("value")
1640            .and_then(|v| serde_json::from_value::<DfuOutput>(v.clone()).ok())
1641            .filter(|o| o.exit_code.is_some())
1642        {
1643            return Err(Error::DfuFailed {
1644                status,
1645                exit_code: output.exit_code,
1646                stdout: output.stdout,
1647                stderr: output.stderr,
1648            });
1649        }
1650    }
1651    parse_command(status, body)
1652}
1653
1654pub(crate) fn dfu_unsupported() -> Error {
1655    Error::UnsupportedByBox {
1656        message: "this box does not serve POST /usb/dfu (requires box software >= 0.33.0)"
1657            .to_string(),
1658    }
1659}
1660
1661// ---------------------------------------------------------------------------
1662// Box lock / reservation (GET/POST /lock, /lock/heartbeat, /unlock)
1663// ---------------------------------------------------------------------------
1664
1665/// Box lock state, as returned by the `/lock` family of endpoints (the same
1666/// ones `lager boxes lock` uses). `locked: false` with everything else
1667/// `None` means the box is free.
1668#[derive(Debug, Clone, Deserialize)]
1669pub struct BoxLock {
1670    /// Whether the box is currently locked.
1671    #[serde(default)]
1672    pub locked: bool,
1673    /// Lock holder.
1674    #[serde(default)]
1675    pub user: Option<String>,
1676    /// Holder classification (`"user"`, `"ci"`, `"ephemeral"`, or another
1677    /// service's reservation origin).
1678    #[serde(default)]
1679    pub holder_type: Option<String>,
1680    /// When the lock was acquired (ISO 8601 UTC).
1681    #[serde(default)]
1682    pub locked_at: Option<String>,
1683    /// Last heartbeat (ISO 8601 UTC).
1684    #[serde(default)]
1685    pub last_heartbeat: Option<String>,
1686    /// Lock TTL in seconds; `None` means the lock never auto-expires.
1687    #[serde(default, deserialize_with = "lenient::opt_i64")]
1688    pub ttl_seconds: Option<i64>,
1689    /// On acquire: the holder before this call (`None` when the box was
1690    /// free). Distinguishes "just acquired" from "already held it".
1691    #[serde(default)]
1692    pub previous_user: Option<String>,
1693}
1694
1695/// Build a `GET /lock` request.
1696pub fn lock_status() -> HttpRequest {
1697    get("/lock")
1698}
1699
1700/// Build a `POST /lock` request. `ttl_seconds: None` sends JSON `null`
1701/// (an eternal lock, matching `lager boxes lock`).
1702pub fn lock_acquire(user: &str, holder_type: &str, ttl_seconds: Option<u64>) -> HttpRequest {
1703    HttpRequest {
1704        method: Method::Post,
1705        path: "/lock".to_string(),
1706        body: Some(json!({
1707            "user": user,
1708            "holder_type": holder_type,
1709            "ttl_seconds": ttl_seconds,
1710        })),
1711        timeout: Timeout::Default,
1712    }
1713}
1714
1715/// Build a `POST /lock/heartbeat` request.
1716pub fn lock_heartbeat(user: &str) -> HttpRequest {
1717    HttpRequest {
1718        method: Method::Post,
1719        path: "/lock/heartbeat".to_string(),
1720        body: Some(json!({ "user": user })),
1721        timeout: Timeout::Default,
1722    }
1723}
1724
1725/// Build a `POST /unlock` request.
1726pub fn unlock(user: &str, force: bool) -> HttpRequest {
1727    HttpRequest {
1728        method: Method::Post,
1729        path: "/unlock".to_string(),
1730        body: Some(json!({ "user": user, "force": force })),
1731        timeout: Timeout::Default,
1732    }
1733}
1734
1735/// Parse a `/lock` family response. These endpoints return the raw lock
1736/// dict on 200 and `{"error": ..., "lock": {...}}` on contention
1737/// (409 acquire, 403 heartbeat/unlock, 404 heartbeat on an unlocked box).
1738pub fn parse_lock(status: u16, body: Value) -> Result<BoxLock> {
1739    if status == 200 {
1740        return serde_json::from_value(body)
1741            .map_err(|e| Error::Decode(format!("invalid lock state: {e}")));
1742    }
1743    let message = body
1744        .get("error")
1745        .and_then(Value::as_str)
1746        .unwrap_or("lock request failed")
1747        .to_string();
1748    Err(Error::Box { status, message })
1749}
1750
1751pub(crate) fn lock_unsupported() -> Error {
1752    Error::UnsupportedByBox {
1753        message: "this box does not serve the /lock endpoints on port 9000; \
1754                  update the box software"
1755            .to_string(),
1756    }
1757}
1758
1759// ---------------------------------------------------------------------------
1760// Per-net safety limits (PUT /nets/<name>/safety-limits, box >= 0.35.0)
1761// ---------------------------------------------------------------------------
1762
1763/// Voltage/current ceilings and the destructive-op switch enforced by the
1764/// box on a saved net.
1765///
1766/// A `PUT` **replaces** the net's whole limits record with the fields set
1767/// here: a `None` field is *removed* from the net, not preserved. Read the
1768/// current limits first (they ride along on `/nets/list` records as
1769/// [`NetRecord::safety_limits`]) if you mean to change one ceiling and keep
1770/// the others.
1771///
1772/// There is deliberately no `max_power`: one setter call establishes either
1773/// voltage or current, never both, so the box cannot evaluate a power
1774/// ceiling honestly and refuses the key rather than storing something
1775/// nothing enforces.
1776#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1777pub struct SafetyLimits {
1778    /// Voltage ceiling in volts. Must be positive; also caps inline `ovp=`
1779    /// trip settings, which would otherwise lift the instrument's own guard
1780    /// above the net ceiling.
1781    #[serde(default, skip_serializing_if = "Option::is_none")]
1782    pub max_voltage: Option<f64>,
1783    /// Current ceiling in amps. Must be positive; also caps inline `ocp=`.
1784    #[serde(default, skip_serializing_if = "Option::is_none")]
1785    pub max_current: Option<f64>,
1786    /// `Some(false)` makes the box refuse erase and flash on this net.
1787    /// Absent means allowed (the box treats a missing key as unrestricted).
1788    #[serde(default, skip_serializing_if = "Option::is_none")]
1789    pub allow_destructive: Option<bool>,
1790}
1791
1792impl SafetyLimits {
1793    /// True when no field is set — as a PUT body this clears the net's
1794    /// limits, returning it to unrestricted.
1795    pub fn is_empty(&self) -> bool {
1796        self.max_voltage.is_none() && self.max_current.is_none() && self.allow_destructive.is_none()
1797    }
1798}
1799
1800/// Build a `PUT /nets/<name>/safety-limits` request. An empty `limits`
1801/// serialises to `{}`, the box's documented "back to unrestricted" body.
1802pub fn safety_limits_set(name: &str, limits: &SafetyLimits) -> HttpRequest {
1803    HttpRequest {
1804        method: Method::Put,
1805        path: format!("/nets/{name}/safety-limits"),
1806        body: Some(serde_json::to_value(limits).unwrap_or_else(|_| json!({}))),
1807        timeout: Timeout::Default,
1808    }
1809}
1810
1811/// Parse a `PUT /nets/<name>/safety-limits` response into the applied
1812/// limits (`None` when the body cleared them). Validation refusals (unknown
1813/// key, `max_power`, non-positive ceiling) and a missing net come back as
1814/// [`Error::Box`] carrying the box's message.
1815pub fn parse_safety_limits(status: u16, body: Value) -> Result<Option<SafetyLimits>> {
1816    if status == 200 {
1817        return match body.get("safety_limits") {
1818            None | Some(Value::Null) => Ok(None),
1819            Some(limits) => serde_json::from_value(limits.clone())
1820                .map(Some)
1821                .map_err(|e| Error::Decode(format!("invalid safety_limits shape: {e}"))),
1822        };
1823    }
1824    let message = body
1825        .get("error")
1826        .and_then(Value::as_str)
1827        .unwrap_or("safety-limits request failed")
1828        .to_string();
1829    Err(Error::Box { status, message })
1830}
1831
1832pub(crate) fn safety_limits_unsupported() -> Error {
1833    Error::UnsupportedByBox {
1834        message: "this box does not serve PUT /nets/<name>/safety-limits \
1835                  (requires box software >= 0.35.0)"
1836            .to_string(),
1837    }
1838}
1839
1840// ---------------------------------------------------------------------------
1841// Live net state (GET /nets/state, box >= 0.34.0)
1842// ---------------------------------------------------------------------------
1843
1844/// Brief live state of one saved net, from `GET /nets/state`.
1845///
1846/// The box probes each physical instrument in parallel under a shared
1847/// whole-request budget (8s), so a slow, wedged or absent instrument comes
1848/// back with `state: None` and a [`NetState::reason`] instead of failing or
1849/// delaying the rest of the bench.
1850#[derive(Debug, Clone, Deserialize)]
1851#[non_exhaustive]
1852pub struct NetState {
1853    /// Net name.
1854    pub name: String,
1855    /// Net role (`"usb"`, `"power-supply"`, ...).
1856    #[serde(default)]
1857    pub role: String,
1858    /// Live state (e.g. `"enabled"`, `"3.300V"`), or `None` when the box
1859    /// could not read one — see [`NetState::reason`] for why.
1860    #[serde(default)]
1861    pub state: Option<String>,
1862    /// Attached only when `state` is `None`: `"deadline"` (the shared
1863    /// budget ran out before this net's instrument answered — not
1864    /// necessarily this instrument's fault), `"no probe for role"` (uart,
1865    /// spi, i2c, ... have no live-state probe), or `"unreadable: <detail>"`.
1866    #[serde(default)]
1867    pub reason: Option<String>,
1868    /// Stable machine-readable token alongside `reason`, when the box
1869    /// classified the fault: for example `"hub-skipped"`,
1870    /// `"instrument-absent"`, `"instrument-timeout"`, `"probe-cooldown"` or
1871    /// `"device-busy"` (box >= 0.58.0 for the last four). The human `reason`
1872    /// is always complete on its own.
1873    #[serde(default)]
1874    pub reason_code: Option<String>,
1875    /// Whether the net's output is on, for nets that have one (box >=
1876    /// 0.53.0; `None` from older boxes, for nets without an output, and
1877    /// when the box could not read it).
1878    #[serde(default, deserialize_with = "lenient::opt_bool")]
1879    pub enabled: Option<bool>,
1880    /// Any further keys a newer box attaches.
1881    #[serde(flatten)]
1882    pub extra: Map<String, Value>,
1883}
1884
1885/// Build a `GET /nets/state` request. The client timeout is widened past
1886/// the box's own 8s probe budget so a fully-consumed budget still yields
1887/// the (partial) answer rather than a client-side abort.
1888pub fn nets_state() -> HttpRequest {
1889    HttpRequest {
1890        method: Method::Get,
1891        path: "/nets/state".to_string(),
1892        body: None,
1893        timeout: Timeout::After(Duration::from_secs(15)),
1894    }
1895}
1896
1897/// Parse a `GET /nets/state` response (always 200 with one entry per saved
1898/// net on boxes that serve it).
1899pub fn parse_nets_state(status: u16, body: Value) -> Result<Vec<NetState>> {
1900    if status != 200 {
1901        let message = body
1902            .get("error")
1903            .and_then(Value::as_str)
1904            .unwrap_or("nets/state request failed")
1905            .to_string();
1906        return Err(Error::Box { status, message });
1907    }
1908    serde_json::from_value(body)
1909        .map_err(|e| Error::Decode(format!("invalid nets/state shape: {e}")))
1910}
1911
1912pub(crate) fn nets_state_unsupported() -> Error {
1913    Error::UnsupportedByBox {
1914        message: "this box does not serve GET /nets/state (requires box software >= 0.34.0)"
1915            .to_string(),
1916    }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921    use super::*;
1922
1923    #[test]
1924    fn parse_success_envelope() {
1925        let resp = parse_command(
1926            200,
1927            json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
1928        )
1929        .unwrap();
1930        assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
1931    }
1932
1933    #[test]
1934    fn parse_failure_with_http_200_is_box_error() {
1935        // Cross-role conflicts are reported as success=false with HTTP 200.
1936        let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
1937        match err {
1938            Error::Box { status, message } => {
1939                assert_eq!(status, 200);
1940                assert_eq!(message, "conflict");
1941            }
1942            other => panic!("unexpected error: {other:?}"),
1943        }
1944    }
1945
1946    #[test]
1947    fn parse_501_is_unsupported() {
1948        let err = parse_command(
1949            501,
1950            json!({"success": false, "error": "Role 'gpio' is not supported"}),
1951        )
1952        .unwrap_err();
1953        assert!(matches!(err, Error::UnsupportedByBox { .. }));
1954    }
1955
1956    #[test]
1957    fn base_url_normalization() {
1958        assert_eq!(
1959            base_url("192.168.1.42").unwrap(),
1960            "http://192.168.1.42:9000"
1961        );
1962        assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
1963        assert_eq!(
1964            base_url("192.168.1.42:8080").unwrap(),
1965            "http://192.168.1.42:8080"
1966        );
1967        assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
1968        assert!(base_url("").is_err());
1969        assert!(base_url("http://").is_err());
1970    }
1971
1972    #[test]
1973    fn lenient_numbers() {
1974        assert_eq!(as_f64(&json!("3.3")), Some(3.3));
1975        assert_eq!(as_f64(&json!(3.3)), Some(3.3));
1976        assert_eq!(as_bool(&json!("ON")), Some(true));
1977        assert_eq!(as_bool(&json!(0)), Some(false));
1978        assert_eq!(as_i64(&json!("42")), Some(42));
1979    }
1980
1981    #[test]
1982    fn supply_state_tolerates_nulls_and_strings() {
1983        let state: SupplyState = serde_json::from_value(json!({
1984            "netname": "supply1", "channel": "1", "error": null,
1985            "voltage": "3.3", "current": null, "enabled": 1,
1986        }))
1987        .unwrap();
1988        assert_eq!(state.channel, Some(1));
1989        assert_eq!(state.voltage, Some(3.3));
1990        assert_eq!(state.current, None);
1991        assert_eq!(state.enabled, Some(true));
1992    }
1993
1994    #[test]
1995    fn safety_limits_body_omits_unset_fields() {
1996        // The box refuses unknown keys and treats an explicit null as "do
1997        // not store this key", so unset fields must vanish from the body
1998        // entirely rather than ride along as nulls.
1999        let req = safety_limits_set(
2000            "supply1",
2001            &SafetyLimits {
2002                max_voltage: Some(5.0),
2003                ..Default::default()
2004            },
2005        );
2006        assert_eq!(req.method, Method::Put);
2007        assert_eq!(req.path, "/nets/supply1/safety-limits");
2008        assert_eq!(req.body, Some(json!({ "max_voltage": 5.0 })));
2009
2010        let clear = safety_limits_set("supply1", &SafetyLimits::default());
2011        assert_eq!(clear.body, Some(json!({})));
2012        assert!(SafetyLimits::default().is_empty());
2013    }
2014
2015    #[test]
2016    fn parse_safety_limits_roundtrip_and_clear() {
2017        let applied = parse_safety_limits(
2018            200,
2019            json!({"ok": true, "name": "supply1",
2020                   "safety_limits": {"max_voltage": 5.0, "allow_destructive": false}}),
2021        )
2022        .unwrap()
2023        .unwrap();
2024        assert_eq!(applied.max_voltage, Some(5.0));
2025        assert_eq!(applied.max_current, None);
2026        assert_eq!(applied.allow_destructive, Some(false));
2027
2028        // A clearing PUT echoes `safety_limits: null`.
2029        let cleared = parse_safety_limits(200, json!({"ok": true, "safety_limits": null})).unwrap();
2030        assert!(cleared.is_none());
2031    }
2032
2033    #[test]
2034    fn parse_safety_limits_surfaces_box_refusals() {
2035        let err = parse_safety_limits(400, json!({"error": "max_power is not supported: ..."}))
2036            .unwrap_err();
2037        match err {
2038            Error::Box { status, message } => {
2039                assert_eq!(status, 400);
2040                assert!(message.contains("max_power"));
2041            }
2042            other => panic!("unexpected error: {other:?}"),
2043        }
2044    }
2045
2046    #[test]
2047    fn net_record_carries_safety_limits() {
2048        let rec: NetRecord = serde_json::from_value(json!({
2049            "name": "supply1", "role": "power-supply",
2050            "safety_limits": {"max_voltage": 12.0, "max_current": 2.5},
2051        }))
2052        .unwrap();
2053        let limits = rec.safety_limits.unwrap();
2054        assert_eq!(limits.max_voltage, Some(12.0));
2055        assert_eq!(limits.max_current, Some(2.5));
2056        assert_eq!(limits.allow_destructive, None);
2057
2058        // Absent key means unrestricted, not an error.
2059        let rec: NetRecord =
2060            serde_json::from_value(json!({"name": "gpio1", "role": "gpio"})).unwrap();
2061        assert!(rec.safety_limits.is_none());
2062    }
2063
2064    #[test]
2065    fn capabilities_parse_safety_limits_flag() {
2066        let caps: BoxCapabilities =
2067            serde_json::from_value(json!({"netCommand": true, "safetyLimits": true})).unwrap();
2068        assert!(caps.safety_limits);
2069        // A box predating the flag omits the key, which reads as false.
2070        let caps: BoxCapabilities = serde_json::from_value(json!({"netCommand": true})).unwrap();
2071        assert!(!caps.safety_limits);
2072    }
2073}