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