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(Error::Box { status, message })
323}
324
325/// GDB-server sub-object of a [`DebugConnection`].
326#[derive(Debug, Clone, Deserialize)]
327pub struct GdbServer {
328    /// Server state, e.g. `"started"` or `"already_running"`.
329    #[serde(default)]
330    pub status: Option<String>,
331    /// GDB protocol port.
332    #[serde(default, deserialize_with = "lenient::opt_i64")]
333    pub gdb_port: Option<i64>,
334    /// SWO output port (J-Link only).
335    #[serde(default, deserialize_with = "lenient::opt_i64")]
336    pub swo_port: Option<i64>,
337    /// Telnet I/O port.
338    #[serde(default, deserialize_with = "lenient::opt_i64")]
339    pub telnet_port: Option<i64>,
340    /// TCL/RPC port (OpenOCD only).
341    #[serde(default, deserialize_with = "lenient::opt_i64")]
342    pub tcl_port: Option<i64>,
343    /// RTT telnet port.
344    #[serde(default, deserialize_with = "lenient::opt_i64")]
345    pub rtt_telnet_port: Option<i64>,
346    /// Server process id.
347    #[serde(default, deserialize_with = "lenient::opt_i64")]
348    pub pid: Option<i64>,
349}
350
351/// Result of a debug `connect`.
352#[derive(Debug, Clone, Deserialize)]
353pub struct DebugConnection {
354    /// Connection status, e.g. `"connected"`.
355    #[serde(default)]
356    pub status: Option<String>,
357    /// Resolved device/target type.
358    #[serde(default)]
359    pub device: Option<String>,
360    /// Probe instrument name.
361    #[serde(default)]
362    pub probe: Option<String>,
363    /// Probe serial.
364    #[serde(default)]
365    pub serial: Option<String>,
366    /// Debug backend that started (`"jlink"` or `"openocd"`).
367    #[serde(default)]
368    pub backend: Option<String>,
369    /// Human-readable message.
370    #[serde(default)]
371    pub message: Option<String>,
372    /// Backend process id.
373    #[serde(default, deserialize_with = "lenient::opt_i64")]
374    pub pid: Option<i64>,
375    /// GDB-server details, if a server was started.
376    #[serde(default)]
377    pub gdb_server: Option<GdbServer>,
378}
379
380/// Result of a debug `info` query.
381#[derive(Debug, Clone, Deserialize)]
382pub struct DebugInfo {
383    /// Net name.
384    #[serde(default)]
385    pub net_name: Option<String>,
386    /// Resolved device/target type.
387    #[serde(default)]
388    pub device: Option<String>,
389    /// Target architecture.
390    #[serde(default)]
391    pub arch: Option<String>,
392    /// Probe instrument name.
393    #[serde(default)]
394    pub probe: Option<String>,
395    /// Probe serial.
396    #[serde(default)]
397    pub serial: Option<String>,
398    /// Debug backend (`"jlink"` or `"openocd"`).
399    #[serde(default)]
400    pub backend: Option<String>,
401    /// Whether a gdbserver/daemon is currently running for this probe.
402    #[serde(default)]
403    pub connected: bool,
404}
405
406/// Result of a debug `status` query.
407#[derive(Debug, Clone, Deserialize)]
408pub struct DebugStatus {
409    /// Whether a gdbserver/daemon is currently running for this probe.
410    #[serde(default)]
411    pub connected: bool,
412    /// Backend process id, when connected.
413    #[serde(default, deserialize_with = "lenient::opt_i64")]
414    pub pid: Option<i64>,
415    /// Probe serial.
416    #[serde(default)]
417    pub serial: Option<String>,
418    /// Debug backend (`"jlink"` or `"openocd"`).
419    #[serde(default)]
420    pub backend: Option<String>,
421}
422
423/// Extract the hex `data` field from a `/debug/memrd` response into bytes.
424pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
425    let hex = body
426        .get("data")
427        .and_then(Value::as_str)
428        .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
429    decode_hex(hex)
430}
431
432/// Decode a hex string into bytes.
433pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
434    let s = s.trim();
435    if s.len() % 2 != 0 {
436        return Err(Error::Decode("odd-length hex string".to_string()));
437    }
438    (0..s.len())
439        .step_by(2)
440        .map(|i| {
441            u8::from_str_radix(&s[i..i + 2], 16)
442                .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
443        })
444        .collect()
445}
446
447// ---------------------------------------------------------------------------
448// Lenient deserialization helpers
449// ---------------------------------------------------------------------------
450//
451// Instrument drivers occasionally hand numbers back as strings (SCPI query
452// results). These helpers accept a JSON number, a numeric string, or null.
453
454pub(crate) fn as_f64(v: &Value) -> Option<f64> {
455    match v {
456        Value::Number(n) => n.as_f64(),
457        Value::String(s) => s.trim().parse().ok(),
458        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
459        _ => None,
460    }
461}
462
463pub(crate) fn as_i64(v: &Value) -> Option<i64> {
464    match v {
465        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
466        Value::String(s) => s
467            .trim()
468            .parse::<i64>()
469            .ok()
470            .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
471        Value::Bool(b) => Some(i64::from(*b)),
472        _ => None,
473    }
474}
475
476pub(crate) fn as_bool(v: &Value) -> Option<bool> {
477    match v {
478        Value::Bool(b) => Some(*b),
479        Value::Number(n) => n.as_f64().map(|f| f != 0.0),
480        Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
481            "true" | "on" | "1" | "yes" | "enabled" => Some(true),
482            "false" | "off" | "0" | "no" | "disabled" => Some(false),
483            _ => None,
484        },
485        _ => None,
486    }
487}
488
489pub(crate) mod lenient {
490    //! `deserialize_with` adapters for fields that may arrive as numbers,
491    //! numeric strings, or null.
492
493    use serde::{Deserialize, Deserializer};
494    use serde_json::Value;
495
496    pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
497        let v = Option::<Value>::deserialize(d)?;
498        Ok(v.as_ref().and_then(super::as_f64))
499    }
500
501    pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
502        let v = Option::<Value>::deserialize(d)?;
503        Ok(v.as_ref().and_then(super::as_i64))
504    }
505
506    pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
507        let v = Option::<Value>::deserialize(d)?;
508        Ok(v.as_ref().and_then(super::as_bool))
509    }
510}
511
512// ---------------------------------------------------------------------------
513// Typed extraction from the envelope
514// ---------------------------------------------------------------------------
515
516/// Extract the `value` field as an `f64` (accepting numeric strings).
517pub fn value_f64(resp: CommandResponse) -> Result<f64> {
518    resp.value
519        .as_ref()
520        .and_then(as_f64)
521        .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
522}
523
524/// Extract the `value` field as an `i64` (accepting numeric strings).
525pub fn value_i64(resp: CommandResponse) -> Result<i64> {
526    resp.value
527        .as_ref()
528        .and_then(as_i64)
529        .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
530}
531
532/// Extract the `value` field as a list of integers (I2C bytes/addresses,
533/// SPI words).
534pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
535    let arr = resp
536        .value
537        .as_ref()
538        .and_then(Value::as_array)
539        .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
540    arr.iter()
541        .map(|v| {
542            as_i64(v)
543                .and_then(|n| u32::try_from(n).ok())
544                .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
545        })
546        .collect()
547}
548
549/// Deserialize the `value` field into a typed struct.
550pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
551    let v = resp
552        .value
553        .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
554    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
555}
556
557/// Deserialize the `state` field into a typed struct.
558pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
559    let v = resp
560        .state
561        .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
562    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
563}
564
565/// Interpret a nets-list body into raw JSON values. `/nets/list` returns a
566/// bare array; the older `/uart/nets/list` wraps it in `{"nets": [...]}`.
567pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
568    match body {
569        Value::Array(a) => a,
570        Value::Object(mut o) => match o.remove("nets") {
571            Some(Value::Array(a)) => a,
572            _ => vec![],
573        },
574        _ => vec![],
575    }
576}
577
578/// Interpret a nets-list body into typed [`NetRecord`]s.
579pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
580    let list = Value::Array(nets_list_values(body));
581    serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
582}
583
584/// Discard the payload, keeping only success/failure.
585pub fn unit(_resp: CommandResponse) -> Result<()> {
586    Ok(())
587}
588
589/// Keep the whole envelope (for callers that want `message` etc.).
590pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
591    Ok(resp)
592}
593
594// ---------------------------------------------------------------------------
595// Shared response types
596// ---------------------------------------------------------------------------
597
598/// One saved-net record from `GET /nets/list` (the box's `saved_nets.json`).
599#[derive(Debug, Clone, Deserialize)]
600pub struct NetRecord {
601    /// Net name, e.g. `"supply1"`.
602    #[serde(default)]
603    pub name: String,
604    /// Role string, e.g. `"power-supply"`, `"gpio"`, `"adc"`.
605    #[serde(default)]
606    pub role: String,
607    /// Instrument backing this net, e.g. `"Rigol DP832"`, `"LabJack T7"`.
608    #[serde(default)]
609    pub instrument: Option<String>,
610    /// Pin / channel identifier (number or string depending on role).
611    #[serde(default)]
612    pub pin: Option<Value>,
613    /// Channel (multi-channel instruments).
614    #[serde(default)]
615    pub channel: Option<Value>,
616    /// VISA or device address.
617    #[serde(default)]
618    pub address: Option<String>,
619    /// Role-specific saved parameters (SPI mode, UART baudrate, ...).
620    #[serde(default)]
621    pub params: Option<Map<String, Value>>,
622    /// Safety ceilings the box enforces on this net (box >= 0.35.0).
623    /// `None` means unrestricted.
624    #[serde(default)]
625    pub safety_limits: Option<SafetyLimits>,
626    /// Everything else in the record (mappings, scope_points, ...).
627    #[serde(flatten)]
628    pub extra: Map<String, Value>,
629}
630
631/// Response of `GET /health`.
632#[derive(Debug, Clone, Deserialize)]
633pub struct Health {
634    /// `"healthy"` when the box HTTP server is up.
635    #[serde(default)]
636    pub status: String,
637    /// Service identifier.
638    #[serde(default)]
639    pub service: Option<String>,
640    /// Service version string.
641    #[serde(default)]
642    pub version: Option<String>,
643}
644
645/// One net summary inside [`BoxStatus`].
646#[derive(Debug, Clone, Deserialize)]
647pub struct NetSummary {
648    /// Net name.
649    #[serde(default)]
650    pub name: String,
651    /// Net type name (e.g. `"PowerSupply"`, `"GPIO"`).
652    #[serde(default, rename = "type")]
653    pub net_type: String,
654}
655
656/// Capabilities advertised by the box in `GET /status`.
657#[derive(Debug, Clone, Default, Deserialize)]
658pub struct BoxCapabilities {
659    /// Whether the box serves `POST /net/command` (Tier-1 nets over HTTP).
660    /// When false the box software predates this crate's contract.
661    #[serde(default, rename = "netCommand")]
662    pub net_command: bool,
663    /// Roles served by `POST /net/command` on this box. Empty on box images
664    /// that predate role advertising (which still serve the original Tier-1
665    /// roles when `net_command` is true).
666    #[serde(default, rename = "netCommandRoles")]
667    pub net_command_roles: Vec<String>,
668    /// Whether the box serves `POST /ble/command`.
669    #[serde(default, rename = "bleCommand")]
670    pub ble_command: bool,
671    /// Whether the box serves `POST /wifi/command`.
672    #[serde(default, rename = "wifiCommand")]
673    pub wifi_command: bool,
674    /// Whether the box serves `POST /blufi/command`.
675    #[serde(default, rename = "blufiCommand")]
676    pub blufi_command: bool,
677    /// Whether the box serves the `/custom-devices/*` endpoints (the
678    /// `lager nets assign` backend). Not used by this crate; surfaced for
679    /// callers probing box features.
680    #[serde(default, rename = "customDevices")]
681    pub custom_devices: bool,
682    /// Whether the box serves `/binaries/*` and `/download-file`. Not used
683    /// by this crate; surfaced for callers probing box features.
684    #[serde(default)]
685    pub binaries: bool,
686    /// Whether the box serves `PUT /nets/<name>/safety-limits` (box >=
687    /// 0.35.0). The flag mirrors route registration rather than the version
688    /// string, so a box whose nets handler failed to import reads `false`
689    /// here even if its version says otherwise.
690    #[serde(default, rename = "safetyLimits")]
691    pub safety_limits: bool,
692}
693
694/// Response of `GET /status`.
695#[derive(Debug, Clone, Deserialize)]
696pub struct BoxStatus {
697    /// Whether the box reports itself healthy.
698    #[serde(default)]
699    pub healthy: bool,
700    /// Box software version.
701    #[serde(default)]
702    pub version: String,
703    /// Configured nets (name + type only; use `nets()` for full records).
704    #[serde(default)]
705    pub nets: Vec<NetSummary>,
706    /// Endpoint capabilities.
707    #[serde(default)]
708    pub capabilities: BoxCapabilities,
709}
710
711/// Structured power-supply state from the `state` action on
712/// `POST /supply/command`. Fields are `None` when the individual read failed
713/// (the box returns a full-shaped dict even on a degraded read; `error`
714/// carries the reason).
715#[derive(Debug, Clone, Deserialize)]
716pub struct SupplyState {
717    /// Net name echo.
718    #[serde(default)]
719    pub netname: Option<String>,
720    /// Instrument channel driving this net.
721    #[serde(default, deserialize_with = "lenient::opt_i64")]
722    pub channel: Option<i64>,
723    /// Transport-level error when the whole state gather failed.
724    #[serde(default)]
725    pub error: Option<String>,
726    /// Measured output voltage (V).
727    #[serde(default, deserialize_with = "lenient::opt_f64")]
728    pub voltage: Option<f64>,
729    /// Measured output current (A).
730    #[serde(default, deserialize_with = "lenient::opt_f64")]
731    pub current: Option<f64>,
732    /// Measured output power (W).
733    #[serde(default, deserialize_with = "lenient::opt_f64")]
734    pub power: Option<f64>,
735    /// Whether the output is enabled.
736    #[serde(default, deserialize_with = "lenient::opt_bool")]
737    pub enabled: Option<bool>,
738    /// Operating mode (e.g. `"CV"`, `"CC"`).
739    #[serde(default)]
740    pub mode: Option<String>,
741    /// Voltage setpoint (V).
742    #[serde(default, deserialize_with = "lenient::opt_f64")]
743    pub voltage_set: Option<f64>,
744    /// Current limit setpoint (A).
745    #[serde(default, deserialize_with = "lenient::opt_f64")]
746    pub current_set: Option<f64>,
747    /// Hardware maximum voltage (V).
748    #[serde(default, deserialize_with = "lenient::opt_f64")]
749    pub voltage_max: Option<f64>,
750    /// Hardware maximum current (A).
751    #[serde(default, deserialize_with = "lenient::opt_f64")]
752    pub current_max: Option<f64>,
753    /// Over-current protection limit (A).
754    #[serde(default, deserialize_with = "lenient::opt_f64")]
755    pub ocp_limit: Option<f64>,
756    /// Whether OCP has tripped.
757    #[serde(default, deserialize_with = "lenient::opt_bool")]
758    pub ocp_tripped: Option<bool>,
759    /// Over-voltage protection limit (V).
760    #[serde(default, deserialize_with = "lenient::opt_f64")]
761    pub ovp_limit: Option<f64>,
762    /// Whether OVP has tripped.
763    #[serde(default, deserialize_with = "lenient::opt_bool")]
764    pub ovp_tripped: Option<bool>,
765}
766
767/// Structured battery-simulator state from the `state` action on
768/// `POST /battery/command`. Fields are `None` when the individual read
769/// failed.
770#[derive(Debug, Clone, Deserialize)]
771pub struct BatteryState {
772    /// Net name echo.
773    #[serde(default)]
774    pub netname: Option<String>,
775    /// Instrument channel driving this net.
776    #[serde(default, deserialize_with = "lenient::opt_i64")]
777    pub channel: Option<i64>,
778    /// Transport-level error when the whole state gather failed.
779    #[serde(default)]
780    pub error: Option<String>,
781    /// Simulated terminal voltage (V).
782    #[serde(default, deserialize_with = "lenient::opt_f64")]
783    pub terminal_voltage: Option<f64>,
784    /// Measured current (A).
785    #[serde(default, deserialize_with = "lenient::opt_f64")]
786    pub current: Option<f64>,
787    /// Equivalent series resistance (ohm).
788    #[serde(default, deserialize_with = "lenient::opt_f64")]
789    pub esr: Option<f64>,
790    /// State of charge (%).
791    #[serde(default, deserialize_with = "lenient::opt_f64")]
792    pub soc: Option<f64>,
793    /// Open-circuit voltage (V).
794    #[serde(default, deserialize_with = "lenient::opt_f64")]
795    pub voc: Option<f64>,
796    /// Whether the simulator output is enabled.
797    #[serde(default, deserialize_with = "lenient::opt_bool")]
798    pub enabled: Option<bool>,
799    /// Simulation mode (`"static"` / `"dynamic"`).
800    #[serde(default)]
801    pub mode: Option<String>,
802    /// Battery model / part number.
803    #[serde(default)]
804    pub model: Option<String>,
805    /// Battery capacity (Ah).
806    #[serde(default, deserialize_with = "lenient::opt_f64")]
807    pub capacity: Option<f64>,
808    /// Current limit (A).
809    #[serde(default, deserialize_with = "lenient::opt_f64")]
810    pub current_limit: Option<f64>,
811    /// Over-current protection limit (A).
812    #[serde(default, deserialize_with = "lenient::opt_f64")]
813    pub ocp_limit: Option<f64>,
814    /// Over-voltage protection limit (V).
815    #[serde(default, deserialize_with = "lenient::opt_f64")]
816    pub ovp_limit: Option<f64>,
817    /// Voltage considered "full" (V).
818    #[serde(default, deserialize_with = "lenient::opt_f64")]
819    pub volt_full: Option<f64>,
820    /// Voltage considered "empty" (V).
821    #[serde(default, deserialize_with = "lenient::opt_f64")]
822    pub volt_empty: Option<f64>,
823    /// Whether OCP has tripped.
824    #[serde(default, deserialize_with = "lenient::opt_bool")]
825    pub ocp_tripped: Option<bool>,
826    /// Whether OVP has tripped.
827    #[serde(default, deserialize_with = "lenient::opt_bool")]
828    pub ovp_tripped: Option<bool>,
829}
830
831/// Structured e-load state from the `state` action on `POST /net/command`.
832#[derive(Debug, Clone, Deserialize)]
833pub struct EloadState {
834    /// Active mode (`"cc"`, `"cv"`, `"cr"`, `"cp"`).
835    #[serde(default)]
836    pub mode: Option<String>,
837    /// Whether the load input is enabled.
838    #[serde(default, deserialize_with = "lenient::opt_bool")]
839    pub input_enabled: Option<bool>,
840    /// Measured voltage (V).
841    #[serde(default, deserialize_with = "lenient::opt_f64")]
842    pub measured_voltage: Option<f64>,
843    /// Measured current (A).
844    #[serde(default, deserialize_with = "lenient::opt_f64")]
845    pub measured_current: Option<f64>,
846    /// Measured power (W).
847    #[serde(default, deserialize_with = "lenient::opt_f64")]
848    pub measured_power: Option<f64>,
849    /// Any extra driver-specific fields.
850    #[serde(flatten)]
851    pub extra: Map<String, Value>,
852}
853
854/// Combined current/voltage/power reading from a watt-meter `all` action.
855#[derive(Debug, Clone, Deserialize)]
856pub struct WattReading {
857    /// Mean current over the window (A).
858    #[serde(default, deserialize_with = "lenient::opt_f64")]
859    pub current: Option<f64>,
860    /// Mean voltage over the window (V).
861    #[serde(default, deserialize_with = "lenient::opt_f64")]
862    pub voltage: Option<f64>,
863    /// Mean power over the window (W).
864    #[serde(default, deserialize_with = "lenient::opt_f64")]
865    pub power: Option<f64>,
866    /// Measurement window (s).
867    #[serde(default, deserialize_with = "lenient::opt_f64")]
868    pub duration_s: Option<f64>,
869}
870
871/// Integrated energy reading from an energy-analyzer `read_energy` action.
872#[derive(Debug, Clone, Deserialize)]
873pub struct EnergyReading {
874    /// Integrated energy (J).
875    #[serde(default, deserialize_with = "lenient::opt_f64")]
876    pub energy_j: Option<f64>,
877    /// Integrated charge (C).
878    #[serde(default, deserialize_with = "lenient::opt_f64")]
879    pub charge_c: Option<f64>,
880    /// Actual integration window (s).
881    #[serde(default, deserialize_with = "lenient::opt_f64")]
882    pub duration_s: Option<f64>,
883    /// Any extra analyzer-specific fields.
884    #[serde(flatten)]
885    pub extra: Map<String, Value>,
886}
887
888/// Statistical summary of one signal inside [`EnergyStats`].
889#[derive(Debug, Clone, Default, Deserialize)]
890pub struct StatSummary {
891    /// Mean value.
892    #[serde(default, deserialize_with = "lenient::opt_f64")]
893    pub mean: Option<f64>,
894    /// Minimum value.
895    #[serde(default, deserialize_with = "lenient::opt_f64")]
896    pub min: Option<f64>,
897    /// Maximum value.
898    #[serde(default, deserialize_with = "lenient::opt_f64")]
899    pub max: Option<f64>,
900    /// Standard deviation.
901    #[serde(default, deserialize_with = "lenient::opt_f64")]
902    pub std: Option<f64>,
903    /// Any extra analyzer-specific fields.
904    #[serde(flatten)]
905    pub extra: Map<String, Value>,
906}
907
908/// Statistics from an energy-analyzer `read_stats` action.
909#[derive(Debug, Clone, Deserialize)]
910pub struct EnergyStats {
911    /// Current statistics (A).
912    #[serde(default)]
913    pub current: Option<StatSummary>,
914    /// Voltage statistics (V).
915    #[serde(default)]
916    pub voltage: Option<StatSummary>,
917    /// Power statistics (W).
918    #[serde(default)]
919    pub power: Option<StatSummary>,
920    /// Any extra analyzer-specific fields.
921    #[serde(flatten)]
922    pub extra: Map<String, Value>,
923}
924
925// ---------------------------------------------------------------------------
926// Arm / webcam / router / BLE / WiFi / BluFi response types
927// ---------------------------------------------------------------------------
928
929/// Cartesian position of a robot arm's end effector (mm), from the `value:
930/// [x, y, z]` list the arm actions return.
931#[derive(Debug, Clone, Copy, PartialEq)]
932pub struct ArmPosition {
933    /// X coordinate (mm).
934    pub x: f64,
935    /// Y coordinate (mm).
936    pub y: f64,
937    /// Z coordinate (mm).
938    pub z: f64,
939}
940
941/// Parse an arm response's `value: [x, y, z]` into an [`ArmPosition`].
942pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
943    let arr = resp
944        .value
945        .as_ref()
946        .and_then(Value::as_array)
947        .ok_or_else(|| Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value)))?;
948    match arr.as_slice() {
949        [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
950            (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
951            _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
952        },
953        _ => Err(Error::Decode(format!(
954            "expected 3-element arm position, got {} elements",
955            arr.len()
956        ))),
957    }
958}
959
960/// Result of starting a webcam stream (`start` action).
961#[derive(Debug, Clone, Deserialize)]
962pub struct WebcamStream {
963    /// MJPEG stream URL viewers should open.
964    #[serde(default)]
965    pub url: String,
966    /// TCP port the stream is served on.
967    #[serde(default, deserialize_with = "lenient::opt_i64")]
968    pub port: Option<i64>,
969    /// `true` when a stream was already up and the box reused it.
970    #[serde(default)]
971    pub already_running: bool,
972}
973
974/// Current state of a webcam stream (`status`/`url` actions).
975#[derive(Debug, Clone, Deserialize)]
976pub struct WebcamStatus {
977    /// Whether a stream is currently running for this net.
978    #[serde(default)]
979    pub running: bool,
980    /// Stream URL, when running.
981    #[serde(default)]
982    pub url: Option<String>,
983    /// Stream TCP port, when running.
984    #[serde(default, deserialize_with = "lenient::opt_i64")]
985    pub port: Option<i64>,
986    /// Backing video device (e.g. `/dev/video0`), when running.
987    #[serde(default)]
988    pub video_device: Option<String>,
989}
990
991/// System information for a router net (`system_info` action).
992#[derive(Debug, Clone, Deserialize)]
993pub struct RouterSystemInfo {
994    /// Router identity name.
995    #[serde(default)]
996    pub name: Option<String>,
997    /// RouterOS version.
998    #[serde(default)]
999    pub version: Option<String>,
1000    /// Board model, e.g. `"hAP ac^2"`.
1001    #[serde(default)]
1002    pub board: Option<String>,
1003    /// CPU architecture.
1004    #[serde(default)]
1005    pub architecture: Option<String>,
1006    /// Uptime string, e.g. `"1w2d3h"`.
1007    #[serde(default)]
1008    pub uptime: Option<String>,
1009    /// CPU load (%).
1010    #[serde(default, deserialize_with = "lenient::opt_i64")]
1011    pub cpu_load: Option<i64>,
1012    /// Free RAM (bytes).
1013    #[serde(default, deserialize_with = "lenient::opt_i64")]
1014    pub free_memory: Option<i64>,
1015    /// Total RAM (bytes).
1016    #[serde(default, deserialize_with = "lenient::opt_i64")]
1017    pub total_memory: Option<i64>,
1018    /// Free storage (bytes).
1019    #[serde(default, deserialize_with = "lenient::opt_i64")]
1020    pub free_hdd_space: Option<i64>,
1021    /// Any extra fields.
1022    #[serde(flatten)]
1023    pub extra: Map<String, Value>,
1024}
1025
1026/// One device found by a BLE or BluFi scan.
1027#[derive(Debug, Clone, Deserialize)]
1028pub struct BleDevice {
1029    /// Advertised name (falls back to the address when unnamed).
1030    #[serde(default)]
1031    pub name: String,
1032    /// BLE MAC address, `XX:XX:XX:XX:XX:XX`.
1033    #[serde(default)]
1034    pub address: String,
1035    /// Signal strength (dBm).
1036    #[serde(default, deserialize_with = "lenient::opt_i64")]
1037    pub rssi: Option<i64>,
1038    /// Advertised service UUIDs.
1039    #[serde(default)]
1040    pub uuids: Vec<String>,
1041}
1042
1043/// One GATT characteristic inside a [`BleService`].
1044#[derive(Debug, Clone, Deserialize)]
1045pub struct BleCharacteristic {
1046    /// Characteristic UUID.
1047    #[serde(default)]
1048    pub uuid: String,
1049    /// Human-readable description, when known.
1050    #[serde(default)]
1051    pub description: Option<String>,
1052    /// Supported operations, e.g. `["read", "notify"]`.
1053    #[serde(default)]
1054    pub properties: Vec<String>,
1055}
1056
1057/// One GATT service enumerated from a connected BLE device.
1058#[derive(Debug, Clone, Deserialize)]
1059pub struct BleService {
1060    /// Service UUID.
1061    #[serde(default)]
1062    pub uuid: String,
1063    /// Human-readable description, when known.
1064    #[serde(default)]
1065    pub description: Option<String>,
1066    /// Characteristics under this service.
1067    #[serde(default)]
1068    pub characteristics: Vec<BleCharacteristic>,
1069}
1070
1071/// Result of a BLE `info`/`connect`: the device's GATT database.
1072#[derive(Debug, Clone, Deserialize)]
1073pub struct BleDeviceInfo {
1074    /// Device address.
1075    #[serde(default)]
1076    pub address: String,
1077    /// Whether the box reached the device.
1078    #[serde(default)]
1079    pub connected: bool,
1080    /// Enumerated GATT services.
1081    #[serde(default)]
1082    pub services: Vec<BleService>,
1083}
1084
1085/// Status of one wireless interface on the box (`wifi status`).
1086#[derive(Debug, Clone, Deserialize)]
1087pub struct WifiInterface {
1088    /// Interface name, e.g. `"wlan0"`.
1089    #[serde(default)]
1090    pub interface: String,
1091    /// Connected SSID, or a placeholder like `"Not Connected"`.
1092    #[serde(default)]
1093    pub ssid: String,
1094    /// Connection state, e.g. `"Connected"` / `"Disconnected"`.
1095    #[serde(default)]
1096    pub state: String,
1097}
1098
1099/// One access point found by a box-side WiFi scan.
1100#[derive(Debug, Clone, Deserialize)]
1101pub struct WifiAccessPoint {
1102    /// Network SSID (`"Hidden"` for hidden networks).
1103    #[serde(default)]
1104    pub ssid: Option<String>,
1105    /// BSSID / AP MAC address.
1106    #[serde(default)]
1107    pub address: Option<String>,
1108    /// Signal strength (approximate %, 0-100).
1109    #[serde(default, deserialize_with = "lenient::opt_i64")]
1110    pub strength: Option<i64>,
1111    /// `"Open"` or `"Secured"`.
1112    #[serde(default)]
1113    pub security: Option<String>,
1114}
1115
1116/// Result of connecting the box to a WiFi network.
1117#[derive(Debug, Clone, Deserialize)]
1118pub struct WifiConnection {
1119    /// SSID the box joined.
1120    #[serde(default)]
1121    pub ssid: String,
1122    /// Whether the connection succeeded (always true in a success envelope).
1123    #[serde(default)]
1124    pub connected: bool,
1125    /// Interface used, e.g. `"wlan0"`.
1126    #[serde(default)]
1127    pub interface: Option<String>,
1128    /// Connection method, e.g. `"nmcli"` or `"wpa_supplicant"`.
1129    #[serde(default)]
1130    pub method: Option<String>,
1131}
1132
1133/// WiFi state reported by a BluFi target device (`status`, and embedded in
1134/// `connect`). Codes follow the ESP32 BluFi protocol.
1135#[derive(Debug, Clone, Deserialize)]
1136pub struct BlufiStatus {
1137    /// Target device name echo.
1138    #[serde(default)]
1139    pub device_name: Option<String>,
1140    /// Operation mode code (0 NULL, 1 STA, 2 SoftAP, 3 STA+SoftAP).
1141    #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
1142    pub op_mode: Option<i64>,
1143    /// Human-readable operation mode.
1144    #[serde(default, rename = "opModeName")]
1145    pub op_mode_name: Option<String>,
1146    /// Station connection code (0 connected, 1 failed, 2 connecting, 3 no IP).
1147    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1148    pub sta_conn: Option<i64>,
1149    /// Human-readable station connection state.
1150    #[serde(default, rename = "staConnName")]
1151    pub sta_conn_name: Option<String>,
1152    /// SoftAP connection count/state code.
1153    #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
1154    pub soft_ap_conn: Option<i64>,
1155}
1156
1157/// Result of a BluFi `connect`: firmware version plus WiFi state.
1158#[derive(Debug, Clone, Deserialize)]
1159pub struct BlufiDeviceInfo {
1160    /// Target firmware version, when the device reports one.
1161    #[serde(default)]
1162    pub version: Option<String>,
1163    /// WiFi state of the target.
1164    #[serde(flatten)]
1165    pub status: BlufiStatus,
1166}
1167
1168/// Result of a BluFi `provision`.
1169#[derive(Debug, Clone, Deserialize)]
1170pub struct BlufiProvisionResult {
1171    /// Target device name echo.
1172    #[serde(default)]
1173    pub device_name: Option<String>,
1174    /// SSID the target was provisioned onto.
1175    #[serde(default)]
1176    pub ssid: String,
1177    /// Station connection code after provisioning (0 = connected).
1178    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1179    pub sta_conn: Option<i64>,
1180    /// Human-readable station connection state.
1181    #[serde(default, rename = "staConnName")]
1182    pub sta_conn_name: Option<String>,
1183}
1184
1185/// One network seen by a BluFi target's own WiFi scan (`wifi_scan`).
1186#[derive(Debug, Clone, Deserialize)]
1187pub struct BlufiNetwork {
1188    /// Network SSID.
1189    #[serde(default)]
1190    pub ssid: String,
1191    /// Signal strength at the target (dBm).
1192    #[serde(default, deserialize_with = "lenient::opt_i64")]
1193    pub rssi: Option<i64>,
1194}
1195
1196/// Extract a named list field out of the `value` object (e.g. `devices`
1197/// from a BLE scan, `access_points` from a WiFi scan).
1198pub(crate) fn value_list_field<T: DeserializeOwned>(
1199    resp: CommandResponse,
1200    field: &str,
1201) -> Result<Vec<T>> {
1202    let list = resp
1203        .value
1204        .as_ref()
1205        .and_then(|v| v.get(field))
1206        .cloned()
1207        .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
1208    serde_json::from_value(list)
1209        .map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
1210}
1211
1212// ---------------------------------------------------------------------------
1213// USB bus enumeration (GET /usb/devices)
1214// ---------------------------------------------------------------------------
1215
1216/// Optional filters for [`crate::LagerBox::usb_devices_matching`], applied
1217/// box-side. `vid`/`pid` are hex strings (with or without `0x`); `serial`
1218/// is an exact iSerial match. An empty filter returns every device.
1219#[derive(Debug, Clone, Default)]
1220pub struct UsbDeviceFilter {
1221    /// USB vendor id, hex (e.g. `"0483"`).
1222    pub vid: Option<String>,
1223    /// USB product id, hex (e.g. `"df11"`).
1224    pub pid: Option<String>,
1225    /// Exact iSerial string.
1226    pub serial: Option<String>,
1227}
1228
1229/// One USB device on the box's bus, read from sysfs by `GET /usb/devices`.
1230/// String fields are `None` when the device does not expose the descriptor
1231/// (e.g. `serial` on devices with no iSerial).
1232#[derive(Debug, Clone, Deserialize)]
1233pub struct UsbDeviceInfo {
1234    /// sysfs entry name, e.g. `"1-1.4"`.
1235    #[serde(default)]
1236    pub sysfs_name: String,
1237    /// Vendor id, lowercase hex (e.g. `"0483"`).
1238    #[serde(default)]
1239    pub vid: Option<String>,
1240    /// Product id, lowercase hex (e.g. `"df11"`).
1241    #[serde(default)]
1242    pub pid: Option<String>,
1243    /// iSerial descriptor.
1244    #[serde(default)]
1245    pub serial: Option<String>,
1246    /// Product descriptor string.
1247    #[serde(default)]
1248    pub product: Option<String>,
1249    /// Manufacturer descriptor string.
1250    #[serde(default)]
1251    pub manufacturer: Option<String>,
1252    /// Bus number.
1253    #[serde(default)]
1254    pub busnum: Option<String>,
1255    /// Device number on the bus (changes on re-enumeration).
1256    #[serde(default)]
1257    pub devnum: Option<String>,
1258    /// Hub port path, e.g. `"1.4"`.
1259    #[serde(default)]
1260    pub devpath: Option<String>,
1261    /// bDeviceClass, hex.
1262    #[serde(default)]
1263    pub device_class: Option<String>,
1264    /// Negotiated speed in Mbps (`"1.5"`, `"12"`, `"480"`, ...).
1265    #[serde(default)]
1266    pub speed: Option<String>,
1267}
1268
1269/// Percent-encode one query-string value (RFC 3986 unreserved set).
1270fn encode_query_value(s: &str) -> String {
1271    let mut out = String::with_capacity(s.len());
1272    for b in s.bytes() {
1273        match b {
1274            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1275                out.push(b as char)
1276            }
1277            _ => out.push_str(&format!("%{b:02X}")),
1278        }
1279    }
1280    out
1281}
1282
1283/// Build a `GET /usb/devices` request with optional box-side filters.
1284pub fn usb_devices(filter: &UsbDeviceFilter) -> HttpRequest {
1285    let mut query = Vec::new();
1286    for (key, value) in [
1287        ("vid", &filter.vid),
1288        ("pid", &filter.pid),
1289        ("serial", &filter.serial),
1290    ] {
1291        if let Some(value) = value {
1292            query.push(format!("{key}={}", encode_query_value(value)));
1293        }
1294    }
1295    let path = if query.is_empty() {
1296        "/usb/devices".to_string()
1297    } else {
1298        format!("/usb/devices?{}", query.join("&"))
1299    };
1300    HttpRequest {
1301        method: Method::Get,
1302        path,
1303        body: None,
1304        timeout: Timeout::Default,
1305    }
1306}
1307
1308/// Parse a `GET /usb/devices` response into device records.
1309pub fn parse_usb_devices(status: u16, body: Value) -> Result<Vec<UsbDeviceInfo>> {
1310    if status == 404 {
1311        return Err(usb_devices_unsupported());
1312    }
1313    let resp = parse_command(status, body)?;
1314    let devices = resp
1315        .extra
1316        .get("devices")
1317        .cloned()
1318        .ok_or_else(|| Error::Decode("response has no 'devices' list".to_string()))?;
1319    serde_json::from_value(devices)
1320        .map_err(|e| Error::Decode(format!("invalid 'devices' shape: {e}")))
1321}
1322
1323pub(crate) fn usb_devices_unsupported() -> Error {
1324    Error::UnsupportedByBox {
1325        message: "this box does not serve GET /usb/devices (requires box software >= 0.33.0)"
1326            .to_string(),
1327    }
1328}
1329
1330/// Map a route-missing 404 (Flask's non-JSON default page) to
1331/// [`Error::UnsupportedByBox`] with an endpoint-specific message. Real box
1332/// errors carry JSON bodies and pass through untouched.
1333pub(crate) fn map_route_missing(err: Error, unsupported: fn() -> Error) -> Error {
1334    match err {
1335        Error::Box {
1336            status: 404,
1337            ref message,
1338        } if message.contains("non-JSON") => unsupported(),
1339        other => other,
1340    }
1341}
1342
1343// ---------------------------------------------------------------------------
1344// DFU (POST /usb/dfu)
1345// ---------------------------------------------------------------------------
1346
1347/// One device reported by `dfu-util -l` (via [`crate::nets::dfu::Dfu::list`]).
1348#[derive(Debug, Clone, Deserialize)]
1349pub struct DfuDevice {
1350    /// `"DFU"` (in DFU mode) or `"Runtime"` (app running, DFU-capable).
1351    #[serde(default)]
1352    pub mode: String,
1353    /// Vendor id, lowercase hex.
1354    #[serde(default)]
1355    pub vid: String,
1356    /// Product id, lowercase hex.
1357    #[serde(default)]
1358    pub pid: String,
1359    /// Device number on the bus.
1360    #[serde(default, deserialize_with = "lenient::opt_i64")]
1361    pub devnum: Option<i64>,
1362    /// Configuration index.
1363    #[serde(default, deserialize_with = "lenient::opt_i64")]
1364    pub cfg: Option<i64>,
1365    /// Interface index.
1366    #[serde(default, deserialize_with = "lenient::opt_i64")]
1367    pub intf: Option<i64>,
1368    /// Alternate setting index.
1369    #[serde(default, deserialize_with = "lenient::opt_i64")]
1370    pub alt: Option<i64>,
1371    /// Interface name (e.g. `"@Internal Flash /0x08000000/..."`).
1372    #[serde(default)]
1373    pub name: Option<String>,
1374    /// Device serial.
1375    #[serde(default)]
1376    pub serial: Option<String>,
1377    /// Hub port path, e.g. `"1-1.4"`.
1378    #[serde(default)]
1379    pub path: Option<String>,
1380}
1381
1382/// Captured output of one box-side `dfu-util` run.
1383#[derive(Debug, Clone, Deserialize)]
1384pub struct DfuOutput {
1385    /// dfu-util exit code (0 on the success envelope).
1386    #[serde(default, deserialize_with = "lenient::opt_i64")]
1387    pub exit_code: Option<i64>,
1388    /// Captured stdout.
1389    #[serde(default)]
1390    pub stdout: String,
1391    /// Captured stderr (dfu-util writes progress here).
1392    #[serde(default)]
1393    pub stderr: String,
1394}
1395
1396pub(crate) fn dfu_unsupported() -> Error {
1397    Error::UnsupportedByBox {
1398        message: "this box does not serve POST /usb/dfu (requires box software >= 0.33.0)"
1399            .to_string(),
1400    }
1401}
1402
1403// ---------------------------------------------------------------------------
1404// Box lock / reservation (GET/POST /lock, /lock/heartbeat, /unlock)
1405// ---------------------------------------------------------------------------
1406
1407/// Box lock state, as returned by the `/lock` family of endpoints (the same
1408/// ones `lager boxes lock` uses). `locked: false` with everything else
1409/// `None` means the box is free.
1410#[derive(Debug, Clone, Deserialize)]
1411pub struct BoxLock {
1412    /// Whether the box is currently locked.
1413    #[serde(default)]
1414    pub locked: bool,
1415    /// Lock holder.
1416    #[serde(default)]
1417    pub user: Option<String>,
1418    /// Holder classification (`"user"`, `"ci"`, `"ephemeral"`, or another
1419    /// service's reservation origin).
1420    #[serde(default)]
1421    pub holder_type: Option<String>,
1422    /// When the lock was acquired (ISO 8601 UTC).
1423    #[serde(default)]
1424    pub locked_at: Option<String>,
1425    /// Last heartbeat (ISO 8601 UTC).
1426    #[serde(default)]
1427    pub last_heartbeat: Option<String>,
1428    /// Lock TTL in seconds; `None` means the lock never auto-expires.
1429    #[serde(default, deserialize_with = "lenient::opt_i64")]
1430    pub ttl_seconds: Option<i64>,
1431    /// On acquire: the holder before this call (`None` when the box was
1432    /// free). Distinguishes "just acquired" from "already held it".
1433    #[serde(default)]
1434    pub previous_user: Option<String>,
1435}
1436
1437/// Build a `GET /lock` request.
1438pub fn lock_status() -> HttpRequest {
1439    get("/lock")
1440}
1441
1442/// Build a `POST /lock` request. `ttl_seconds: None` sends JSON `null`
1443/// (an eternal lock, matching `lager boxes lock`).
1444pub fn lock_acquire(user: &str, holder_type: &str, ttl_seconds: Option<u64>) -> HttpRequest {
1445    HttpRequest {
1446        method: Method::Post,
1447        path: "/lock".to_string(),
1448        body: Some(json!({
1449            "user": user,
1450            "holder_type": holder_type,
1451            "ttl_seconds": ttl_seconds,
1452        })),
1453        timeout: Timeout::Default,
1454    }
1455}
1456
1457/// Build a `POST /lock/heartbeat` request.
1458pub fn lock_heartbeat(user: &str) -> HttpRequest {
1459    HttpRequest {
1460        method: Method::Post,
1461        path: "/lock/heartbeat".to_string(),
1462        body: Some(json!({ "user": user })),
1463        timeout: Timeout::Default,
1464    }
1465}
1466
1467/// Build a `POST /unlock` request.
1468pub fn unlock(user: &str, force: bool) -> HttpRequest {
1469    HttpRequest {
1470        method: Method::Post,
1471        path: "/unlock".to_string(),
1472        body: Some(json!({ "user": user, "force": force })),
1473        timeout: Timeout::Default,
1474    }
1475}
1476
1477/// Parse a `/lock` family response. These endpoints return the raw lock
1478/// dict on 200 and `{"error": ..., "lock": {...}}` on contention
1479/// (409 acquire, 403 heartbeat/unlock, 404 heartbeat on an unlocked box).
1480pub fn parse_lock(status: u16, body: Value) -> Result<BoxLock> {
1481    if status == 200 {
1482        return serde_json::from_value(body)
1483            .map_err(|e| Error::Decode(format!("invalid lock state: {e}")));
1484    }
1485    let message = body
1486        .get("error")
1487        .and_then(Value::as_str)
1488        .unwrap_or("lock request failed")
1489        .to_string();
1490    Err(Error::Box { status, message })
1491}
1492
1493pub(crate) fn lock_unsupported() -> Error {
1494    Error::UnsupportedByBox {
1495        message: "this box does not serve the /lock endpoints on port 9000; \
1496                  update the box software"
1497            .to_string(),
1498    }
1499}
1500
1501// ---------------------------------------------------------------------------
1502// Per-net safety limits (PUT /nets/<name>/safety-limits, box >= 0.35.0)
1503// ---------------------------------------------------------------------------
1504
1505/// Voltage/current ceilings and the destructive-op switch enforced by the
1506/// box on a saved net.
1507///
1508/// A `PUT` **replaces** the net's whole limits record with the fields set
1509/// here: a `None` field is *removed* from the net, not preserved. Read the
1510/// current limits first (they ride along on `/nets/list` records as
1511/// [`NetRecord::safety_limits`]) if you mean to change one ceiling and keep
1512/// the others.
1513///
1514/// There is deliberately no `max_power`: one setter call establishes either
1515/// voltage or current, never both, so the box cannot evaluate a power
1516/// ceiling honestly and refuses the key rather than storing something
1517/// nothing enforces.
1518#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1519pub struct SafetyLimits {
1520    /// Voltage ceiling in volts. Must be positive; also caps inline `ovp=`
1521    /// trip settings, which would otherwise lift the instrument's own guard
1522    /// above the net ceiling.
1523    #[serde(default, skip_serializing_if = "Option::is_none")]
1524    pub max_voltage: Option<f64>,
1525    /// Current ceiling in amps. Must be positive; also caps inline `ocp=`.
1526    #[serde(default, skip_serializing_if = "Option::is_none")]
1527    pub max_current: Option<f64>,
1528    /// `Some(false)` makes the box refuse erase and flash on this net.
1529    /// Absent means allowed (the box treats a missing key as unrestricted).
1530    #[serde(default, skip_serializing_if = "Option::is_none")]
1531    pub allow_destructive: Option<bool>,
1532}
1533
1534impl SafetyLimits {
1535    /// True when no field is set — as a PUT body this clears the net's
1536    /// limits, returning it to unrestricted.
1537    pub fn is_empty(&self) -> bool {
1538        self.max_voltage.is_none() && self.max_current.is_none() && self.allow_destructive.is_none()
1539    }
1540}
1541
1542/// Build a `PUT /nets/<name>/safety-limits` request. An empty `limits`
1543/// serialises to `{}`, the box's documented "back to unrestricted" body.
1544pub fn safety_limits_set(name: &str, limits: &SafetyLimits) -> HttpRequest {
1545    HttpRequest {
1546        method: Method::Put,
1547        path: format!("/nets/{name}/safety-limits"),
1548        body: Some(serde_json::to_value(limits).unwrap_or_else(|_| json!({}))),
1549        timeout: Timeout::Default,
1550    }
1551}
1552
1553/// Parse a `PUT /nets/<name>/safety-limits` response into the applied
1554/// limits (`None` when the body cleared them). Validation refusals (unknown
1555/// key, `max_power`, non-positive ceiling) and a missing net come back as
1556/// [`Error::Box`] carrying the box's message.
1557pub fn parse_safety_limits(status: u16, body: Value) -> Result<Option<SafetyLimits>> {
1558    if status == 200 {
1559        return match body.get("safety_limits") {
1560            None | Some(Value::Null) => Ok(None),
1561            Some(limits) => serde_json::from_value(limits.clone())
1562                .map(Some)
1563                .map_err(|e| Error::Decode(format!("invalid safety_limits shape: {e}"))),
1564        };
1565    }
1566    let message = body
1567        .get("error")
1568        .and_then(Value::as_str)
1569        .unwrap_or("safety-limits request failed")
1570        .to_string();
1571    Err(Error::Box { status, message })
1572}
1573
1574pub(crate) fn safety_limits_unsupported() -> Error {
1575    Error::UnsupportedByBox {
1576        message: "this box does not serve PUT /nets/<name>/safety-limits \
1577                  (requires box software >= 0.35.0)"
1578            .to_string(),
1579    }
1580}
1581
1582// ---------------------------------------------------------------------------
1583// Live net state (GET /nets/state, box >= 0.34.0)
1584// ---------------------------------------------------------------------------
1585
1586/// Brief live state of one saved net, from `GET /nets/state`.
1587///
1588/// The box probes each physical instrument in parallel under a shared
1589/// whole-request budget (8s), so a slow, wedged or absent instrument comes
1590/// back with `state: None` and a [`NetState::reason`] instead of failing or
1591/// delaying the rest of the bench.
1592#[derive(Debug, Clone, Deserialize)]
1593pub struct NetState {
1594    /// Net name.
1595    pub name: String,
1596    /// Net role (`"usb"`, `"power-supply"`, ...).
1597    #[serde(default)]
1598    pub role: String,
1599    /// Live state (e.g. `"enabled"`, `"3.300V"`), or `None` when the box
1600    /// could not read one — see [`NetState::reason`] for why.
1601    #[serde(default)]
1602    pub state: Option<String>,
1603    /// Attached only when `state` is `None`: `"deadline"` (the shared
1604    /// budget ran out before this net's instrument answered — not
1605    /// necessarily this instrument's fault), `"no probe for role"` (uart,
1606    /// spi, i2c, ... have no live-state probe), or `"unreadable: <detail>"`.
1607    #[serde(default)]
1608    pub reason: Option<String>,
1609    /// Stable machine-readable token alongside `reason` (e.g.
1610    /// `"hub-skipped"`), when the box classified the fault. The human
1611    /// `reason` is always complete on its own.
1612    #[serde(default)]
1613    pub reason_code: Option<String>,
1614    /// Any further keys a newer box attaches.
1615    #[serde(flatten)]
1616    pub extra: Map<String, Value>,
1617}
1618
1619/// Build a `GET /nets/state` request. The client timeout is widened past
1620/// the box's own 8s probe budget so a fully-consumed budget still yields
1621/// the (partial) answer rather than a client-side abort.
1622pub fn nets_state() -> HttpRequest {
1623    HttpRequest {
1624        method: Method::Get,
1625        path: "/nets/state".to_string(),
1626        body: None,
1627        timeout: Timeout::After(Duration::from_secs(15)),
1628    }
1629}
1630
1631/// Parse a `GET /nets/state` response (always 200 with one entry per saved
1632/// net on boxes that serve it).
1633pub fn parse_nets_state(status: u16, body: Value) -> Result<Vec<NetState>> {
1634    if status != 200 {
1635        let message = body
1636            .get("error")
1637            .and_then(Value::as_str)
1638            .unwrap_or("nets/state request failed")
1639            .to_string();
1640        return Err(Error::Box { status, message });
1641    }
1642    serde_json::from_value(body)
1643        .map_err(|e| Error::Decode(format!("invalid nets/state shape: {e}")))
1644}
1645
1646pub(crate) fn nets_state_unsupported() -> Error {
1647    Error::UnsupportedByBox {
1648        message: "this box does not serve GET /nets/state (requires box software >= 0.34.0)"
1649            .to_string(),
1650    }
1651}
1652
1653#[cfg(test)]
1654mod tests {
1655    use super::*;
1656
1657    #[test]
1658    fn parse_success_envelope() {
1659        let resp = parse_command(
1660            200,
1661            json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
1662        )
1663        .unwrap();
1664        assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
1665    }
1666
1667    #[test]
1668    fn parse_failure_with_http_200_is_box_error() {
1669        // Cross-role conflicts are reported as success=false with HTTP 200.
1670        let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
1671        match err {
1672            Error::Box { status, message } => {
1673                assert_eq!(status, 200);
1674                assert_eq!(message, "conflict");
1675            }
1676            other => panic!("unexpected error: {other:?}"),
1677        }
1678    }
1679
1680    #[test]
1681    fn parse_501_is_unsupported() {
1682        let err = parse_command(
1683            501,
1684            json!({"success": false, "error": "Role 'gpio' is not supported"}),
1685        )
1686        .unwrap_err();
1687        assert!(matches!(err, Error::UnsupportedByBox { .. }));
1688    }
1689
1690    #[test]
1691    fn base_url_normalization() {
1692        assert_eq!(base_url("192.168.1.42").unwrap(), "http://192.168.1.42:9000");
1693        assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
1694        assert_eq!(base_url("192.168.1.42:8080").unwrap(), "http://192.168.1.42:8080");
1695        assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
1696        assert!(base_url("").is_err());
1697        assert!(base_url("http://").is_err());
1698    }
1699
1700    #[test]
1701    fn lenient_numbers() {
1702        assert_eq!(as_f64(&json!("3.3")), Some(3.3));
1703        assert_eq!(as_f64(&json!(3.3)), Some(3.3));
1704        assert_eq!(as_bool(&json!("ON")), Some(true));
1705        assert_eq!(as_bool(&json!(0)), Some(false));
1706        assert_eq!(as_i64(&json!("42")), Some(42));
1707    }
1708
1709    #[test]
1710    fn supply_state_tolerates_nulls_and_strings() {
1711        let state: SupplyState = serde_json::from_value(json!({
1712            "netname": "supply1", "channel": "1", "error": null,
1713            "voltage": "3.3", "current": null, "enabled": 1,
1714        }))
1715        .unwrap();
1716        assert_eq!(state.channel, Some(1));
1717        assert_eq!(state.voltage, Some(3.3));
1718        assert_eq!(state.current, None);
1719        assert_eq!(state.enabled, Some(true));
1720    }
1721
1722    #[test]
1723    fn safety_limits_body_omits_unset_fields() {
1724        // The box refuses unknown keys and treats an explicit null as "do
1725        // not store this key", so unset fields must vanish from the body
1726        // entirely rather than ride along as nulls.
1727        let req = safety_limits_set(
1728            "supply1",
1729            &SafetyLimits {
1730                max_voltage: Some(5.0),
1731                ..Default::default()
1732            },
1733        );
1734        assert_eq!(req.method, Method::Put);
1735        assert_eq!(req.path, "/nets/supply1/safety-limits");
1736        assert_eq!(req.body, Some(json!({ "max_voltage": 5.0 })));
1737
1738        let clear = safety_limits_set("supply1", &SafetyLimits::default());
1739        assert_eq!(clear.body, Some(json!({})));
1740        assert!(SafetyLimits::default().is_empty());
1741    }
1742
1743    #[test]
1744    fn parse_safety_limits_roundtrip_and_clear() {
1745        let applied = parse_safety_limits(
1746            200,
1747            json!({"ok": true, "name": "supply1",
1748                   "safety_limits": {"max_voltage": 5.0, "allow_destructive": false}}),
1749        )
1750        .unwrap()
1751        .unwrap();
1752        assert_eq!(applied.max_voltage, Some(5.0));
1753        assert_eq!(applied.max_current, None);
1754        assert_eq!(applied.allow_destructive, Some(false));
1755
1756        // A clearing PUT echoes `safety_limits: null`.
1757        let cleared =
1758            parse_safety_limits(200, json!({"ok": true, "safety_limits": null})).unwrap();
1759        assert!(cleared.is_none());
1760    }
1761
1762    #[test]
1763    fn parse_safety_limits_surfaces_box_refusals() {
1764        let err = parse_safety_limits(
1765            400,
1766            json!({"error": "max_power is not supported: ..."}),
1767        )
1768        .unwrap_err();
1769        match err {
1770            Error::Box { status, message } => {
1771                assert_eq!(status, 400);
1772                assert!(message.contains("max_power"));
1773            }
1774            other => panic!("unexpected error: {other:?}"),
1775        }
1776    }
1777
1778    #[test]
1779    fn net_record_carries_safety_limits() {
1780        let rec: NetRecord = serde_json::from_value(json!({
1781            "name": "supply1", "role": "power-supply",
1782            "safety_limits": {"max_voltage": 12.0, "max_current": 2.5},
1783        }))
1784        .unwrap();
1785        let limits = rec.safety_limits.unwrap();
1786        assert_eq!(limits.max_voltage, Some(12.0));
1787        assert_eq!(limits.max_current, Some(2.5));
1788        assert_eq!(limits.allow_destructive, None);
1789
1790        // Absent key means unrestricted, not an error.
1791        let rec: NetRecord =
1792            serde_json::from_value(json!({"name": "gpio1", "role": "gpio"})).unwrap();
1793        assert!(rec.safety_limits.is_none());
1794    }
1795
1796    #[test]
1797    fn capabilities_parse_safety_limits_flag() {
1798        let caps: BoxCapabilities =
1799            serde_json::from_value(json!({"netCommand": true, "safetyLimits": true})).unwrap();
1800        assert!(caps.safety_limits);
1801        // A box predating the flag omits the key, which reads as false.
1802        let caps: BoxCapabilities = serde_json::from_value(json!({"netCommand": true})).unwrap();
1803        assert!(!caps.safety_limits);
1804    }
1805}