use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use crate::error::{Error, Result};
pub const DEFAULT_PORT: u16 = 9000;
pub const DEBUG_SERVICE_PORT: u16 = 8765;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Post,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Timeout {
Default,
After(Duration),
Unbounded,
}
#[derive(Debug, Clone)]
pub struct HttpRequest {
pub method: Method,
pub path: String,
pub body: Option<Value>,
pub timeout: Timeout,
}
pub struct Op<T> {
pub req: HttpRequest,
pub parse: fn(CommandResponse) -> Result<T>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CommandResponse {
#[serde(default)]
pub success: bool,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub value: Option<Value>,
#[serde(default)]
pub state: Option<Value>,
#[serde(default)]
pub error: Option<String>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
pub fn parse_command(status: u16, body: Value) -> Result<CommandResponse> {
let resp: CommandResponse = serde_json::from_value(body)
.map_err(|e| Error::Decode(format!("invalid response envelope: {e}")))?;
if status == 200 && resp.success {
return Ok(resp);
}
let message = resp
.error
.or(resp.message)
.unwrap_or_else(|| format!("HTTP {status}"));
if status == 501 {
return Err(Error::UnsupportedByBox { message });
}
Err(Error::Box { status, message })
}
pub fn net_command(
netname: &str,
role: impl Into<Option<&'static str>>,
action: &str,
params: Value,
timeout: Timeout,
) -> HttpRequest {
let mut body = json!({
"netname": netname,
"action": action,
"params": params,
});
if let Some(role) = role.into() {
body["role"] = json!(role);
}
HttpRequest {
method: Method::Post,
path: "/net/command".to_string(),
body: Some(body),
timeout,
}
}
pub fn supply_command(netname: &str, action: &str, params: Value) -> HttpRequest {
HttpRequest {
method: Method::Post,
path: "/supply/command".to_string(),
body: Some(json!({
"netname": netname,
"action": action,
"params": params,
})),
timeout: Timeout::Default,
}
}
pub fn battery_command(netname: &str, action: &str, params: Value) -> HttpRequest {
HttpRequest {
method: Method::Post,
path: "/battery/command".to_string(),
body: Some(json!({
"netname": netname,
"action": action,
"params": params,
})),
timeout: Timeout::Default,
}
}
pub fn usb_command(netname: &str, action: &str) -> HttpRequest {
HttpRequest {
method: Method::Post,
path: "/usb/command".to_string(),
body: Some(json!({
"netname": netname,
"action": action,
})),
timeout: Timeout::Default,
}
}
pub fn box_command(path: &str, action: &str, params: Value, timeout: Timeout) -> HttpRequest {
HttpRequest {
method: Method::Post,
path: path.to_string(),
body: Some(json!({
"action": action,
"params": params,
})),
timeout,
}
}
pub fn get(path: &str) -> HttpRequest {
HttpRequest {
method: Method::Get,
path: path.to_string(),
body: None,
timeout: Timeout::Default,
}
}
pub(crate) fn base_url(host: &str) -> Result<String> {
base_url_with_port(host, DEFAULT_PORT)
}
pub(crate) fn base_url_with_port(host: &str, default_port: u16) -> Result<String> {
let input = host.trim();
let (scheme, rest) = match input.split_once("://") {
Some((scheme, rest)) => (scheme, rest),
None => ("http", input),
};
let rest = rest.trim_end_matches('/');
if scheme.is_empty() || rest.is_empty() {
return Err(Error::Config(format!("invalid box host '{host}'")));
}
if rest.contains(':') {
Ok(format!("{scheme}://{rest}"))
} else {
Ok(format!("{scheme}://{rest}:{default_port}"))
}
}
pub(crate) fn service_base(base: &str, port: u16) -> String {
let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
let host = rest.rsplit_once(':').map(|(h, _)| h).unwrap_or(rest);
format!("{scheme}://{host}:{port}")
}
pub fn debug_request(path: &str, body: Value, timeout: Timeout) -> HttpRequest {
HttpRequest {
method: Method::Post,
path: path.to_string(),
body: Some(body),
timeout,
}
}
pub fn parse_debug(status: u16, body: Value) -> Result<Value> {
if status == 200 {
return Ok(body);
}
let message = body
.get("error")
.and_then(Value::as_str)
.or_else(|| body.get("message").and_then(Value::as_str))
.unwrap_or("debug service request failed")
.to_string();
Err(Error::Box { status, message })
}
#[derive(Debug, Clone, Deserialize)]
pub struct GdbServer {
#[serde(default)]
pub status: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub gdb_port: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub swo_port: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub telnet_port: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub tcl_port: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub rtt_telnet_port: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub pid: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DebugConnection {
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub device: Option<String>,
#[serde(default)]
pub probe: Option<String>,
#[serde(default)]
pub serial: Option<String>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub pid: Option<i64>,
#[serde(default)]
pub gdb_server: Option<GdbServer>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DebugInfo {
#[serde(default)]
pub net_name: Option<String>,
#[serde(default)]
pub device: Option<String>,
#[serde(default)]
pub arch: Option<String>,
#[serde(default)]
pub probe: Option<String>,
#[serde(default)]
pub serial: Option<String>,
#[serde(default)]
pub backend: Option<String>,
#[serde(default)]
pub connected: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DebugStatus {
#[serde(default)]
pub connected: bool,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub pid: Option<i64>,
#[serde(default)]
pub serial: Option<String>,
#[serde(default)]
pub backend: Option<String>,
}
pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
let hex = body
.get("data")
.and_then(Value::as_str)
.ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
decode_hex(hex)
}
pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
let s = s.trim();
if s.len() % 2 != 0 {
return Err(Error::Decode("odd-length hex string".to_string()));
}
(0..s.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&s[i..i + 2], 16)
.map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
})
.collect()
}
pub(crate) fn as_f64(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse().ok(),
Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
}
}
pub(crate) fn as_i64(v: &Value) -> Option<i64> {
match v {
Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
Value::String(s) => s
.trim()
.parse::<i64>()
.ok()
.or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
Value::Bool(b) => Some(i64::from(*b)),
_ => None,
}
}
pub(crate) fn as_bool(v: &Value) -> Option<bool> {
match v {
Value::Bool(b) => Some(*b),
Value::Number(n) => n.as_f64().map(|f| f != 0.0),
Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
"true" | "on" | "1" | "yes" | "enabled" => Some(true),
"false" | "off" | "0" | "no" | "disabled" => Some(false),
_ => None,
},
_ => None,
}
}
pub(crate) mod lenient {
use serde::{Deserialize, Deserializer};
use serde_json::Value;
pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
let v = Option::<Value>::deserialize(d)?;
Ok(v.as_ref().and_then(super::as_f64))
}
pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
let v = Option::<Value>::deserialize(d)?;
Ok(v.as_ref().and_then(super::as_i64))
}
pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
let v = Option::<Value>::deserialize(d)?;
Ok(v.as_ref().and_then(super::as_bool))
}
}
pub fn value_f64(resp: CommandResponse) -> Result<f64> {
resp.value
.as_ref()
.and_then(as_f64)
.ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
}
pub fn value_i64(resp: CommandResponse) -> Result<i64> {
resp.value
.as_ref()
.and_then(as_i64)
.ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
}
pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
let arr = resp
.value
.as_ref()
.and_then(Value::as_array)
.ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
arr.iter()
.map(|v| {
as_i64(v)
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
})
.collect()
}
pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
let v = resp
.value
.ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
}
pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
let v = resp
.state
.ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
}
pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
match body {
Value::Array(a) => a,
Value::Object(mut o) => match o.remove("nets") {
Some(Value::Array(a)) => a,
_ => vec![],
},
_ => vec![],
}
}
pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
let list = Value::Array(nets_list_values(body));
serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
}
pub fn unit(_resp: CommandResponse) -> Result<()> {
Ok(())
}
pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
Ok(resp)
}
#[derive(Debug, Clone, Deserialize)]
pub struct NetRecord {
#[serde(default)]
pub name: String,
#[serde(default)]
pub role: String,
#[serde(default)]
pub instrument: Option<String>,
#[serde(default)]
pub pin: Option<Value>,
#[serde(default)]
pub channel: Option<Value>,
#[serde(default)]
pub address: Option<String>,
#[serde(default)]
pub params: Option<Map<String, Value>>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Health {
#[serde(default)]
pub status: String,
#[serde(default)]
pub service: Option<String>,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct NetSummary {
#[serde(default)]
pub name: String,
#[serde(default, rename = "type")]
pub net_type: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BoxCapabilities {
#[serde(default, rename = "netCommand")]
pub net_command: bool,
#[serde(default, rename = "netCommandRoles")]
pub net_command_roles: Vec<String>,
#[serde(default, rename = "bleCommand")]
pub ble_command: bool,
#[serde(default, rename = "wifiCommand")]
pub wifi_command: bool,
#[serde(default, rename = "blufiCommand")]
pub blufi_command: bool,
#[serde(default, rename = "customDevices")]
pub custom_devices: bool,
#[serde(default)]
pub binaries: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BoxStatus {
#[serde(default)]
pub healthy: bool,
#[serde(default)]
pub version: String,
#[serde(default)]
pub nets: Vec<NetSummary>,
#[serde(default)]
pub capabilities: BoxCapabilities,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SupplyState {
#[serde(default)]
pub netname: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub channel: Option<i64>,
#[serde(default)]
pub error: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub voltage: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub current: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub power: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_bool")]
pub enabled: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub voltage_set: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub current_set: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub voltage_max: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub current_max: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub ocp_limit: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_bool")]
pub ocp_tripped: Option<bool>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub ovp_limit: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_bool")]
pub ovp_tripped: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BatteryState {
#[serde(default)]
pub netname: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub channel: Option<i64>,
#[serde(default)]
pub error: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub terminal_voltage: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub current: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub esr: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub soc: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub voc: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_bool")]
pub enabled: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub capacity: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub current_limit: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub ocp_limit: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub ovp_limit: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub volt_full: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub volt_empty: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_bool")]
pub ocp_tripped: Option<bool>,
#[serde(default, deserialize_with = "lenient::opt_bool")]
pub ovp_tripped: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EloadState {
#[serde(default)]
pub mode: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_bool")]
pub input_enabled: Option<bool>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub measured_voltage: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub measured_current: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub measured_power: Option<f64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WattReading {
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub current: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub voltage: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub power: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub duration_s: Option<f64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EnergyReading {
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub energy_j: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub charge_c: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub duration_s: Option<f64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct StatSummary {
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub mean: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub min: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub max: Option<f64>,
#[serde(default, deserialize_with = "lenient::opt_f64")]
pub std: Option<f64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EnergyStats {
#[serde(default)]
pub current: Option<StatSummary>,
#[serde(default)]
pub voltage: Option<StatSummary>,
#[serde(default)]
pub power: Option<StatSummary>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ArmPosition {
pub x: f64,
pub y: f64,
pub z: f64,
}
pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
let arr = resp
.value
.as_ref()
.and_then(Value::as_array)
.ok_or_else(|| Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value)))?;
match arr.as_slice() {
[x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
(Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
_ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
},
_ => Err(Error::Decode(format!(
"expected 3-element arm position, got {} elements",
arr.len()
))),
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebcamStream {
#[serde(default)]
pub url: String,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub port: Option<i64>,
#[serde(default)]
pub already_running: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebcamStatus {
#[serde(default)]
pub running: bool,
#[serde(default)]
pub url: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub port: Option<i64>,
#[serde(default)]
pub video_device: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RouterSystemInfo {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub board: Option<String>,
#[serde(default)]
pub architecture: Option<String>,
#[serde(default)]
pub uptime: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub cpu_load: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub free_memory: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub total_memory: Option<i64>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub free_hdd_space: Option<i64>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BleDevice {
#[serde(default)]
pub name: String,
#[serde(default)]
pub address: String,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub rssi: Option<i64>,
#[serde(default)]
pub uuids: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BleCharacteristic {
#[serde(default)]
pub uuid: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub properties: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BleService {
#[serde(default)]
pub uuid: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub characteristics: Vec<BleCharacteristic>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BleDeviceInfo {
#[serde(default)]
pub address: String,
#[serde(default)]
pub connected: bool,
#[serde(default)]
pub services: Vec<BleService>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WifiInterface {
#[serde(default)]
pub interface: String,
#[serde(default)]
pub ssid: String,
#[serde(default)]
pub state: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WifiAccessPoint {
#[serde(default)]
pub ssid: Option<String>,
#[serde(default)]
pub address: Option<String>,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub strength: Option<i64>,
#[serde(default)]
pub security: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WifiConnection {
#[serde(default)]
pub ssid: String,
#[serde(default)]
pub connected: bool,
#[serde(default)]
pub interface: Option<String>,
#[serde(default)]
pub method: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiStatus {
#[serde(default)]
pub device_name: Option<String>,
#[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
pub op_mode: Option<i64>,
#[serde(default, rename = "opModeName")]
pub op_mode_name: Option<String>,
#[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
pub sta_conn: Option<i64>,
#[serde(default, rename = "staConnName")]
pub sta_conn_name: Option<String>,
#[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
pub soft_ap_conn: Option<i64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiDeviceInfo {
#[serde(default)]
pub version: Option<String>,
#[serde(flatten)]
pub status: BlufiStatus,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiProvisionResult {
#[serde(default)]
pub device_name: Option<String>,
#[serde(default)]
pub ssid: String,
#[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
pub sta_conn: Option<i64>,
#[serde(default, rename = "staConnName")]
pub sta_conn_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiNetwork {
#[serde(default)]
pub ssid: String,
#[serde(default, deserialize_with = "lenient::opt_i64")]
pub rssi: Option<i64>,
}
pub(crate) fn value_list_field<T: DeserializeOwned>(
resp: CommandResponse,
field: &str,
) -> Result<Vec<T>> {
let list = resp
.value
.as_ref()
.and_then(|v| v.get(field))
.cloned()
.ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
serde_json::from_value(list)
.map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_success_envelope() {
let resp = parse_command(
200,
json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
)
.unwrap();
assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
}
#[test]
fn parse_failure_with_http_200_is_box_error() {
let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
match err {
Error::Box { status, message } => {
assert_eq!(status, 200);
assert_eq!(message, "conflict");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn parse_501_is_unsupported() {
let err = parse_command(
501,
json!({"success": false, "error": "Role 'gpio' is not supported"}),
)
.unwrap_err();
assert!(matches!(err, Error::UnsupportedByBox { .. }));
}
#[test]
fn base_url_normalization() {
assert_eq!(base_url("192.168.1.42").unwrap(), "http://192.168.1.42:9000");
assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
assert_eq!(base_url("192.168.1.42:8080").unwrap(), "http://192.168.1.42:8080");
assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
assert!(base_url("").is_err());
assert!(base_url("http://").is_err());
}
#[test]
fn lenient_numbers() {
assert_eq!(as_f64(&json!("3.3")), Some(3.3));
assert_eq!(as_f64(&json!(3.3)), Some(3.3));
assert_eq!(as_bool(&json!("ON")), Some(true));
assert_eq!(as_bool(&json!(0)), Some(false));
assert_eq!(as_i64(&json!("42")), Some(42));
}
#[test]
fn supply_state_tolerates_nulls_and_strings() {
let state: SupplyState = serde_json::from_value(json!({
"netname": "supply1", "channel": "1", "error": null,
"voltage": "3.3", "current": null, "enabled": 1,
}))
.unwrap();
assert_eq!(state.channel, Some(1));
assert_eq!(state.voltage, Some(3.3));
assert_eq!(state.current, None);
assert_eq!(state.enabled, Some(true));
}
}