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;
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. The box API only uses GET and POST.
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}
35
36/// Client-side timeout policy for one request.
37///
38/// Mirrors the Lager CLI's budgets: quick commands get the 10s default,
39/// while actions that block on the box for a caller-controlled duration
40/// (watt/energy integration windows, `wait_for_level`) widen or drop the
41/// client timeout so a healthy request is never aborted mid-measurement.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Timeout {
44    /// Use the client's configured default (10s unless overridden).
45    Default,
46    /// Use this specific timeout.
47    After(Duration),
48    /// No client-side timeout at all (e.g. an unbounded `wait_for_level`).
49    Unbounded,
50}
51
52/// One fully-described HTTP request to the box, transport-agnostic.
53#[derive(Debug, Clone)]
54pub struct HttpRequest {
55    /// HTTP method.
56    pub method: Method,
57    /// Path relative to the box base URL, e.g. `"/net/command"`.
58    pub path: String,
59    /// JSON body for POST requests.
60    pub body: Option<Value>,
61    /// Client-side timeout policy.
62    pub timeout: Timeout,
63}
64
65/// A complete operation: the request to send plus the parser that turns the
66/// box's response envelope into a typed value. Net handles build these; the
67/// sync/async clients only execute them.
68pub struct Op<T> {
69    /// The request to send.
70    pub req: HttpRequest,
71    /// Parser applied to the successful response envelope.
72    pub parse: fn(CommandResponse) -> Result<T>,
73}
74
75// ---------------------------------------------------------------------------
76// Response envelope
77// ---------------------------------------------------------------------------
78
79/// The common response envelope every box command endpoint returns:
80/// `{"success": bool, "action": ..., "message": ..., "value": ..., "state": ...}`.
81#[derive(Debug, Clone, Deserialize)]
82pub struct CommandResponse {
83    /// Whether the box executed the command.
84    #[serde(default)]
85    pub success: bool,
86    /// Echo of the action that ran.
87    #[serde(default)]
88    pub action: Option<String>,
89    /// Human-readable result message (what the CLI prints).
90    #[serde(default)]
91    pub message: Option<String>,
92    /// Structured result value, shape depends on the action.
93    #[serde(default)]
94    pub value: Option<Value>,
95    /// Structured state object (supply/battery `state`, usb port state).
96    #[serde(default)]
97    pub state: Option<Value>,
98    /// Error message when `success` is false.
99    #[serde(default)]
100    pub error: Option<String>,
101    /// Any endpoint-specific extra fields (e.g. SPI's top-level `word_size`).
102    #[serde(flatten)]
103    pub extra: Map<String, Value>,
104}
105
106/// Interpret a raw `(status, body)` pair as the command envelope, applying
107/// the box's error conventions:
108///
109/// - 200 + `success: true` is the only success shape.
110/// - 501 means the box image predates the endpoint ([`Error::UnsupportedByBox`]).
111/// - Everything else (including `success: false` with HTTP 200, which the box
112///   uses for cross-role instrument conflicts) is [`Error::Box`].
113pub fn parse_command(status: u16, body: Value) -> Result<CommandResponse> {
114    let resp: CommandResponse = serde_json::from_value(body)
115        .map_err(|e| Error::Decode(format!("invalid response envelope: {e}")))?;
116    if status == 200 && resp.success {
117        return Ok(resp);
118    }
119    let message = resp
120        .error
121        .or(resp.message)
122        .unwrap_or_else(|| format!("HTTP {status}"));
123    if status == 501 {
124        return Err(Error::UnsupportedByBox { message });
125    }
126    Err(Error::Box { status, message })
127}
128
129// ---------------------------------------------------------------------------
130// Generic request builders
131// ---------------------------------------------------------------------------
132
133/// Build a `POST /net/command` request (gpio, adc, dac, thermocouple,
134/// watt-meter, eload, spi, i2c, energy-analyzer).
135///
136/// The `role` is sent as a hint; the box resolves the authoritative role from
137/// its `saved_nets.json` and verifies the hint, so a typo'd net name or a
138/// role mismatch fails loudly instead of driving the wrong instrument.
139///
140/// `role: None` omits the hint entirely and defers fully to the box's
141/// resolution. Used for roles with saved-record aliases (router nets may be
142/// saved as `"router"` or the legacy `"mikrotik"`; the box dispatches both
143/// to the same handler, but a hint must match the saved string exactly).
144pub fn net_command(
145    netname: &str,
146    role: impl Into<Option<&'static str>>,
147    action: &str,
148    params: Value,
149    timeout: Timeout,
150) -> HttpRequest {
151    let mut body = json!({
152        "netname": netname,
153        "action": action,
154        "params": params,
155    });
156    if let Some(role) = role.into() {
157        body["role"] = json!(role);
158    }
159    HttpRequest {
160        method: Method::Post,
161        path: "/net/command".to_string(),
162        body: Some(body),
163        timeout,
164    }
165}
166
167/// Build a `POST /supply/command` request.
168pub fn supply_command(netname: &str, action: &str, params: Value) -> HttpRequest {
169    HttpRequest {
170        method: Method::Post,
171        path: "/supply/command".to_string(),
172        body: Some(json!({
173            "netname": netname,
174            "action": action,
175            "params": params,
176        })),
177        timeout: Timeout::Default,
178    }
179}
180
181/// Build a `POST /battery/command` request.
182pub fn battery_command(netname: &str, action: &str, params: Value) -> HttpRequest {
183    HttpRequest {
184        method: Method::Post,
185        path: "/battery/command".to_string(),
186        body: Some(json!({
187            "netname": netname,
188            "action": action,
189            "params": params,
190        })),
191        timeout: Timeout::Default,
192    }
193}
194
195/// Build a `POST /usb/command` request (no `params` object on this endpoint).
196pub fn usb_command(netname: &str, action: &str) -> HttpRequest {
197    HttpRequest {
198        method: Method::Post,
199        path: "/usb/command".to_string(),
200        body: Some(json!({
201            "netname": netname,
202            "action": action,
203        })),
204        timeout: Timeout::Default,
205    }
206}
207
208/// Build a box-level command request (`POST /ble/command`, `/wifi/command`,
209/// `/blufi/command`). These endpoints drive the box's own hardware (its
210/// Bluetooth adapter or wlan interface) rather than a saved net, so the body
211/// carries only `{action, params}` — no `netname`.
212pub fn box_command(path: &str, action: &str, params: Value, timeout: Timeout) -> HttpRequest {
213    HttpRequest {
214        method: Method::Post,
215        path: path.to_string(),
216        body: Some(json!({
217            "action": action,
218            "params": params,
219        })),
220        timeout,
221    }
222}
223
224/// Build a plain GET request (discovery/health endpoints).
225pub fn get(path: &str) -> HttpRequest {
226    HttpRequest {
227        method: Method::Get,
228        path: path.to_string(),
229        body: None,
230        timeout: Timeout::Default,
231    }
232}
233
234/// Normalize a user-supplied host string into a base URL.
235///
236/// Accepts a bare host/IP (`"192.168.1.42"`, `"mybox.tailnet.ts.net"`), a
237/// `host:port` pair, or a full URL. The scheme defaults to `http` and the
238/// port to [`DEFAULT_PORT`] (9000).
239pub(crate) fn base_url(host: &str) -> Result<String> {
240    base_url_with_port(host, DEFAULT_PORT)
241}
242
243/// Like [`base_url`] but uses `default_port` when the host has no explicit
244/// port. Used for the debug-service URL, which defaults to 8765.
245pub(crate) fn base_url_with_port(host: &str, default_port: u16) -> Result<String> {
246    let input = host.trim();
247    let (scheme, rest) = match input.split_once("://") {
248        Some((scheme, rest)) => (scheme, rest),
249        None => ("http", input),
250    };
251    let rest = rest.trim_end_matches('/');
252    if scheme.is_empty() || rest.is_empty() {
253        return Err(Error::Config(format!("invalid box host '{host}'")));
254    }
255    if rest.contains(':') {
256        Ok(format!("{scheme}://{rest}"))
257    } else {
258        Ok(format!("{scheme}://{rest}:{default_port}"))
259    }
260}
261
262/// Derive a sibling service base URL on the same host but a different port,
263/// e.g. turn `http://192.168.1.42:9000` into `http://192.168.1.42:8765` for
264/// the debug service. `base` is expected to be normalized by [`base_url`].
265pub(crate) fn service_base(base: &str, port: u16) -> String {
266    let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
267    let host = rest.rsplit_once(':').map(|(h, _)| h).unwrap_or(rest);
268    format!("{scheme}://{host}:{port}")
269}
270
271// ---------------------------------------------------------------------------
272// Debug service (:8765) — separate protocol from the :9000 command envelope
273// ---------------------------------------------------------------------------
274
275/// Build a `POST /debug/<op>` request for the debug service.
276pub fn debug_request(path: &str, body: Value, timeout: Timeout) -> HttpRequest {
277    HttpRequest {
278        method: Method::Post,
279        path: path.to_string(),
280        body: Some(body),
281        timeout,
282    }
283}
284
285/// Interpret a debug-service `(status, body)` pair. The debug service does
286/// not use the `success` envelope: 200 is success, and errors arrive as
287/// `{"error": msg, "status": "error"}` with a 4xx/5xx code.
288pub fn parse_debug(status: u16, body: Value) -> Result<Value> {
289    if status == 200 {
290        return Ok(body);
291    }
292    let message = body
293        .get("error")
294        .and_then(Value::as_str)
295        .or_else(|| body.get("message").and_then(Value::as_str))
296        .unwrap_or("debug service request failed")
297        .to_string();
298    Err(Error::Box { status, message })
299}
300
301/// GDB-server sub-object of a [`DebugConnection`].
302#[derive(Debug, Clone, Deserialize)]
303pub struct GdbServer {
304    /// Server state, e.g. `"started"` or `"already_running"`.
305    #[serde(default)]
306    pub status: Option<String>,
307    /// GDB protocol port.
308    #[serde(default, deserialize_with = "lenient::opt_i64")]
309    pub gdb_port: Option<i64>,
310    /// SWO output port (J-Link only).
311    #[serde(default, deserialize_with = "lenient::opt_i64")]
312    pub swo_port: Option<i64>,
313    /// Telnet I/O port.
314    #[serde(default, deserialize_with = "lenient::opt_i64")]
315    pub telnet_port: Option<i64>,
316    /// TCL/RPC port (OpenOCD only).
317    #[serde(default, deserialize_with = "lenient::opt_i64")]
318    pub tcl_port: Option<i64>,
319    /// RTT telnet port.
320    #[serde(default, deserialize_with = "lenient::opt_i64")]
321    pub rtt_telnet_port: Option<i64>,
322    /// Server process id.
323    #[serde(default, deserialize_with = "lenient::opt_i64")]
324    pub pid: Option<i64>,
325}
326
327/// Result of a debug `connect`.
328#[derive(Debug, Clone, Deserialize)]
329pub struct DebugConnection {
330    /// Connection status, e.g. `"connected"`.
331    #[serde(default)]
332    pub status: Option<String>,
333    /// Resolved device/target type.
334    #[serde(default)]
335    pub device: Option<String>,
336    /// Probe instrument name.
337    #[serde(default)]
338    pub probe: Option<String>,
339    /// Probe serial.
340    #[serde(default)]
341    pub serial: Option<String>,
342    /// Debug backend that started (`"jlink"` or `"openocd"`).
343    #[serde(default)]
344    pub backend: Option<String>,
345    /// Human-readable message.
346    #[serde(default)]
347    pub message: Option<String>,
348    /// Backend process id.
349    #[serde(default, deserialize_with = "lenient::opt_i64")]
350    pub pid: Option<i64>,
351    /// GDB-server details, if a server was started.
352    #[serde(default)]
353    pub gdb_server: Option<GdbServer>,
354}
355
356/// Result of a debug `info` query.
357#[derive(Debug, Clone, Deserialize)]
358pub struct DebugInfo {
359    /// Net name.
360    #[serde(default)]
361    pub net_name: Option<String>,
362    /// Resolved device/target type.
363    #[serde(default)]
364    pub device: Option<String>,
365    /// Target architecture.
366    #[serde(default)]
367    pub arch: Option<String>,
368    /// Probe instrument name.
369    #[serde(default)]
370    pub probe: Option<String>,
371    /// Probe serial.
372    #[serde(default)]
373    pub serial: Option<String>,
374    /// Debug backend (`"jlink"` or `"openocd"`).
375    #[serde(default)]
376    pub backend: Option<String>,
377    /// Whether a gdbserver/daemon is currently running for this probe.
378    #[serde(default)]
379    pub connected: bool,
380}
381
382/// Result of a debug `status` query.
383#[derive(Debug, Clone, Deserialize)]
384pub struct DebugStatus {
385    /// Whether a gdbserver/daemon is currently running for this probe.
386    #[serde(default)]
387    pub connected: bool,
388    /// Backend process id, when connected.
389    #[serde(default, deserialize_with = "lenient::opt_i64")]
390    pub pid: Option<i64>,
391    /// Probe serial.
392    #[serde(default)]
393    pub serial: Option<String>,
394    /// Debug backend (`"jlink"` or `"openocd"`).
395    #[serde(default)]
396    pub backend: Option<String>,
397}
398
399/// Extract the hex `data` field from a `/debug/memrd` response into bytes.
400pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
401    let hex = body
402        .get("data")
403        .and_then(Value::as_str)
404        .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
405    decode_hex(hex)
406}
407
408/// Decode a hex string into bytes.
409pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
410    let s = s.trim();
411    if s.len() % 2 != 0 {
412        return Err(Error::Decode("odd-length hex string".to_string()));
413    }
414    (0..s.len())
415        .step_by(2)
416        .map(|i| {
417            u8::from_str_radix(&s[i..i + 2], 16)
418                .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
419        })
420        .collect()
421}
422
423// ---------------------------------------------------------------------------
424// Lenient deserialization helpers
425// ---------------------------------------------------------------------------
426//
427// Instrument drivers occasionally hand numbers back as strings (SCPI query
428// results). These helpers accept a JSON number, a numeric string, or null.
429
430pub(crate) fn as_f64(v: &Value) -> Option<f64> {
431    match v {
432        Value::Number(n) => n.as_f64(),
433        Value::String(s) => s.trim().parse().ok(),
434        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
435        _ => None,
436    }
437}
438
439pub(crate) fn as_i64(v: &Value) -> Option<i64> {
440    match v {
441        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
442        Value::String(s) => s
443            .trim()
444            .parse::<i64>()
445            .ok()
446            .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
447        Value::Bool(b) => Some(i64::from(*b)),
448        _ => None,
449    }
450}
451
452pub(crate) fn as_bool(v: &Value) -> Option<bool> {
453    match v {
454        Value::Bool(b) => Some(*b),
455        Value::Number(n) => n.as_f64().map(|f| f != 0.0),
456        Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
457            "true" | "on" | "1" | "yes" | "enabled" => Some(true),
458            "false" | "off" | "0" | "no" | "disabled" => Some(false),
459            _ => None,
460        },
461        _ => None,
462    }
463}
464
465pub(crate) mod lenient {
466    //! `deserialize_with` adapters for fields that may arrive as numbers,
467    //! numeric strings, or null.
468
469    use serde::{Deserialize, Deserializer};
470    use serde_json::Value;
471
472    pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
473        let v = Option::<Value>::deserialize(d)?;
474        Ok(v.as_ref().and_then(super::as_f64))
475    }
476
477    pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
478        let v = Option::<Value>::deserialize(d)?;
479        Ok(v.as_ref().and_then(super::as_i64))
480    }
481
482    pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
483        let v = Option::<Value>::deserialize(d)?;
484        Ok(v.as_ref().and_then(super::as_bool))
485    }
486}
487
488// ---------------------------------------------------------------------------
489// Typed extraction from the envelope
490// ---------------------------------------------------------------------------
491
492/// Extract the `value` field as an `f64` (accepting numeric strings).
493pub fn value_f64(resp: CommandResponse) -> Result<f64> {
494    resp.value
495        .as_ref()
496        .and_then(as_f64)
497        .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
498}
499
500/// Extract the `value` field as an `i64` (accepting numeric strings).
501pub fn value_i64(resp: CommandResponse) -> Result<i64> {
502    resp.value
503        .as_ref()
504        .and_then(as_i64)
505        .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
506}
507
508/// Extract the `value` field as a list of integers (I2C bytes/addresses,
509/// SPI words).
510pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
511    let arr = resp
512        .value
513        .as_ref()
514        .and_then(Value::as_array)
515        .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
516    arr.iter()
517        .map(|v| {
518            as_i64(v)
519                .and_then(|n| u32::try_from(n).ok())
520                .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
521        })
522        .collect()
523}
524
525/// Deserialize the `value` field into a typed struct.
526pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
527    let v = resp
528        .value
529        .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
530    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
531}
532
533/// Deserialize the `state` field into a typed struct.
534pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
535    let v = resp
536        .state
537        .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
538    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
539}
540
541/// Interpret a nets-list body into raw JSON values. `/nets/list` returns a
542/// bare array; the older `/uart/nets/list` wraps it in `{"nets": [...]}`.
543pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
544    match body {
545        Value::Array(a) => a,
546        Value::Object(mut o) => match o.remove("nets") {
547            Some(Value::Array(a)) => a,
548            _ => vec![],
549        },
550        _ => vec![],
551    }
552}
553
554/// Interpret a nets-list body into typed [`NetRecord`]s.
555pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
556    let list = Value::Array(nets_list_values(body));
557    serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
558}
559
560/// Discard the payload, keeping only success/failure.
561pub fn unit(_resp: CommandResponse) -> Result<()> {
562    Ok(())
563}
564
565/// Keep the whole envelope (for callers that want `message` etc.).
566pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
567    Ok(resp)
568}
569
570// ---------------------------------------------------------------------------
571// Shared response types
572// ---------------------------------------------------------------------------
573
574/// One saved-net record from `GET /nets/list` (the box's `saved_nets.json`).
575#[derive(Debug, Clone, Deserialize)]
576pub struct NetRecord {
577    /// Net name, e.g. `"supply1"`.
578    #[serde(default)]
579    pub name: String,
580    /// Role string, e.g. `"power-supply"`, `"gpio"`, `"adc"`.
581    #[serde(default)]
582    pub role: String,
583    /// Instrument backing this net, e.g. `"Rigol DP832"`, `"LabJack T7"`.
584    #[serde(default)]
585    pub instrument: Option<String>,
586    /// Pin / channel identifier (number or string depending on role).
587    #[serde(default)]
588    pub pin: Option<Value>,
589    /// Channel (multi-channel instruments).
590    #[serde(default)]
591    pub channel: Option<Value>,
592    /// VISA or device address.
593    #[serde(default)]
594    pub address: Option<String>,
595    /// Role-specific saved parameters (SPI mode, UART baudrate, ...).
596    #[serde(default)]
597    pub params: Option<Map<String, Value>>,
598    /// Everything else in the record (mappings, scope_points, ...).
599    #[serde(flatten)]
600    pub extra: Map<String, Value>,
601}
602
603/// Response of `GET /health`.
604#[derive(Debug, Clone, Deserialize)]
605pub struct Health {
606    /// `"healthy"` when the box HTTP server is up.
607    #[serde(default)]
608    pub status: String,
609    /// Service identifier.
610    #[serde(default)]
611    pub service: Option<String>,
612    /// Service version string.
613    #[serde(default)]
614    pub version: Option<String>,
615}
616
617/// One net summary inside [`BoxStatus`].
618#[derive(Debug, Clone, Deserialize)]
619pub struct NetSummary {
620    /// Net name.
621    #[serde(default)]
622    pub name: String,
623    /// Net type name (e.g. `"PowerSupply"`, `"GPIO"`).
624    #[serde(default, rename = "type")]
625    pub net_type: String,
626}
627
628/// Capabilities advertised by the box in `GET /status`.
629#[derive(Debug, Clone, Default, Deserialize)]
630pub struct BoxCapabilities {
631    /// Whether the box serves `POST /net/command` (Tier-1 nets over HTTP).
632    /// When false the box software predates this crate's contract.
633    #[serde(default, rename = "netCommand")]
634    pub net_command: bool,
635    /// Roles served by `POST /net/command` on this box. Empty on box images
636    /// that predate role advertising (which still serve the original Tier-1
637    /// roles when `net_command` is true).
638    #[serde(default, rename = "netCommandRoles")]
639    pub net_command_roles: Vec<String>,
640    /// Whether the box serves `POST /ble/command`.
641    #[serde(default, rename = "bleCommand")]
642    pub ble_command: bool,
643    /// Whether the box serves `POST /wifi/command`.
644    #[serde(default, rename = "wifiCommand")]
645    pub wifi_command: bool,
646    /// Whether the box serves `POST /blufi/command`.
647    #[serde(default, rename = "blufiCommand")]
648    pub blufi_command: bool,
649    /// Whether the box serves the `/custom-devices/*` endpoints (the
650    /// `lager nets assign` backend). Not used by this crate; surfaced for
651    /// callers probing box features.
652    #[serde(default, rename = "customDevices")]
653    pub custom_devices: bool,
654    /// Whether the box serves `/binaries/*` and `/download-file`. Not used
655    /// by this crate; surfaced for callers probing box features.
656    #[serde(default)]
657    pub binaries: bool,
658}
659
660/// Response of `GET /status`.
661#[derive(Debug, Clone, Deserialize)]
662pub struct BoxStatus {
663    /// Whether the box reports itself healthy.
664    #[serde(default)]
665    pub healthy: bool,
666    /// Box software version.
667    #[serde(default)]
668    pub version: String,
669    /// Configured nets (name + type only; use `nets()` for full records).
670    #[serde(default)]
671    pub nets: Vec<NetSummary>,
672    /// Endpoint capabilities.
673    #[serde(default)]
674    pub capabilities: BoxCapabilities,
675}
676
677/// Structured power-supply state from the `state` action on
678/// `POST /supply/command`. Fields are `None` when the individual read failed
679/// (the box returns a full-shaped dict even on a degraded read; `error`
680/// carries the reason).
681#[derive(Debug, Clone, Deserialize)]
682pub struct SupplyState {
683    /// Net name echo.
684    #[serde(default)]
685    pub netname: Option<String>,
686    /// Instrument channel driving this net.
687    #[serde(default, deserialize_with = "lenient::opt_i64")]
688    pub channel: Option<i64>,
689    /// Transport-level error when the whole state gather failed.
690    #[serde(default)]
691    pub error: Option<String>,
692    /// Measured output voltage (V).
693    #[serde(default, deserialize_with = "lenient::opt_f64")]
694    pub voltage: Option<f64>,
695    /// Measured output current (A).
696    #[serde(default, deserialize_with = "lenient::opt_f64")]
697    pub current: Option<f64>,
698    /// Measured output power (W).
699    #[serde(default, deserialize_with = "lenient::opt_f64")]
700    pub power: Option<f64>,
701    /// Whether the output is enabled.
702    #[serde(default, deserialize_with = "lenient::opt_bool")]
703    pub enabled: Option<bool>,
704    /// Operating mode (e.g. `"CV"`, `"CC"`).
705    #[serde(default)]
706    pub mode: Option<String>,
707    /// Voltage setpoint (V).
708    #[serde(default, deserialize_with = "lenient::opt_f64")]
709    pub voltage_set: Option<f64>,
710    /// Current limit setpoint (A).
711    #[serde(default, deserialize_with = "lenient::opt_f64")]
712    pub current_set: Option<f64>,
713    /// Hardware maximum voltage (V).
714    #[serde(default, deserialize_with = "lenient::opt_f64")]
715    pub voltage_max: Option<f64>,
716    /// Hardware maximum current (A).
717    #[serde(default, deserialize_with = "lenient::opt_f64")]
718    pub current_max: Option<f64>,
719    /// Over-current protection limit (A).
720    #[serde(default, deserialize_with = "lenient::opt_f64")]
721    pub ocp_limit: Option<f64>,
722    /// Whether OCP has tripped.
723    #[serde(default, deserialize_with = "lenient::opt_bool")]
724    pub ocp_tripped: Option<bool>,
725    /// Over-voltage protection limit (V).
726    #[serde(default, deserialize_with = "lenient::opt_f64")]
727    pub ovp_limit: Option<f64>,
728    /// Whether OVP has tripped.
729    #[serde(default, deserialize_with = "lenient::opt_bool")]
730    pub ovp_tripped: Option<bool>,
731}
732
733/// Structured battery-simulator state from the `state` action on
734/// `POST /battery/command`. Fields are `None` when the individual read
735/// failed.
736#[derive(Debug, Clone, Deserialize)]
737pub struct BatteryState {
738    /// Net name echo.
739    #[serde(default)]
740    pub netname: Option<String>,
741    /// Instrument channel driving this net.
742    #[serde(default, deserialize_with = "lenient::opt_i64")]
743    pub channel: Option<i64>,
744    /// Transport-level error when the whole state gather failed.
745    #[serde(default)]
746    pub error: Option<String>,
747    /// Simulated terminal voltage (V).
748    #[serde(default, deserialize_with = "lenient::opt_f64")]
749    pub terminal_voltage: Option<f64>,
750    /// Measured current (A).
751    #[serde(default, deserialize_with = "lenient::opt_f64")]
752    pub current: Option<f64>,
753    /// Equivalent series resistance (ohm).
754    #[serde(default, deserialize_with = "lenient::opt_f64")]
755    pub esr: Option<f64>,
756    /// State of charge (%).
757    #[serde(default, deserialize_with = "lenient::opt_f64")]
758    pub soc: Option<f64>,
759    /// Open-circuit voltage (V).
760    #[serde(default, deserialize_with = "lenient::opt_f64")]
761    pub voc: Option<f64>,
762    /// Whether the simulator output is enabled.
763    #[serde(default, deserialize_with = "lenient::opt_bool")]
764    pub enabled: Option<bool>,
765    /// Simulation mode (`"static"` / `"dynamic"`).
766    #[serde(default)]
767    pub mode: Option<String>,
768    /// Battery model / part number.
769    #[serde(default)]
770    pub model: Option<String>,
771    /// Battery capacity (Ah).
772    #[serde(default, deserialize_with = "lenient::opt_f64")]
773    pub capacity: Option<f64>,
774    /// Current limit (A).
775    #[serde(default, deserialize_with = "lenient::opt_f64")]
776    pub current_limit: Option<f64>,
777    /// Over-current protection limit (A).
778    #[serde(default, deserialize_with = "lenient::opt_f64")]
779    pub ocp_limit: Option<f64>,
780    /// Over-voltage protection limit (V).
781    #[serde(default, deserialize_with = "lenient::opt_f64")]
782    pub ovp_limit: Option<f64>,
783    /// Voltage considered "full" (V).
784    #[serde(default, deserialize_with = "lenient::opt_f64")]
785    pub volt_full: Option<f64>,
786    /// Voltage considered "empty" (V).
787    #[serde(default, deserialize_with = "lenient::opt_f64")]
788    pub volt_empty: Option<f64>,
789    /// Whether OCP has tripped.
790    #[serde(default, deserialize_with = "lenient::opt_bool")]
791    pub ocp_tripped: Option<bool>,
792    /// Whether OVP has tripped.
793    #[serde(default, deserialize_with = "lenient::opt_bool")]
794    pub ovp_tripped: Option<bool>,
795}
796
797/// Structured e-load state from the `state` action on `POST /net/command`.
798#[derive(Debug, Clone, Deserialize)]
799pub struct EloadState {
800    /// Active mode (`"cc"`, `"cv"`, `"cr"`, `"cp"`).
801    #[serde(default)]
802    pub mode: Option<String>,
803    /// Whether the load input is enabled.
804    #[serde(default, deserialize_with = "lenient::opt_bool")]
805    pub input_enabled: Option<bool>,
806    /// Measured voltage (V).
807    #[serde(default, deserialize_with = "lenient::opt_f64")]
808    pub measured_voltage: Option<f64>,
809    /// Measured current (A).
810    #[serde(default, deserialize_with = "lenient::opt_f64")]
811    pub measured_current: Option<f64>,
812    /// Measured power (W).
813    #[serde(default, deserialize_with = "lenient::opt_f64")]
814    pub measured_power: Option<f64>,
815    /// Any extra driver-specific fields.
816    #[serde(flatten)]
817    pub extra: Map<String, Value>,
818}
819
820/// Combined current/voltage/power reading from a watt-meter `all` action.
821#[derive(Debug, Clone, Deserialize)]
822pub struct WattReading {
823    /// Mean current over the window (A).
824    #[serde(default, deserialize_with = "lenient::opt_f64")]
825    pub current: Option<f64>,
826    /// Mean voltage over the window (V).
827    #[serde(default, deserialize_with = "lenient::opt_f64")]
828    pub voltage: Option<f64>,
829    /// Mean power over the window (W).
830    #[serde(default, deserialize_with = "lenient::opt_f64")]
831    pub power: Option<f64>,
832    /// Measurement window (s).
833    #[serde(default, deserialize_with = "lenient::opt_f64")]
834    pub duration_s: Option<f64>,
835}
836
837/// Integrated energy reading from an energy-analyzer `read_energy` action.
838#[derive(Debug, Clone, Deserialize)]
839pub struct EnergyReading {
840    /// Integrated energy (J).
841    #[serde(default, deserialize_with = "lenient::opt_f64")]
842    pub energy_j: Option<f64>,
843    /// Integrated charge (C).
844    #[serde(default, deserialize_with = "lenient::opt_f64")]
845    pub charge_c: Option<f64>,
846    /// Actual integration window (s).
847    #[serde(default, deserialize_with = "lenient::opt_f64")]
848    pub duration_s: Option<f64>,
849    /// Any extra analyzer-specific fields.
850    #[serde(flatten)]
851    pub extra: Map<String, Value>,
852}
853
854/// Statistical summary of one signal inside [`EnergyStats`].
855#[derive(Debug, Clone, Default, Deserialize)]
856pub struct StatSummary {
857    /// Mean value.
858    #[serde(default, deserialize_with = "lenient::opt_f64")]
859    pub mean: Option<f64>,
860    /// Minimum value.
861    #[serde(default, deserialize_with = "lenient::opt_f64")]
862    pub min: Option<f64>,
863    /// Maximum value.
864    #[serde(default, deserialize_with = "lenient::opt_f64")]
865    pub max: Option<f64>,
866    /// Standard deviation.
867    #[serde(default, deserialize_with = "lenient::opt_f64")]
868    pub std: Option<f64>,
869    /// Any extra analyzer-specific fields.
870    #[serde(flatten)]
871    pub extra: Map<String, Value>,
872}
873
874/// Statistics from an energy-analyzer `read_stats` action.
875#[derive(Debug, Clone, Deserialize)]
876pub struct EnergyStats {
877    /// Current statistics (A).
878    #[serde(default)]
879    pub current: Option<StatSummary>,
880    /// Voltage statistics (V).
881    #[serde(default)]
882    pub voltage: Option<StatSummary>,
883    /// Power statistics (W).
884    #[serde(default)]
885    pub power: Option<StatSummary>,
886    /// Any extra analyzer-specific fields.
887    #[serde(flatten)]
888    pub extra: Map<String, Value>,
889}
890
891// ---------------------------------------------------------------------------
892// Arm / webcam / router / BLE / WiFi / BluFi response types
893// ---------------------------------------------------------------------------
894
895/// Cartesian position of a robot arm's end effector (mm), from the `value:
896/// [x, y, z]` list the arm actions return.
897#[derive(Debug, Clone, Copy, PartialEq)]
898pub struct ArmPosition {
899    /// X coordinate (mm).
900    pub x: f64,
901    /// Y coordinate (mm).
902    pub y: f64,
903    /// Z coordinate (mm).
904    pub z: f64,
905}
906
907/// Parse an arm response's `value: [x, y, z]` into an [`ArmPosition`].
908pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
909    let arr = resp
910        .value
911        .as_ref()
912        .and_then(Value::as_array)
913        .ok_or_else(|| Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value)))?;
914    match arr.as_slice() {
915        [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
916            (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
917            _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
918        },
919        _ => Err(Error::Decode(format!(
920            "expected 3-element arm position, got {} elements",
921            arr.len()
922        ))),
923    }
924}
925
926/// Result of starting a webcam stream (`start` action).
927#[derive(Debug, Clone, Deserialize)]
928pub struct WebcamStream {
929    /// MJPEG stream URL viewers should open.
930    #[serde(default)]
931    pub url: String,
932    /// TCP port the stream is served on.
933    #[serde(default, deserialize_with = "lenient::opt_i64")]
934    pub port: Option<i64>,
935    /// `true` when a stream was already up and the box reused it.
936    #[serde(default)]
937    pub already_running: bool,
938}
939
940/// Current state of a webcam stream (`status`/`url` actions).
941#[derive(Debug, Clone, Deserialize)]
942pub struct WebcamStatus {
943    /// Whether a stream is currently running for this net.
944    #[serde(default)]
945    pub running: bool,
946    /// Stream URL, when running.
947    #[serde(default)]
948    pub url: Option<String>,
949    /// Stream TCP port, when running.
950    #[serde(default, deserialize_with = "lenient::opt_i64")]
951    pub port: Option<i64>,
952    /// Backing video device (e.g. `/dev/video0`), when running.
953    #[serde(default)]
954    pub video_device: Option<String>,
955}
956
957/// System information for a router net (`system_info` action).
958#[derive(Debug, Clone, Deserialize)]
959pub struct RouterSystemInfo {
960    /// Router identity name.
961    #[serde(default)]
962    pub name: Option<String>,
963    /// RouterOS version.
964    #[serde(default)]
965    pub version: Option<String>,
966    /// Board model, e.g. `"hAP ac^2"`.
967    #[serde(default)]
968    pub board: Option<String>,
969    /// CPU architecture.
970    #[serde(default)]
971    pub architecture: Option<String>,
972    /// Uptime string, e.g. `"1w2d3h"`.
973    #[serde(default)]
974    pub uptime: Option<String>,
975    /// CPU load (%).
976    #[serde(default, deserialize_with = "lenient::opt_i64")]
977    pub cpu_load: Option<i64>,
978    /// Free RAM (bytes).
979    #[serde(default, deserialize_with = "lenient::opt_i64")]
980    pub free_memory: Option<i64>,
981    /// Total RAM (bytes).
982    #[serde(default, deserialize_with = "lenient::opt_i64")]
983    pub total_memory: Option<i64>,
984    /// Free storage (bytes).
985    #[serde(default, deserialize_with = "lenient::opt_i64")]
986    pub free_hdd_space: Option<i64>,
987    /// Any extra fields.
988    #[serde(flatten)]
989    pub extra: Map<String, Value>,
990}
991
992/// One device found by a BLE or BluFi scan.
993#[derive(Debug, Clone, Deserialize)]
994pub struct BleDevice {
995    /// Advertised name (falls back to the address when unnamed).
996    #[serde(default)]
997    pub name: String,
998    /// BLE MAC address, `XX:XX:XX:XX:XX:XX`.
999    #[serde(default)]
1000    pub address: String,
1001    /// Signal strength (dBm).
1002    #[serde(default, deserialize_with = "lenient::opt_i64")]
1003    pub rssi: Option<i64>,
1004    /// Advertised service UUIDs.
1005    #[serde(default)]
1006    pub uuids: Vec<String>,
1007}
1008
1009/// One GATT characteristic inside a [`BleService`].
1010#[derive(Debug, Clone, Deserialize)]
1011pub struct BleCharacteristic {
1012    /// Characteristic UUID.
1013    #[serde(default)]
1014    pub uuid: String,
1015    /// Human-readable description, when known.
1016    #[serde(default)]
1017    pub description: Option<String>,
1018    /// Supported operations, e.g. `["read", "notify"]`.
1019    #[serde(default)]
1020    pub properties: Vec<String>,
1021}
1022
1023/// One GATT service enumerated from a connected BLE device.
1024#[derive(Debug, Clone, Deserialize)]
1025pub struct BleService {
1026    /// Service UUID.
1027    #[serde(default)]
1028    pub uuid: String,
1029    /// Human-readable description, when known.
1030    #[serde(default)]
1031    pub description: Option<String>,
1032    /// Characteristics under this service.
1033    #[serde(default)]
1034    pub characteristics: Vec<BleCharacteristic>,
1035}
1036
1037/// Result of a BLE `info`/`connect`: the device's GATT database.
1038#[derive(Debug, Clone, Deserialize)]
1039pub struct BleDeviceInfo {
1040    /// Device address.
1041    #[serde(default)]
1042    pub address: String,
1043    /// Whether the box reached the device.
1044    #[serde(default)]
1045    pub connected: bool,
1046    /// Enumerated GATT services.
1047    #[serde(default)]
1048    pub services: Vec<BleService>,
1049}
1050
1051/// Status of one wireless interface on the box (`wifi status`).
1052#[derive(Debug, Clone, Deserialize)]
1053pub struct WifiInterface {
1054    /// Interface name, e.g. `"wlan0"`.
1055    #[serde(default)]
1056    pub interface: String,
1057    /// Connected SSID, or a placeholder like `"Not Connected"`.
1058    #[serde(default)]
1059    pub ssid: String,
1060    /// Connection state, e.g. `"Connected"` / `"Disconnected"`.
1061    #[serde(default)]
1062    pub state: String,
1063}
1064
1065/// One access point found by a box-side WiFi scan.
1066#[derive(Debug, Clone, Deserialize)]
1067pub struct WifiAccessPoint {
1068    /// Network SSID (`"Hidden"` for hidden networks).
1069    #[serde(default)]
1070    pub ssid: Option<String>,
1071    /// BSSID / AP MAC address.
1072    #[serde(default)]
1073    pub address: Option<String>,
1074    /// Signal strength (approximate %, 0-100).
1075    #[serde(default, deserialize_with = "lenient::opt_i64")]
1076    pub strength: Option<i64>,
1077    /// `"Open"` or `"Secured"`.
1078    #[serde(default)]
1079    pub security: Option<String>,
1080}
1081
1082/// Result of connecting the box to a WiFi network.
1083#[derive(Debug, Clone, Deserialize)]
1084pub struct WifiConnection {
1085    /// SSID the box joined.
1086    #[serde(default)]
1087    pub ssid: String,
1088    /// Whether the connection succeeded (always true in a success envelope).
1089    #[serde(default)]
1090    pub connected: bool,
1091    /// Interface used, e.g. `"wlan0"`.
1092    #[serde(default)]
1093    pub interface: Option<String>,
1094    /// Connection method, e.g. `"nmcli"` or `"wpa_supplicant"`.
1095    #[serde(default)]
1096    pub method: Option<String>,
1097}
1098
1099/// WiFi state reported by a BluFi target device (`status`, and embedded in
1100/// `connect`). Codes follow the ESP32 BluFi protocol.
1101#[derive(Debug, Clone, Deserialize)]
1102pub struct BlufiStatus {
1103    /// Target device name echo.
1104    #[serde(default)]
1105    pub device_name: Option<String>,
1106    /// Operation mode code (0 NULL, 1 STA, 2 SoftAP, 3 STA+SoftAP).
1107    #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
1108    pub op_mode: Option<i64>,
1109    /// Human-readable operation mode.
1110    #[serde(default, rename = "opModeName")]
1111    pub op_mode_name: Option<String>,
1112    /// Station connection code (0 connected, 1 failed, 2 connecting, 3 no IP).
1113    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1114    pub sta_conn: Option<i64>,
1115    /// Human-readable station connection state.
1116    #[serde(default, rename = "staConnName")]
1117    pub sta_conn_name: Option<String>,
1118    /// SoftAP connection count/state code.
1119    #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
1120    pub soft_ap_conn: Option<i64>,
1121}
1122
1123/// Result of a BluFi `connect`: firmware version plus WiFi state.
1124#[derive(Debug, Clone, Deserialize)]
1125pub struct BlufiDeviceInfo {
1126    /// Target firmware version, when the device reports one.
1127    #[serde(default)]
1128    pub version: Option<String>,
1129    /// WiFi state of the target.
1130    #[serde(flatten)]
1131    pub status: BlufiStatus,
1132}
1133
1134/// Result of a BluFi `provision`.
1135#[derive(Debug, Clone, Deserialize)]
1136pub struct BlufiProvisionResult {
1137    /// Target device name echo.
1138    #[serde(default)]
1139    pub device_name: Option<String>,
1140    /// SSID the target was provisioned onto.
1141    #[serde(default)]
1142    pub ssid: String,
1143    /// Station connection code after provisioning (0 = connected).
1144    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1145    pub sta_conn: Option<i64>,
1146    /// Human-readable station connection state.
1147    #[serde(default, rename = "staConnName")]
1148    pub sta_conn_name: Option<String>,
1149}
1150
1151/// One network seen by a BluFi target's own WiFi scan (`wifi_scan`).
1152#[derive(Debug, Clone, Deserialize)]
1153pub struct BlufiNetwork {
1154    /// Network SSID.
1155    #[serde(default)]
1156    pub ssid: String,
1157    /// Signal strength at the target (dBm).
1158    #[serde(default, deserialize_with = "lenient::opt_i64")]
1159    pub rssi: Option<i64>,
1160}
1161
1162/// Extract a named list field out of the `value` object (e.g. `devices`
1163/// from a BLE scan, `access_points` from a WiFi scan).
1164pub(crate) fn value_list_field<T: DeserializeOwned>(
1165    resp: CommandResponse,
1166    field: &str,
1167) -> Result<Vec<T>> {
1168    let list = resp
1169        .value
1170        .as_ref()
1171        .and_then(|v| v.get(field))
1172        .cloned()
1173        .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
1174    serde_json::from_value(list)
1175        .map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    #[test]
1183    fn parse_success_envelope() {
1184        let resp = parse_command(
1185            200,
1186            json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
1187        )
1188        .unwrap();
1189        assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
1190    }
1191
1192    #[test]
1193    fn parse_failure_with_http_200_is_box_error() {
1194        // Cross-role conflicts are reported as success=false with HTTP 200.
1195        let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
1196        match err {
1197            Error::Box { status, message } => {
1198                assert_eq!(status, 200);
1199                assert_eq!(message, "conflict");
1200            }
1201            other => panic!("unexpected error: {other:?}"),
1202        }
1203    }
1204
1205    #[test]
1206    fn parse_501_is_unsupported() {
1207        let err = parse_command(
1208            501,
1209            json!({"success": false, "error": "Role 'gpio' is not supported"}),
1210        )
1211        .unwrap_err();
1212        assert!(matches!(err, Error::UnsupportedByBox { .. }));
1213    }
1214
1215    #[test]
1216    fn base_url_normalization() {
1217        assert_eq!(base_url("192.168.1.42").unwrap(), "http://192.168.1.42:9000");
1218        assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
1219        assert_eq!(base_url("192.168.1.42:8080").unwrap(), "http://192.168.1.42:8080");
1220        assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
1221        assert!(base_url("").is_err());
1222        assert!(base_url("http://").is_err());
1223    }
1224
1225    #[test]
1226    fn lenient_numbers() {
1227        assert_eq!(as_f64(&json!("3.3")), Some(3.3));
1228        assert_eq!(as_f64(&json!(3.3)), Some(3.3));
1229        assert_eq!(as_bool(&json!("ON")), Some(true));
1230        assert_eq!(as_bool(&json!(0)), Some(false));
1231        assert_eq!(as_i64(&json!("42")), Some(42));
1232    }
1233
1234    #[test]
1235    fn supply_state_tolerates_nulls_and_strings() {
1236        let state: SupplyState = serde_json::from_value(json!({
1237            "netname": "supply1", "channel": "1", "error": null,
1238            "voltage": "3.3", "current": null, "enabled": 1,
1239        }))
1240        .unwrap();
1241        assert_eq!(state.channel, Some(1));
1242        assert_eq!(state.voltage, Some(3.3));
1243        assert_eq!(state.current, None);
1244        assert_eq!(state.enabled, Some(true));
1245    }
1246}