1use 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
16pub const DEFAULT_PORT: u16 = 9000;
18
19pub const DEBUG_SERVICE_PORT: u16 = 8765;
22
23pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Method {
30 Get,
32 Post,
34 Put,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Timeout {
46 Default,
48 After(Duration),
50 Unbounded,
52}
53
54#[derive(Debug, Clone)]
56pub struct HttpRequest {
57 pub method: Method,
59 pub path: String,
61 pub body: Option<Value>,
63 pub timeout: Timeout,
65}
66
67pub struct Op<T> {
71 pub req: HttpRequest,
73 pub parse: fn(CommandResponse) -> Result<T>,
75}
76
77#[derive(Debug, Clone, Deserialize)]
84pub struct CommandResponse {
85 #[serde(default)]
87 pub success: bool,
88 #[serde(default)]
90 pub action: Option<String>,
91 #[serde(default)]
93 pub message: Option<String>,
94 #[serde(default)]
96 pub value: Option<Value>,
97 #[serde(default)]
99 pub state: Option<Value>,
100 #[serde(default)]
102 pub error: Option<String>,
103 #[serde(flatten)]
105 pub extra: Map<String, Value>,
106}
107
108pub 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
131pub 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
169pub 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
183pub 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
197pub 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
210pub fn usb_cycle(netname: &str, off_time: Option<f64>) -> HttpRequest {
217 let mut body = json!({
218 "netname": netname,
219 "action": "cycle",
220 });
221 if let Some(secs) = off_time {
222 body["off_time"] = json!(secs);
223 }
224 HttpRequest {
225 method: Method::Post,
226 path: "/usb/command".to_string(),
227 body: Some(body),
228 timeout: Timeout::After(Duration::from_secs_f64(30.0 + off_time.unwrap_or(1.0))),
229 }
230}
231
232pub fn box_command(path: &str, action: &str, params: Value, timeout: Timeout) -> HttpRequest {
237 HttpRequest {
238 method: Method::Post,
239 path: path.to_string(),
240 body: Some(json!({
241 "action": action,
242 "params": params,
243 })),
244 timeout,
245 }
246}
247
248pub fn get(path: &str) -> HttpRequest {
250 HttpRequest {
251 method: Method::Get,
252 path: path.to_string(),
253 body: None,
254 timeout: Timeout::Default,
255 }
256}
257
258pub(crate) fn base_url(host: &str) -> Result<String> {
264 base_url_with_port(host, DEFAULT_PORT)
265}
266
267pub(crate) fn base_url_with_port(host: &str, default_port: u16) -> Result<String> {
270 let input = host.trim();
271 let (scheme, rest) = match input.split_once("://") {
272 Some((scheme, rest)) => (scheme, rest),
273 None => ("http", input),
274 };
275 let rest = rest.trim_end_matches('/');
276 if scheme.is_empty() || rest.is_empty() {
277 return Err(Error::Config(format!("invalid box host '{host}'")));
278 }
279 if rest.contains(':') {
280 Ok(format!("{scheme}://{rest}"))
281 } else {
282 Ok(format!("{scheme}://{rest}:{default_port}"))
283 }
284}
285
286pub(crate) fn service_base(base: &str, port: u16) -> String {
290 let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
291 let host = rest.rsplit_once(':').map(|(h, _)| h).unwrap_or(rest);
292 format!("{scheme}://{host}:{port}")
293}
294
295pub fn debug_request(path: &str, body: Value, timeout: Timeout) -> HttpRequest {
301 HttpRequest {
302 method: Method::Post,
303 path: path.to_string(),
304 body: Some(body),
305 timeout,
306 }
307}
308
309pub fn parse_debug(status: u16, body: Value) -> Result<Value> {
313 if status == 200 {
314 return Ok(body);
315 }
316 let message = body
317 .get("error")
318 .and_then(Value::as_str)
319 .or_else(|| body.get("message").and_then(Value::as_str))
320 .unwrap_or("debug service request failed")
321 .to_string();
322 Err(Error::Box { status, message })
323}
324
325#[derive(Debug, Clone, Deserialize)]
327pub struct GdbServer {
328 #[serde(default)]
330 pub status: Option<String>,
331 #[serde(default, deserialize_with = "lenient::opt_i64")]
333 pub gdb_port: Option<i64>,
334 #[serde(default, deserialize_with = "lenient::opt_i64")]
336 pub swo_port: Option<i64>,
337 #[serde(default, deserialize_with = "lenient::opt_i64")]
339 pub telnet_port: Option<i64>,
340 #[serde(default, deserialize_with = "lenient::opt_i64")]
342 pub tcl_port: Option<i64>,
343 #[serde(default, deserialize_with = "lenient::opt_i64")]
345 pub rtt_telnet_port: Option<i64>,
346 #[serde(default, deserialize_with = "lenient::opt_i64")]
348 pub pid: Option<i64>,
349}
350
351#[derive(Debug, Clone, Deserialize)]
353pub struct DebugConnection {
354 #[serde(default)]
356 pub status: Option<String>,
357 #[serde(default)]
359 pub device: Option<String>,
360 #[serde(default)]
362 pub probe: Option<String>,
363 #[serde(default)]
365 pub serial: Option<String>,
366 #[serde(default)]
368 pub backend: Option<String>,
369 #[serde(default)]
371 pub message: Option<String>,
372 #[serde(default, deserialize_with = "lenient::opt_i64")]
374 pub pid: Option<i64>,
375 #[serde(default)]
377 pub gdb_server: Option<GdbServer>,
378}
379
380#[derive(Debug, Clone, Deserialize)]
382pub struct DebugInfo {
383 #[serde(default)]
385 pub net_name: Option<String>,
386 #[serde(default)]
388 pub device: Option<String>,
389 #[serde(default)]
391 pub arch: Option<String>,
392 #[serde(default)]
394 pub probe: Option<String>,
395 #[serde(default)]
397 pub serial: Option<String>,
398 #[serde(default)]
400 pub backend: Option<String>,
401 #[serde(default)]
403 pub connected: bool,
404}
405
406#[derive(Debug, Clone, Deserialize)]
408pub struct DebugStatus {
409 #[serde(default)]
411 pub connected: bool,
412 #[serde(default, deserialize_with = "lenient::opt_i64")]
414 pub pid: Option<i64>,
415 #[serde(default)]
417 pub serial: Option<String>,
418 #[serde(default)]
420 pub backend: Option<String>,
421}
422
423pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
425 let hex = body
426 .get("data")
427 .and_then(Value::as_str)
428 .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
429 decode_hex(hex)
430}
431
432pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
434 let s = s.trim();
435 if s.len() % 2 != 0 {
436 return Err(Error::Decode("odd-length hex string".to_string()));
437 }
438 (0..s.len())
439 .step_by(2)
440 .map(|i| {
441 u8::from_str_radix(&s[i..i + 2], 16)
442 .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
443 })
444 .collect()
445}
446
447pub(crate) fn as_f64(v: &Value) -> Option<f64> {
455 match v {
456 Value::Number(n) => n.as_f64(),
457 Value::String(s) => s.trim().parse().ok(),
458 Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
459 _ => None,
460 }
461}
462
463pub(crate) fn as_i64(v: &Value) -> Option<i64> {
464 match v {
465 Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
466 Value::String(s) => s
467 .trim()
468 .parse::<i64>()
469 .ok()
470 .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
471 Value::Bool(b) => Some(i64::from(*b)),
472 _ => None,
473 }
474}
475
476pub(crate) fn as_bool(v: &Value) -> Option<bool> {
477 match v {
478 Value::Bool(b) => Some(*b),
479 Value::Number(n) => n.as_f64().map(|f| f != 0.0),
480 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
481 "true" | "on" | "1" | "yes" | "enabled" => Some(true),
482 "false" | "off" | "0" | "no" | "disabled" => Some(false),
483 _ => None,
484 },
485 _ => None,
486 }
487}
488
489pub(crate) mod lenient {
490 use serde::{Deserialize, Deserializer};
494 use serde_json::Value;
495
496 pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
497 let v = Option::<Value>::deserialize(d)?;
498 Ok(v.as_ref().and_then(super::as_f64))
499 }
500
501 pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
502 let v = Option::<Value>::deserialize(d)?;
503 Ok(v.as_ref().and_then(super::as_i64))
504 }
505
506 pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
507 let v = Option::<Value>::deserialize(d)?;
508 Ok(v.as_ref().and_then(super::as_bool))
509 }
510}
511
512pub fn value_f64(resp: CommandResponse) -> Result<f64> {
518 resp.value
519 .as_ref()
520 .and_then(as_f64)
521 .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
522}
523
524pub fn value_i64(resp: CommandResponse) -> Result<i64> {
526 resp.value
527 .as_ref()
528 .and_then(as_i64)
529 .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
530}
531
532pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
535 let arr = resp
536 .value
537 .as_ref()
538 .and_then(Value::as_array)
539 .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
540 arr.iter()
541 .map(|v| {
542 as_i64(v)
543 .and_then(|n| u32::try_from(n).ok())
544 .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
545 })
546 .collect()
547}
548
549pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
551 let v = resp
552 .value
553 .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
554 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
555}
556
557pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
559 let v = resp
560 .state
561 .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
562 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
563}
564
565pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
568 match body {
569 Value::Array(a) => a,
570 Value::Object(mut o) => match o.remove("nets") {
571 Some(Value::Array(a)) => a,
572 _ => vec![],
573 },
574 _ => vec![],
575 }
576}
577
578pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
580 let list = Value::Array(nets_list_values(body));
581 serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
582}
583
584pub fn unit(_resp: CommandResponse) -> Result<()> {
586 Ok(())
587}
588
589pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
591 Ok(resp)
592}
593
594#[derive(Debug, Clone, Deserialize)]
600pub struct NetRecord {
601 #[serde(default)]
603 pub name: String,
604 #[serde(default)]
606 pub role: String,
607 #[serde(default)]
609 pub instrument: Option<String>,
610 #[serde(default)]
612 pub pin: Option<Value>,
613 #[serde(default)]
615 pub channel: Option<Value>,
616 #[serde(default)]
618 pub address: Option<String>,
619 #[serde(default)]
621 pub params: Option<Map<String, Value>>,
622 #[serde(default)]
625 pub safety_limits: Option<SafetyLimits>,
626 #[serde(flatten)]
628 pub extra: Map<String, Value>,
629}
630
631#[derive(Debug, Clone, Deserialize)]
633pub struct Health {
634 #[serde(default)]
636 pub status: String,
637 #[serde(default)]
639 pub service: Option<String>,
640 #[serde(default)]
642 pub version: Option<String>,
643}
644
645#[derive(Debug, Clone, Deserialize)]
647pub struct NetSummary {
648 #[serde(default)]
650 pub name: String,
651 #[serde(default, rename = "type")]
653 pub net_type: String,
654}
655
656#[derive(Debug, Clone, Default, Deserialize)]
658pub struct BoxCapabilities {
659 #[serde(default, rename = "netCommand")]
662 pub net_command: bool,
663 #[serde(default, rename = "netCommandRoles")]
667 pub net_command_roles: Vec<String>,
668 #[serde(default, rename = "bleCommand")]
670 pub ble_command: bool,
671 #[serde(default, rename = "wifiCommand")]
673 pub wifi_command: bool,
674 #[serde(default, rename = "blufiCommand")]
676 pub blufi_command: bool,
677 #[serde(default, rename = "customDevices")]
681 pub custom_devices: bool,
682 #[serde(default)]
685 pub binaries: bool,
686 #[serde(default, rename = "safetyLimits")]
691 pub safety_limits: bool,
692}
693
694#[derive(Debug, Clone, Deserialize)]
696pub struct BoxStatus {
697 #[serde(default)]
699 pub healthy: bool,
700 #[serde(default)]
702 pub version: String,
703 #[serde(default)]
705 pub nets: Vec<NetSummary>,
706 #[serde(default)]
708 pub capabilities: BoxCapabilities,
709}
710
711#[derive(Debug, Clone, Deserialize)]
716pub struct SupplyState {
717 #[serde(default)]
719 pub netname: Option<String>,
720 #[serde(default, deserialize_with = "lenient::opt_i64")]
722 pub channel: Option<i64>,
723 #[serde(default)]
725 pub error: Option<String>,
726 #[serde(default, deserialize_with = "lenient::opt_f64")]
728 pub voltage: Option<f64>,
729 #[serde(default, deserialize_with = "lenient::opt_f64")]
731 pub current: Option<f64>,
732 #[serde(default, deserialize_with = "lenient::opt_f64")]
734 pub power: Option<f64>,
735 #[serde(default, deserialize_with = "lenient::opt_bool")]
737 pub enabled: Option<bool>,
738 #[serde(default)]
740 pub mode: Option<String>,
741 #[serde(default, deserialize_with = "lenient::opt_f64")]
743 pub voltage_set: Option<f64>,
744 #[serde(default, deserialize_with = "lenient::opt_f64")]
746 pub current_set: Option<f64>,
747 #[serde(default, deserialize_with = "lenient::opt_f64")]
749 pub voltage_max: Option<f64>,
750 #[serde(default, deserialize_with = "lenient::opt_f64")]
752 pub current_max: Option<f64>,
753 #[serde(default, deserialize_with = "lenient::opt_f64")]
755 pub ocp_limit: Option<f64>,
756 #[serde(default, deserialize_with = "lenient::opt_bool")]
758 pub ocp_tripped: Option<bool>,
759 #[serde(default, deserialize_with = "lenient::opt_f64")]
761 pub ovp_limit: Option<f64>,
762 #[serde(default, deserialize_with = "lenient::opt_bool")]
764 pub ovp_tripped: Option<bool>,
765}
766
767#[derive(Debug, Clone, Deserialize)]
771pub struct BatteryState {
772 #[serde(default)]
774 pub netname: Option<String>,
775 #[serde(default, deserialize_with = "lenient::opt_i64")]
777 pub channel: Option<i64>,
778 #[serde(default)]
780 pub error: Option<String>,
781 #[serde(default, deserialize_with = "lenient::opt_f64")]
783 pub terminal_voltage: Option<f64>,
784 #[serde(default, deserialize_with = "lenient::opt_f64")]
786 pub current: Option<f64>,
787 #[serde(default, deserialize_with = "lenient::opt_f64")]
789 pub esr: Option<f64>,
790 #[serde(default, deserialize_with = "lenient::opt_f64")]
792 pub soc: Option<f64>,
793 #[serde(default, deserialize_with = "lenient::opt_f64")]
795 pub voc: Option<f64>,
796 #[serde(default, deserialize_with = "lenient::opt_bool")]
798 pub enabled: Option<bool>,
799 #[serde(default)]
801 pub mode: Option<String>,
802 #[serde(default)]
804 pub model: Option<String>,
805 #[serde(default, deserialize_with = "lenient::opt_f64")]
807 pub capacity: Option<f64>,
808 #[serde(default, deserialize_with = "lenient::opt_f64")]
810 pub current_limit: Option<f64>,
811 #[serde(default, deserialize_with = "lenient::opt_f64")]
813 pub ocp_limit: Option<f64>,
814 #[serde(default, deserialize_with = "lenient::opt_f64")]
816 pub ovp_limit: Option<f64>,
817 #[serde(default, deserialize_with = "lenient::opt_f64")]
819 pub volt_full: Option<f64>,
820 #[serde(default, deserialize_with = "lenient::opt_f64")]
822 pub volt_empty: Option<f64>,
823 #[serde(default, deserialize_with = "lenient::opt_bool")]
825 pub ocp_tripped: Option<bool>,
826 #[serde(default, deserialize_with = "lenient::opt_bool")]
828 pub ovp_tripped: Option<bool>,
829}
830
831#[derive(Debug, Clone, Deserialize)]
833pub struct EloadState {
834 #[serde(default)]
836 pub mode: Option<String>,
837 #[serde(default, deserialize_with = "lenient::opt_bool")]
839 pub input_enabled: Option<bool>,
840 #[serde(default, deserialize_with = "lenient::opt_f64")]
842 pub measured_voltage: Option<f64>,
843 #[serde(default, deserialize_with = "lenient::opt_f64")]
845 pub measured_current: Option<f64>,
846 #[serde(default, deserialize_with = "lenient::opt_f64")]
848 pub measured_power: Option<f64>,
849 #[serde(flatten)]
851 pub extra: Map<String, Value>,
852}
853
854#[derive(Debug, Clone, Deserialize)]
856pub struct WattReading {
857 #[serde(default, deserialize_with = "lenient::opt_f64")]
859 pub current: Option<f64>,
860 #[serde(default, deserialize_with = "lenient::opt_f64")]
862 pub voltage: Option<f64>,
863 #[serde(default, deserialize_with = "lenient::opt_f64")]
865 pub power: Option<f64>,
866 #[serde(default, deserialize_with = "lenient::opt_f64")]
868 pub duration_s: Option<f64>,
869}
870
871#[derive(Debug, Clone, Deserialize)]
873pub struct EnergyReading {
874 #[serde(default, deserialize_with = "lenient::opt_f64")]
876 pub energy_j: Option<f64>,
877 #[serde(default, deserialize_with = "lenient::opt_f64")]
879 pub charge_c: Option<f64>,
880 #[serde(default, deserialize_with = "lenient::opt_f64")]
882 pub duration_s: Option<f64>,
883 #[serde(flatten)]
885 pub extra: Map<String, Value>,
886}
887
888#[derive(Debug, Clone, Default, Deserialize)]
890pub struct StatSummary {
891 #[serde(default, deserialize_with = "lenient::opt_f64")]
893 pub mean: Option<f64>,
894 #[serde(default, deserialize_with = "lenient::opt_f64")]
896 pub min: Option<f64>,
897 #[serde(default, deserialize_with = "lenient::opt_f64")]
899 pub max: Option<f64>,
900 #[serde(default, deserialize_with = "lenient::opt_f64")]
902 pub std: Option<f64>,
903 #[serde(flatten)]
905 pub extra: Map<String, Value>,
906}
907
908#[derive(Debug, Clone, Deserialize)]
910pub struct EnergyStats {
911 #[serde(default)]
913 pub current: Option<StatSummary>,
914 #[serde(default)]
916 pub voltage: Option<StatSummary>,
917 #[serde(default)]
919 pub power: Option<StatSummary>,
920 #[serde(flatten)]
922 pub extra: Map<String, Value>,
923}
924
925#[derive(Debug, Clone, Copy, PartialEq)]
932pub struct ArmPosition {
933 pub x: f64,
935 pub y: f64,
937 pub z: f64,
939}
940
941pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
943 let arr = resp
944 .value
945 .as_ref()
946 .and_then(Value::as_array)
947 .ok_or_else(|| Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value)))?;
948 match arr.as_slice() {
949 [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
950 (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
951 _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
952 },
953 _ => Err(Error::Decode(format!(
954 "expected 3-element arm position, got {} elements",
955 arr.len()
956 ))),
957 }
958}
959
960#[derive(Debug, Clone, Deserialize)]
962pub struct WebcamStream {
963 #[serde(default)]
965 pub url: String,
966 #[serde(default, deserialize_with = "lenient::opt_i64")]
968 pub port: Option<i64>,
969 #[serde(default)]
971 pub already_running: bool,
972}
973
974#[derive(Debug, Clone, Deserialize)]
976pub struct WebcamStatus {
977 #[serde(default)]
979 pub running: bool,
980 #[serde(default)]
982 pub url: Option<String>,
983 #[serde(default, deserialize_with = "lenient::opt_i64")]
985 pub port: Option<i64>,
986 #[serde(default)]
988 pub video_device: Option<String>,
989}
990
991#[derive(Debug, Clone, Deserialize)]
993pub struct RouterSystemInfo {
994 #[serde(default)]
996 pub name: Option<String>,
997 #[serde(default)]
999 pub version: Option<String>,
1000 #[serde(default)]
1002 pub board: Option<String>,
1003 #[serde(default)]
1005 pub architecture: Option<String>,
1006 #[serde(default)]
1008 pub uptime: Option<String>,
1009 #[serde(default, deserialize_with = "lenient::opt_i64")]
1011 pub cpu_load: Option<i64>,
1012 #[serde(default, deserialize_with = "lenient::opt_i64")]
1014 pub free_memory: Option<i64>,
1015 #[serde(default, deserialize_with = "lenient::opt_i64")]
1017 pub total_memory: Option<i64>,
1018 #[serde(default, deserialize_with = "lenient::opt_i64")]
1020 pub free_hdd_space: Option<i64>,
1021 #[serde(flatten)]
1023 pub extra: Map<String, Value>,
1024}
1025
1026#[derive(Debug, Clone, Deserialize)]
1028pub struct BleDevice {
1029 #[serde(default)]
1031 pub name: String,
1032 #[serde(default)]
1034 pub address: String,
1035 #[serde(default, deserialize_with = "lenient::opt_i64")]
1037 pub rssi: Option<i64>,
1038 #[serde(default)]
1040 pub uuids: Vec<String>,
1041}
1042
1043#[derive(Debug, Clone, Deserialize)]
1045pub struct BleCharacteristic {
1046 #[serde(default)]
1048 pub uuid: String,
1049 #[serde(default)]
1051 pub description: Option<String>,
1052 #[serde(default)]
1054 pub properties: Vec<String>,
1055}
1056
1057#[derive(Debug, Clone, Deserialize)]
1059pub struct BleService {
1060 #[serde(default)]
1062 pub uuid: String,
1063 #[serde(default)]
1065 pub description: Option<String>,
1066 #[serde(default)]
1068 pub characteristics: Vec<BleCharacteristic>,
1069}
1070
1071#[derive(Debug, Clone, Deserialize)]
1073pub struct BleDeviceInfo {
1074 #[serde(default)]
1076 pub address: String,
1077 #[serde(default)]
1079 pub connected: bool,
1080 #[serde(default)]
1082 pub services: Vec<BleService>,
1083}
1084
1085#[derive(Debug, Clone, Deserialize)]
1087pub struct WifiInterface {
1088 #[serde(default)]
1090 pub interface: String,
1091 #[serde(default)]
1093 pub ssid: String,
1094 #[serde(default)]
1096 pub state: String,
1097}
1098
1099#[derive(Debug, Clone, Deserialize)]
1101pub struct WifiAccessPoint {
1102 #[serde(default)]
1104 pub ssid: Option<String>,
1105 #[serde(default)]
1107 pub address: Option<String>,
1108 #[serde(default, deserialize_with = "lenient::opt_i64")]
1110 pub strength: Option<i64>,
1111 #[serde(default)]
1113 pub security: Option<String>,
1114}
1115
1116#[derive(Debug, Clone, Deserialize)]
1118pub struct WifiConnection {
1119 #[serde(default)]
1121 pub ssid: String,
1122 #[serde(default)]
1124 pub connected: bool,
1125 #[serde(default)]
1127 pub interface: Option<String>,
1128 #[serde(default)]
1130 pub method: Option<String>,
1131}
1132
1133#[derive(Debug, Clone, Deserialize)]
1136pub struct BlufiStatus {
1137 #[serde(default)]
1139 pub device_name: Option<String>,
1140 #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
1142 pub op_mode: Option<i64>,
1143 #[serde(default, rename = "opModeName")]
1145 pub op_mode_name: Option<String>,
1146 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1148 pub sta_conn: Option<i64>,
1149 #[serde(default, rename = "staConnName")]
1151 pub sta_conn_name: Option<String>,
1152 #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
1154 pub soft_ap_conn: Option<i64>,
1155}
1156
1157#[derive(Debug, Clone, Deserialize)]
1159pub struct BlufiDeviceInfo {
1160 #[serde(default)]
1162 pub version: Option<String>,
1163 #[serde(flatten)]
1165 pub status: BlufiStatus,
1166}
1167
1168#[derive(Debug, Clone, Deserialize)]
1170pub struct BlufiProvisionResult {
1171 #[serde(default)]
1173 pub device_name: Option<String>,
1174 #[serde(default)]
1176 pub ssid: String,
1177 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1179 pub sta_conn: Option<i64>,
1180 #[serde(default, rename = "staConnName")]
1182 pub sta_conn_name: Option<String>,
1183}
1184
1185#[derive(Debug, Clone, Deserialize)]
1187pub struct BlufiNetwork {
1188 #[serde(default)]
1190 pub ssid: String,
1191 #[serde(default, deserialize_with = "lenient::opt_i64")]
1193 pub rssi: Option<i64>,
1194}
1195
1196pub(crate) fn value_list_field<T: DeserializeOwned>(
1199 resp: CommandResponse,
1200 field: &str,
1201) -> Result<Vec<T>> {
1202 let list = resp
1203 .value
1204 .as_ref()
1205 .and_then(|v| v.get(field))
1206 .cloned()
1207 .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
1208 serde_json::from_value(list)
1209 .map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
1210}
1211
1212#[derive(Debug, Clone, Default)]
1220pub struct UsbDeviceFilter {
1221 pub vid: Option<String>,
1223 pub pid: Option<String>,
1225 pub serial: Option<String>,
1227}
1228
1229#[derive(Debug, Clone, Deserialize)]
1233pub struct UsbDeviceInfo {
1234 #[serde(default)]
1236 pub sysfs_name: String,
1237 #[serde(default)]
1239 pub vid: Option<String>,
1240 #[serde(default)]
1242 pub pid: Option<String>,
1243 #[serde(default)]
1245 pub serial: Option<String>,
1246 #[serde(default)]
1248 pub product: Option<String>,
1249 #[serde(default)]
1251 pub manufacturer: Option<String>,
1252 #[serde(default)]
1254 pub busnum: Option<String>,
1255 #[serde(default)]
1257 pub devnum: Option<String>,
1258 #[serde(default)]
1260 pub devpath: Option<String>,
1261 #[serde(default)]
1263 pub device_class: Option<String>,
1264 #[serde(default)]
1266 pub speed: Option<String>,
1267}
1268
1269fn encode_query_value(s: &str) -> String {
1271 let mut out = String::with_capacity(s.len());
1272 for b in s.bytes() {
1273 match b {
1274 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1275 out.push(b as char)
1276 }
1277 _ => out.push_str(&format!("%{b:02X}")),
1278 }
1279 }
1280 out
1281}
1282
1283pub fn usb_devices(filter: &UsbDeviceFilter) -> HttpRequest {
1285 let mut query = Vec::new();
1286 for (key, value) in [
1287 ("vid", &filter.vid),
1288 ("pid", &filter.pid),
1289 ("serial", &filter.serial),
1290 ] {
1291 if let Some(value) = value {
1292 query.push(format!("{key}={}", encode_query_value(value)));
1293 }
1294 }
1295 let path = if query.is_empty() {
1296 "/usb/devices".to_string()
1297 } else {
1298 format!("/usb/devices?{}", query.join("&"))
1299 };
1300 HttpRequest {
1301 method: Method::Get,
1302 path,
1303 body: None,
1304 timeout: Timeout::Default,
1305 }
1306}
1307
1308pub fn parse_usb_devices(status: u16, body: Value) -> Result<Vec<UsbDeviceInfo>> {
1310 if status == 404 {
1311 return Err(usb_devices_unsupported());
1312 }
1313 let resp = parse_command(status, body)?;
1314 let devices = resp
1315 .extra
1316 .get("devices")
1317 .cloned()
1318 .ok_or_else(|| Error::Decode("response has no 'devices' list".to_string()))?;
1319 serde_json::from_value(devices)
1320 .map_err(|e| Error::Decode(format!("invalid 'devices' shape: {e}")))
1321}
1322
1323pub(crate) fn usb_devices_unsupported() -> Error {
1324 Error::UnsupportedByBox {
1325 message: "this box does not serve GET /usb/devices (requires box software >= 0.33.0)"
1326 .to_string(),
1327 }
1328}
1329
1330pub(crate) fn map_route_missing(err: Error, unsupported: fn() -> Error) -> Error {
1334 match err {
1335 Error::Box {
1336 status: 404,
1337 ref message,
1338 } if message.contains("non-JSON") => unsupported(),
1339 other => other,
1340 }
1341}
1342
1343#[derive(Debug, Clone, Deserialize)]
1349pub struct DfuDevice {
1350 #[serde(default)]
1352 pub mode: String,
1353 #[serde(default)]
1355 pub vid: String,
1356 #[serde(default)]
1358 pub pid: String,
1359 #[serde(default, deserialize_with = "lenient::opt_i64")]
1361 pub devnum: Option<i64>,
1362 #[serde(default, deserialize_with = "lenient::opt_i64")]
1364 pub cfg: Option<i64>,
1365 #[serde(default, deserialize_with = "lenient::opt_i64")]
1367 pub intf: Option<i64>,
1368 #[serde(default, deserialize_with = "lenient::opt_i64")]
1370 pub alt: Option<i64>,
1371 #[serde(default)]
1373 pub name: Option<String>,
1374 #[serde(default)]
1376 pub serial: Option<String>,
1377 #[serde(default)]
1379 pub path: Option<String>,
1380}
1381
1382#[derive(Debug, Clone, Deserialize)]
1384pub struct DfuOutput {
1385 #[serde(default, deserialize_with = "lenient::opt_i64")]
1387 pub exit_code: Option<i64>,
1388 #[serde(default)]
1390 pub stdout: String,
1391 #[serde(default)]
1393 pub stderr: String,
1394}
1395
1396pub(crate) fn dfu_unsupported() -> Error {
1397 Error::UnsupportedByBox {
1398 message: "this box does not serve POST /usb/dfu (requires box software >= 0.33.0)"
1399 .to_string(),
1400 }
1401}
1402
1403#[derive(Debug, Clone, Deserialize)]
1411pub struct BoxLock {
1412 #[serde(default)]
1414 pub locked: bool,
1415 #[serde(default)]
1417 pub user: Option<String>,
1418 #[serde(default)]
1421 pub holder_type: Option<String>,
1422 #[serde(default)]
1424 pub locked_at: Option<String>,
1425 #[serde(default)]
1427 pub last_heartbeat: Option<String>,
1428 #[serde(default, deserialize_with = "lenient::opt_i64")]
1430 pub ttl_seconds: Option<i64>,
1431 #[serde(default)]
1434 pub previous_user: Option<String>,
1435}
1436
1437pub fn lock_status() -> HttpRequest {
1439 get("/lock")
1440}
1441
1442pub fn lock_acquire(user: &str, holder_type: &str, ttl_seconds: Option<u64>) -> HttpRequest {
1445 HttpRequest {
1446 method: Method::Post,
1447 path: "/lock".to_string(),
1448 body: Some(json!({
1449 "user": user,
1450 "holder_type": holder_type,
1451 "ttl_seconds": ttl_seconds,
1452 })),
1453 timeout: Timeout::Default,
1454 }
1455}
1456
1457pub fn lock_heartbeat(user: &str) -> HttpRequest {
1459 HttpRequest {
1460 method: Method::Post,
1461 path: "/lock/heartbeat".to_string(),
1462 body: Some(json!({ "user": user })),
1463 timeout: Timeout::Default,
1464 }
1465}
1466
1467pub fn unlock(user: &str, force: bool) -> HttpRequest {
1469 HttpRequest {
1470 method: Method::Post,
1471 path: "/unlock".to_string(),
1472 body: Some(json!({ "user": user, "force": force })),
1473 timeout: Timeout::Default,
1474 }
1475}
1476
1477pub fn parse_lock(status: u16, body: Value) -> Result<BoxLock> {
1481 if status == 200 {
1482 return serde_json::from_value(body)
1483 .map_err(|e| Error::Decode(format!("invalid lock state: {e}")));
1484 }
1485 let message = body
1486 .get("error")
1487 .and_then(Value::as_str)
1488 .unwrap_or("lock request failed")
1489 .to_string();
1490 Err(Error::Box { status, message })
1491}
1492
1493pub(crate) fn lock_unsupported() -> Error {
1494 Error::UnsupportedByBox {
1495 message: "this box does not serve the /lock endpoints on port 9000; \
1496 update the box software"
1497 .to_string(),
1498 }
1499}
1500
1501#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1519pub struct SafetyLimits {
1520 #[serde(default, skip_serializing_if = "Option::is_none")]
1524 pub max_voltage: Option<f64>,
1525 #[serde(default, skip_serializing_if = "Option::is_none")]
1527 pub max_current: Option<f64>,
1528 #[serde(default, skip_serializing_if = "Option::is_none")]
1531 pub allow_destructive: Option<bool>,
1532}
1533
1534impl SafetyLimits {
1535 pub fn is_empty(&self) -> bool {
1538 self.max_voltage.is_none() && self.max_current.is_none() && self.allow_destructive.is_none()
1539 }
1540}
1541
1542pub fn safety_limits_set(name: &str, limits: &SafetyLimits) -> HttpRequest {
1545 HttpRequest {
1546 method: Method::Put,
1547 path: format!("/nets/{name}/safety-limits"),
1548 body: Some(serde_json::to_value(limits).unwrap_or_else(|_| json!({}))),
1549 timeout: Timeout::Default,
1550 }
1551}
1552
1553pub fn parse_safety_limits(status: u16, body: Value) -> Result<Option<SafetyLimits>> {
1558 if status == 200 {
1559 return match body.get("safety_limits") {
1560 None | Some(Value::Null) => Ok(None),
1561 Some(limits) => serde_json::from_value(limits.clone())
1562 .map(Some)
1563 .map_err(|e| Error::Decode(format!("invalid safety_limits shape: {e}"))),
1564 };
1565 }
1566 let message = body
1567 .get("error")
1568 .and_then(Value::as_str)
1569 .unwrap_or("safety-limits request failed")
1570 .to_string();
1571 Err(Error::Box { status, message })
1572}
1573
1574pub(crate) fn safety_limits_unsupported() -> Error {
1575 Error::UnsupportedByBox {
1576 message: "this box does not serve PUT /nets/<name>/safety-limits \
1577 (requires box software >= 0.35.0)"
1578 .to_string(),
1579 }
1580}
1581
1582#[derive(Debug, Clone, Deserialize)]
1593pub struct NetState {
1594 pub name: String,
1596 #[serde(default)]
1598 pub role: String,
1599 #[serde(default)]
1602 pub state: Option<String>,
1603 #[serde(default)]
1608 pub reason: Option<String>,
1609 #[serde(default)]
1613 pub reason_code: Option<String>,
1614 #[serde(flatten)]
1616 pub extra: Map<String, Value>,
1617}
1618
1619pub fn nets_state() -> HttpRequest {
1623 HttpRequest {
1624 method: Method::Get,
1625 path: "/nets/state".to_string(),
1626 body: None,
1627 timeout: Timeout::After(Duration::from_secs(15)),
1628 }
1629}
1630
1631pub fn parse_nets_state(status: u16, body: Value) -> Result<Vec<NetState>> {
1634 if status != 200 {
1635 let message = body
1636 .get("error")
1637 .and_then(Value::as_str)
1638 .unwrap_or("nets/state request failed")
1639 .to_string();
1640 return Err(Error::Box { status, message });
1641 }
1642 serde_json::from_value(body)
1643 .map_err(|e| Error::Decode(format!("invalid nets/state shape: {e}")))
1644}
1645
1646pub(crate) fn nets_state_unsupported() -> Error {
1647 Error::UnsupportedByBox {
1648 message: "this box does not serve GET /nets/state (requires box software >= 0.34.0)"
1649 .to_string(),
1650 }
1651}
1652
1653#[cfg(test)]
1654mod tests {
1655 use super::*;
1656
1657 #[test]
1658 fn parse_success_envelope() {
1659 let resp = parse_command(
1660 200,
1661 json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
1662 )
1663 .unwrap();
1664 assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
1665 }
1666
1667 #[test]
1668 fn parse_failure_with_http_200_is_box_error() {
1669 let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
1671 match err {
1672 Error::Box { status, message } => {
1673 assert_eq!(status, 200);
1674 assert_eq!(message, "conflict");
1675 }
1676 other => panic!("unexpected error: {other:?}"),
1677 }
1678 }
1679
1680 #[test]
1681 fn parse_501_is_unsupported() {
1682 let err = parse_command(
1683 501,
1684 json!({"success": false, "error": "Role 'gpio' is not supported"}),
1685 )
1686 .unwrap_err();
1687 assert!(matches!(err, Error::UnsupportedByBox { .. }));
1688 }
1689
1690 #[test]
1691 fn base_url_normalization() {
1692 assert_eq!(base_url("192.168.1.42").unwrap(), "http://192.168.1.42:9000");
1693 assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
1694 assert_eq!(base_url("192.168.1.42:8080").unwrap(), "http://192.168.1.42:8080");
1695 assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
1696 assert!(base_url("").is_err());
1697 assert!(base_url("http://").is_err());
1698 }
1699
1700 #[test]
1701 fn lenient_numbers() {
1702 assert_eq!(as_f64(&json!("3.3")), Some(3.3));
1703 assert_eq!(as_f64(&json!(3.3)), Some(3.3));
1704 assert_eq!(as_bool(&json!("ON")), Some(true));
1705 assert_eq!(as_bool(&json!(0)), Some(false));
1706 assert_eq!(as_i64(&json!("42")), Some(42));
1707 }
1708
1709 #[test]
1710 fn supply_state_tolerates_nulls_and_strings() {
1711 let state: SupplyState = serde_json::from_value(json!({
1712 "netname": "supply1", "channel": "1", "error": null,
1713 "voltage": "3.3", "current": null, "enabled": 1,
1714 }))
1715 .unwrap();
1716 assert_eq!(state.channel, Some(1));
1717 assert_eq!(state.voltage, Some(3.3));
1718 assert_eq!(state.current, None);
1719 assert_eq!(state.enabled, Some(true));
1720 }
1721
1722 #[test]
1723 fn safety_limits_body_omits_unset_fields() {
1724 let req = safety_limits_set(
1728 "supply1",
1729 &SafetyLimits {
1730 max_voltage: Some(5.0),
1731 ..Default::default()
1732 },
1733 );
1734 assert_eq!(req.method, Method::Put);
1735 assert_eq!(req.path, "/nets/supply1/safety-limits");
1736 assert_eq!(req.body, Some(json!({ "max_voltage": 5.0 })));
1737
1738 let clear = safety_limits_set("supply1", &SafetyLimits::default());
1739 assert_eq!(clear.body, Some(json!({})));
1740 assert!(SafetyLimits::default().is_empty());
1741 }
1742
1743 #[test]
1744 fn parse_safety_limits_roundtrip_and_clear() {
1745 let applied = parse_safety_limits(
1746 200,
1747 json!({"ok": true, "name": "supply1",
1748 "safety_limits": {"max_voltage": 5.0, "allow_destructive": false}}),
1749 )
1750 .unwrap()
1751 .unwrap();
1752 assert_eq!(applied.max_voltage, Some(5.0));
1753 assert_eq!(applied.max_current, None);
1754 assert_eq!(applied.allow_destructive, Some(false));
1755
1756 let cleared =
1758 parse_safety_limits(200, json!({"ok": true, "safety_limits": null})).unwrap();
1759 assert!(cleared.is_none());
1760 }
1761
1762 #[test]
1763 fn parse_safety_limits_surfaces_box_refusals() {
1764 let err = parse_safety_limits(
1765 400,
1766 json!({"error": "max_power is not supported: ..."}),
1767 )
1768 .unwrap_err();
1769 match err {
1770 Error::Box { status, message } => {
1771 assert_eq!(status, 400);
1772 assert!(message.contains("max_power"));
1773 }
1774 other => panic!("unexpected error: {other:?}"),
1775 }
1776 }
1777
1778 #[test]
1779 fn net_record_carries_safety_limits() {
1780 let rec: NetRecord = serde_json::from_value(json!({
1781 "name": "supply1", "role": "power-supply",
1782 "safety_limits": {"max_voltage": 12.0, "max_current": 2.5},
1783 }))
1784 .unwrap();
1785 let limits = rec.safety_limits.unwrap();
1786 assert_eq!(limits.max_voltage, Some(12.0));
1787 assert_eq!(limits.max_current, Some(2.5));
1788 assert_eq!(limits.allow_destructive, None);
1789
1790 let rec: NetRecord =
1792 serde_json::from_value(json!({"name": "gpio1", "role": "gpio"})).unwrap();
1793 assert!(rec.safety_limits.is_none());
1794 }
1795
1796 #[test]
1797 fn capabilities_parse_safety_limits_flag() {
1798 let caps: BoxCapabilities =
1799 serde_json::from_value(json!({"netCommand": true, "safetyLimits": true})).unwrap();
1800 assert!(caps.safety_limits);
1801 let caps: BoxCapabilities = serde_json::from_value(json!({"netCommand": true})).unwrap();
1803 assert!(!caps.safety_limits);
1804 }
1805}