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 box_command(path: &str, action: &str, params: Value, timeout: Timeout) -> HttpRequest {
215 HttpRequest {
216 method: Method::Post,
217 path: path.to_string(),
218 body: Some(json!({
219 "action": action,
220 "params": params,
221 })),
222 timeout,
223 }
224}
225
226pub fn get(path: &str) -> HttpRequest {
228 HttpRequest {
229 method: Method::Get,
230 path: path.to_string(),
231 body: None,
232 timeout: Timeout::Default,
233 }
234}
235
236pub(crate) fn base_url(host: &str) -> Result<String> {
242 base_url_with_port(host, DEFAULT_PORT)
243}
244
245pub(crate) fn base_url_with_port(host: &str, default_port: u16) -> Result<String> {
248 let input = host.trim();
249 let (scheme, rest) = match input.split_once("://") {
250 Some((scheme, rest)) => (scheme, rest),
251 None => ("http", input),
252 };
253 let rest = rest.trim_end_matches('/');
254 if scheme.is_empty() || rest.is_empty() {
255 return Err(Error::Config(format!("invalid box host '{host}'")));
256 }
257 if rest.contains(':') {
258 Ok(format!("{scheme}://{rest}"))
259 } else {
260 Ok(format!("{scheme}://{rest}:{default_port}"))
261 }
262}
263
264pub(crate) fn service_base(base: &str, port: u16) -> String {
268 let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
269 let host = rest.rsplit_once(':').map(|(h, _)| h).unwrap_or(rest);
270 format!("{scheme}://{host}:{port}")
271}
272
273pub fn debug_request(path: &str, body: Value, timeout: Timeout) -> HttpRequest {
279 HttpRequest {
280 method: Method::Post,
281 path: path.to_string(),
282 body: Some(body),
283 timeout,
284 }
285}
286
287pub fn parse_debug(status: u16, body: Value) -> Result<Value> {
291 if status == 200 {
292 return Ok(body);
293 }
294 let message = body
295 .get("error")
296 .and_then(Value::as_str)
297 .or_else(|| body.get("message").and_then(Value::as_str))
298 .unwrap_or("debug service request failed")
299 .to_string();
300 Err(Error::Box { status, message })
301}
302
303#[derive(Debug, Clone, Deserialize)]
305pub struct GdbServer {
306 #[serde(default)]
308 pub status: Option<String>,
309 #[serde(default, deserialize_with = "lenient::opt_i64")]
311 pub gdb_port: Option<i64>,
312 #[serde(default, deserialize_with = "lenient::opt_i64")]
314 pub swo_port: Option<i64>,
315 #[serde(default, deserialize_with = "lenient::opt_i64")]
317 pub telnet_port: Option<i64>,
318 #[serde(default, deserialize_with = "lenient::opt_i64")]
320 pub tcl_port: Option<i64>,
321 #[serde(default, deserialize_with = "lenient::opt_i64")]
323 pub rtt_telnet_port: Option<i64>,
324 #[serde(default, deserialize_with = "lenient::opt_i64")]
326 pub pid: Option<i64>,
327}
328
329#[derive(Debug, Clone, Deserialize)]
331pub struct DebugConnection {
332 #[serde(default)]
334 pub status: Option<String>,
335 #[serde(default)]
337 pub device: Option<String>,
338 #[serde(default)]
340 pub probe: Option<String>,
341 #[serde(default)]
343 pub serial: Option<String>,
344 #[serde(default)]
346 pub backend: Option<String>,
347 #[serde(default)]
349 pub message: Option<String>,
350 #[serde(default, deserialize_with = "lenient::opt_i64")]
352 pub pid: Option<i64>,
353 #[serde(default)]
355 pub gdb_server: Option<GdbServer>,
356}
357
358#[derive(Debug, Clone, Deserialize)]
360pub struct DebugInfo {
361 #[serde(default)]
363 pub net_name: Option<String>,
364 #[serde(default)]
366 pub device: Option<String>,
367 #[serde(default)]
369 pub arch: Option<String>,
370 #[serde(default)]
372 pub probe: Option<String>,
373 #[serde(default)]
375 pub serial: Option<String>,
376 #[serde(default)]
378 pub backend: Option<String>,
379 #[serde(default)]
381 pub connected: bool,
382}
383
384#[derive(Debug, Clone, Deserialize)]
386pub struct DebugStatus {
387 #[serde(default)]
389 pub connected: bool,
390 #[serde(default, deserialize_with = "lenient::opt_i64")]
392 pub pid: Option<i64>,
393 #[serde(default)]
395 pub serial: Option<String>,
396 #[serde(default)]
398 pub backend: Option<String>,
399}
400
401pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
403 let hex = body
404 .get("data")
405 .and_then(Value::as_str)
406 .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
407 decode_hex(hex)
408}
409
410pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
412 let s = s.trim();
413 if s.len() % 2 != 0 {
414 return Err(Error::Decode("odd-length hex string".to_string()));
415 }
416 (0..s.len())
417 .step_by(2)
418 .map(|i| {
419 u8::from_str_radix(&s[i..i + 2], 16)
420 .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
421 })
422 .collect()
423}
424
425pub(crate) fn as_f64(v: &Value) -> Option<f64> {
433 match v {
434 Value::Number(n) => n.as_f64(),
435 Value::String(s) => s.trim().parse().ok(),
436 Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
437 _ => None,
438 }
439}
440
441pub(crate) fn as_i64(v: &Value) -> Option<i64> {
442 match v {
443 Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
444 Value::String(s) => s
445 .trim()
446 .parse::<i64>()
447 .ok()
448 .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
449 Value::Bool(b) => Some(i64::from(*b)),
450 _ => None,
451 }
452}
453
454pub(crate) fn as_bool(v: &Value) -> Option<bool> {
455 match v {
456 Value::Bool(b) => Some(*b),
457 Value::Number(n) => n.as_f64().map(|f| f != 0.0),
458 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
459 "true" | "on" | "1" | "yes" | "enabled" => Some(true),
460 "false" | "off" | "0" | "no" | "disabled" => Some(false),
461 _ => None,
462 },
463 _ => None,
464 }
465}
466
467pub(crate) mod lenient {
468 use serde::{Deserialize, Deserializer};
472 use serde_json::Value;
473
474 pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
475 let v = Option::<Value>::deserialize(d)?;
476 Ok(v.as_ref().and_then(super::as_f64))
477 }
478
479 pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
480 let v = Option::<Value>::deserialize(d)?;
481 Ok(v.as_ref().and_then(super::as_i64))
482 }
483
484 pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
485 let v = Option::<Value>::deserialize(d)?;
486 Ok(v.as_ref().and_then(super::as_bool))
487 }
488}
489
490pub fn value_f64(resp: CommandResponse) -> Result<f64> {
496 resp.value
497 .as_ref()
498 .and_then(as_f64)
499 .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
500}
501
502pub fn value_i64(resp: CommandResponse) -> Result<i64> {
504 resp.value
505 .as_ref()
506 .and_then(as_i64)
507 .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
508}
509
510pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
513 let arr = resp
514 .value
515 .as_ref()
516 .and_then(Value::as_array)
517 .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
518 arr.iter()
519 .map(|v| {
520 as_i64(v)
521 .and_then(|n| u32::try_from(n).ok())
522 .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
523 })
524 .collect()
525}
526
527pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
529 let v = resp
530 .value
531 .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
532 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
533}
534
535pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
537 let v = resp
538 .state
539 .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
540 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
541}
542
543pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
546 match body {
547 Value::Array(a) => a,
548 Value::Object(mut o) => match o.remove("nets") {
549 Some(Value::Array(a)) => a,
550 _ => vec![],
551 },
552 _ => vec![],
553 }
554}
555
556pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
558 let list = Value::Array(nets_list_values(body));
559 serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
560}
561
562pub fn unit(_resp: CommandResponse) -> Result<()> {
564 Ok(())
565}
566
567pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
569 Ok(resp)
570}
571
572#[derive(Debug, Clone, Deserialize)]
578pub struct NetRecord {
579 #[serde(default)]
581 pub name: String,
582 #[serde(default)]
584 pub role: String,
585 #[serde(default)]
587 pub instrument: Option<String>,
588 #[serde(default)]
590 pub pin: Option<Value>,
591 #[serde(default)]
593 pub channel: Option<Value>,
594 #[serde(default)]
596 pub address: Option<String>,
597 #[serde(default)]
599 pub params: Option<Map<String, Value>>,
600 #[serde(default)]
603 pub safety_limits: Option<SafetyLimits>,
604 #[serde(flatten)]
606 pub extra: Map<String, Value>,
607}
608
609#[derive(Debug, Clone, Deserialize)]
611pub struct Health {
612 #[serde(default)]
614 pub status: String,
615 #[serde(default)]
617 pub service: Option<String>,
618 #[serde(default)]
620 pub version: Option<String>,
621}
622
623#[derive(Debug, Clone, Deserialize)]
625pub struct NetSummary {
626 #[serde(default)]
628 pub name: String,
629 #[serde(default, rename = "type")]
631 pub net_type: String,
632}
633
634#[derive(Debug, Clone, Default, Deserialize)]
636pub struct BoxCapabilities {
637 #[serde(default, rename = "netCommand")]
640 pub net_command: bool,
641 #[serde(default, rename = "netCommandRoles")]
645 pub net_command_roles: Vec<String>,
646 #[serde(default, rename = "bleCommand")]
648 pub ble_command: bool,
649 #[serde(default, rename = "wifiCommand")]
651 pub wifi_command: bool,
652 #[serde(default, rename = "blufiCommand")]
654 pub blufi_command: bool,
655 #[serde(default, rename = "customDevices")]
659 pub custom_devices: bool,
660 #[serde(default)]
663 pub binaries: bool,
664 #[serde(default, rename = "safetyLimits")]
669 pub safety_limits: bool,
670}
671
672#[derive(Debug, Clone, Deserialize)]
674pub struct BoxStatus {
675 #[serde(default)]
677 pub healthy: bool,
678 #[serde(default)]
680 pub version: String,
681 #[serde(default)]
683 pub nets: Vec<NetSummary>,
684 #[serde(default)]
686 pub capabilities: BoxCapabilities,
687}
688
689#[derive(Debug, Clone, Deserialize)]
694pub struct SupplyState {
695 #[serde(default)]
697 pub netname: Option<String>,
698 #[serde(default, deserialize_with = "lenient::opt_i64")]
700 pub channel: Option<i64>,
701 #[serde(default)]
703 pub error: Option<String>,
704 #[serde(default, deserialize_with = "lenient::opt_f64")]
706 pub voltage: Option<f64>,
707 #[serde(default, deserialize_with = "lenient::opt_f64")]
709 pub current: Option<f64>,
710 #[serde(default, deserialize_with = "lenient::opt_f64")]
712 pub power: Option<f64>,
713 #[serde(default, deserialize_with = "lenient::opt_bool")]
715 pub enabled: Option<bool>,
716 #[serde(default)]
718 pub mode: Option<String>,
719 #[serde(default, deserialize_with = "lenient::opt_f64")]
721 pub voltage_set: Option<f64>,
722 #[serde(default, deserialize_with = "lenient::opt_f64")]
724 pub current_set: Option<f64>,
725 #[serde(default, deserialize_with = "lenient::opt_f64")]
727 pub voltage_max: Option<f64>,
728 #[serde(default, deserialize_with = "lenient::opt_f64")]
730 pub current_max: Option<f64>,
731 #[serde(default, deserialize_with = "lenient::opt_f64")]
733 pub ocp_limit: Option<f64>,
734 #[serde(default, deserialize_with = "lenient::opt_bool")]
736 pub ocp_tripped: Option<bool>,
737 #[serde(default, deserialize_with = "lenient::opt_f64")]
739 pub ovp_limit: Option<f64>,
740 #[serde(default, deserialize_with = "lenient::opt_bool")]
742 pub ovp_tripped: Option<bool>,
743}
744
745#[derive(Debug, Clone, Deserialize)]
749pub struct BatteryState {
750 #[serde(default)]
752 pub netname: Option<String>,
753 #[serde(default, deserialize_with = "lenient::opt_i64")]
755 pub channel: Option<i64>,
756 #[serde(default)]
758 pub error: Option<String>,
759 #[serde(default, deserialize_with = "lenient::opt_f64")]
761 pub terminal_voltage: Option<f64>,
762 #[serde(default, deserialize_with = "lenient::opt_f64")]
764 pub current: Option<f64>,
765 #[serde(default, deserialize_with = "lenient::opt_f64")]
767 pub esr: Option<f64>,
768 #[serde(default, deserialize_with = "lenient::opt_f64")]
770 pub soc: Option<f64>,
771 #[serde(default, deserialize_with = "lenient::opt_f64")]
773 pub voc: Option<f64>,
774 #[serde(default, deserialize_with = "lenient::opt_bool")]
776 pub enabled: Option<bool>,
777 #[serde(default)]
779 pub mode: Option<String>,
780 #[serde(default)]
782 pub model: Option<String>,
783 #[serde(default, deserialize_with = "lenient::opt_f64")]
785 pub capacity: Option<f64>,
786 #[serde(default, deserialize_with = "lenient::opt_f64")]
788 pub current_limit: Option<f64>,
789 #[serde(default, deserialize_with = "lenient::opt_f64")]
791 pub ocp_limit: Option<f64>,
792 #[serde(default, deserialize_with = "lenient::opt_f64")]
794 pub ovp_limit: Option<f64>,
795 #[serde(default, deserialize_with = "lenient::opt_f64")]
797 pub volt_full: Option<f64>,
798 #[serde(default, deserialize_with = "lenient::opt_f64")]
800 pub volt_empty: Option<f64>,
801 #[serde(default, deserialize_with = "lenient::opt_bool")]
803 pub ocp_tripped: Option<bool>,
804 #[serde(default, deserialize_with = "lenient::opt_bool")]
806 pub ovp_tripped: Option<bool>,
807}
808
809#[derive(Debug, Clone, Deserialize)]
811pub struct EloadState {
812 #[serde(default)]
814 pub mode: Option<String>,
815 #[serde(default, deserialize_with = "lenient::opt_bool")]
817 pub input_enabled: Option<bool>,
818 #[serde(default, deserialize_with = "lenient::opt_f64")]
820 pub measured_voltage: Option<f64>,
821 #[serde(default, deserialize_with = "lenient::opt_f64")]
823 pub measured_current: Option<f64>,
824 #[serde(default, deserialize_with = "lenient::opt_f64")]
826 pub measured_power: Option<f64>,
827 #[serde(flatten)]
829 pub extra: Map<String, Value>,
830}
831
832#[derive(Debug, Clone, Deserialize)]
834pub struct WattReading {
835 #[serde(default, deserialize_with = "lenient::opt_f64")]
837 pub current: Option<f64>,
838 #[serde(default, deserialize_with = "lenient::opt_f64")]
840 pub voltage: Option<f64>,
841 #[serde(default, deserialize_with = "lenient::opt_f64")]
843 pub power: Option<f64>,
844 #[serde(default, deserialize_with = "lenient::opt_f64")]
846 pub duration_s: Option<f64>,
847}
848
849#[derive(Debug, Clone, Deserialize)]
851pub struct EnergyReading {
852 #[serde(default, deserialize_with = "lenient::opt_f64")]
854 pub energy_j: Option<f64>,
855 #[serde(default, deserialize_with = "lenient::opt_f64")]
857 pub charge_c: Option<f64>,
858 #[serde(default, deserialize_with = "lenient::opt_f64")]
860 pub duration_s: Option<f64>,
861 #[serde(flatten)]
863 pub extra: Map<String, Value>,
864}
865
866#[derive(Debug, Clone, Default, Deserialize)]
868pub struct StatSummary {
869 #[serde(default, deserialize_with = "lenient::opt_f64")]
871 pub mean: Option<f64>,
872 #[serde(default, deserialize_with = "lenient::opt_f64")]
874 pub min: Option<f64>,
875 #[serde(default, deserialize_with = "lenient::opt_f64")]
877 pub max: Option<f64>,
878 #[serde(default, deserialize_with = "lenient::opt_f64")]
880 pub std: Option<f64>,
881 #[serde(flatten)]
883 pub extra: Map<String, Value>,
884}
885
886#[derive(Debug, Clone, Deserialize)]
888pub struct EnergyStats {
889 #[serde(default)]
891 pub current: Option<StatSummary>,
892 #[serde(default)]
894 pub voltage: Option<StatSummary>,
895 #[serde(default)]
897 pub power: Option<StatSummary>,
898 #[serde(flatten)]
900 pub extra: Map<String, Value>,
901}
902
903#[derive(Debug, Clone, Copy, PartialEq)]
910pub struct ArmPosition {
911 pub x: f64,
913 pub y: f64,
915 pub z: f64,
917}
918
919pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
921 let arr = resp
922 .value
923 .as_ref()
924 .and_then(Value::as_array)
925 .ok_or_else(|| Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value)))?;
926 match arr.as_slice() {
927 [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
928 (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
929 _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
930 },
931 _ => Err(Error::Decode(format!(
932 "expected 3-element arm position, got {} elements",
933 arr.len()
934 ))),
935 }
936}
937
938#[derive(Debug, Clone, Deserialize)]
940pub struct WebcamStream {
941 #[serde(default)]
943 pub url: String,
944 #[serde(default, deserialize_with = "lenient::opt_i64")]
946 pub port: Option<i64>,
947 #[serde(default)]
949 pub already_running: bool,
950}
951
952#[derive(Debug, Clone, Deserialize)]
954pub struct WebcamStatus {
955 #[serde(default)]
957 pub running: bool,
958 #[serde(default)]
960 pub url: Option<String>,
961 #[serde(default, deserialize_with = "lenient::opt_i64")]
963 pub port: Option<i64>,
964 #[serde(default)]
966 pub video_device: Option<String>,
967}
968
969#[derive(Debug, Clone, Deserialize)]
971pub struct RouterSystemInfo {
972 #[serde(default)]
974 pub name: Option<String>,
975 #[serde(default)]
977 pub version: Option<String>,
978 #[serde(default)]
980 pub board: Option<String>,
981 #[serde(default)]
983 pub architecture: Option<String>,
984 #[serde(default)]
986 pub uptime: Option<String>,
987 #[serde(default, deserialize_with = "lenient::opt_i64")]
989 pub cpu_load: Option<i64>,
990 #[serde(default, deserialize_with = "lenient::opt_i64")]
992 pub free_memory: Option<i64>,
993 #[serde(default, deserialize_with = "lenient::opt_i64")]
995 pub total_memory: Option<i64>,
996 #[serde(default, deserialize_with = "lenient::opt_i64")]
998 pub free_hdd_space: Option<i64>,
999 #[serde(flatten)]
1001 pub extra: Map<String, Value>,
1002}
1003
1004#[derive(Debug, Clone, Deserialize)]
1006pub struct BleDevice {
1007 #[serde(default)]
1009 pub name: String,
1010 #[serde(default)]
1012 pub address: String,
1013 #[serde(default, deserialize_with = "lenient::opt_i64")]
1015 pub rssi: Option<i64>,
1016 #[serde(default)]
1018 pub uuids: Vec<String>,
1019}
1020
1021#[derive(Debug, Clone, Deserialize)]
1023pub struct BleCharacteristic {
1024 #[serde(default)]
1026 pub uuid: String,
1027 #[serde(default)]
1029 pub description: Option<String>,
1030 #[serde(default)]
1032 pub properties: Vec<String>,
1033}
1034
1035#[derive(Debug, Clone, Deserialize)]
1037pub struct BleService {
1038 #[serde(default)]
1040 pub uuid: String,
1041 #[serde(default)]
1043 pub description: Option<String>,
1044 #[serde(default)]
1046 pub characteristics: Vec<BleCharacteristic>,
1047}
1048
1049#[derive(Debug, Clone, Deserialize)]
1051pub struct BleDeviceInfo {
1052 #[serde(default)]
1054 pub address: String,
1055 #[serde(default)]
1057 pub connected: bool,
1058 #[serde(default)]
1060 pub services: Vec<BleService>,
1061}
1062
1063#[derive(Debug, Clone, Deserialize)]
1065pub struct WifiInterface {
1066 #[serde(default)]
1068 pub interface: String,
1069 #[serde(default)]
1071 pub ssid: String,
1072 #[serde(default)]
1074 pub state: String,
1075}
1076
1077#[derive(Debug, Clone, Deserialize)]
1079pub struct WifiAccessPoint {
1080 #[serde(default)]
1082 pub ssid: Option<String>,
1083 #[serde(default)]
1085 pub address: Option<String>,
1086 #[serde(default, deserialize_with = "lenient::opt_i64")]
1088 pub strength: Option<i64>,
1089 #[serde(default)]
1091 pub security: Option<String>,
1092}
1093
1094#[derive(Debug, Clone, Deserialize)]
1096pub struct WifiConnection {
1097 #[serde(default)]
1099 pub ssid: String,
1100 #[serde(default)]
1102 pub connected: bool,
1103 #[serde(default)]
1105 pub interface: Option<String>,
1106 #[serde(default)]
1108 pub method: Option<String>,
1109}
1110
1111#[derive(Debug, Clone, Deserialize)]
1114pub struct BlufiStatus {
1115 #[serde(default)]
1117 pub device_name: Option<String>,
1118 #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
1120 pub op_mode: Option<i64>,
1121 #[serde(default, rename = "opModeName")]
1123 pub op_mode_name: Option<String>,
1124 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1126 pub sta_conn: Option<i64>,
1127 #[serde(default, rename = "staConnName")]
1129 pub sta_conn_name: Option<String>,
1130 #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
1132 pub soft_ap_conn: Option<i64>,
1133}
1134
1135#[derive(Debug, Clone, Deserialize)]
1137pub struct BlufiDeviceInfo {
1138 #[serde(default)]
1140 pub version: Option<String>,
1141 #[serde(flatten)]
1143 pub status: BlufiStatus,
1144}
1145
1146#[derive(Debug, Clone, Deserialize)]
1148pub struct BlufiProvisionResult {
1149 #[serde(default)]
1151 pub device_name: Option<String>,
1152 #[serde(default)]
1154 pub ssid: String,
1155 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1157 pub sta_conn: Option<i64>,
1158 #[serde(default, rename = "staConnName")]
1160 pub sta_conn_name: Option<String>,
1161}
1162
1163#[derive(Debug, Clone, Deserialize)]
1165pub struct BlufiNetwork {
1166 #[serde(default)]
1168 pub ssid: String,
1169 #[serde(default, deserialize_with = "lenient::opt_i64")]
1171 pub rssi: Option<i64>,
1172}
1173
1174pub(crate) fn value_list_field<T: DeserializeOwned>(
1177 resp: CommandResponse,
1178 field: &str,
1179) -> Result<Vec<T>> {
1180 let list = resp
1181 .value
1182 .as_ref()
1183 .and_then(|v| v.get(field))
1184 .cloned()
1185 .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
1186 serde_json::from_value(list)
1187 .map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
1188}
1189
1190#[derive(Debug, Clone, Default)]
1198pub struct UsbDeviceFilter {
1199 pub vid: Option<String>,
1201 pub pid: Option<String>,
1203 pub serial: Option<String>,
1205}
1206
1207#[derive(Debug, Clone, Deserialize)]
1211pub struct UsbDeviceInfo {
1212 #[serde(default)]
1214 pub sysfs_name: String,
1215 #[serde(default)]
1217 pub vid: Option<String>,
1218 #[serde(default)]
1220 pub pid: Option<String>,
1221 #[serde(default)]
1223 pub serial: Option<String>,
1224 #[serde(default)]
1226 pub product: Option<String>,
1227 #[serde(default)]
1229 pub manufacturer: Option<String>,
1230 #[serde(default)]
1232 pub busnum: Option<String>,
1233 #[serde(default)]
1235 pub devnum: Option<String>,
1236 #[serde(default)]
1238 pub devpath: Option<String>,
1239 #[serde(default)]
1241 pub device_class: Option<String>,
1242 #[serde(default)]
1244 pub speed: Option<String>,
1245}
1246
1247fn encode_query_value(s: &str) -> String {
1249 let mut out = String::with_capacity(s.len());
1250 for b in s.bytes() {
1251 match b {
1252 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1253 out.push(b as char)
1254 }
1255 _ => out.push_str(&format!("%{b:02X}")),
1256 }
1257 }
1258 out
1259}
1260
1261pub fn usb_devices(filter: &UsbDeviceFilter) -> HttpRequest {
1263 let mut query = Vec::new();
1264 for (key, value) in [
1265 ("vid", &filter.vid),
1266 ("pid", &filter.pid),
1267 ("serial", &filter.serial),
1268 ] {
1269 if let Some(value) = value {
1270 query.push(format!("{key}={}", encode_query_value(value)));
1271 }
1272 }
1273 let path = if query.is_empty() {
1274 "/usb/devices".to_string()
1275 } else {
1276 format!("/usb/devices?{}", query.join("&"))
1277 };
1278 HttpRequest {
1279 method: Method::Get,
1280 path,
1281 body: None,
1282 timeout: Timeout::Default,
1283 }
1284}
1285
1286pub fn parse_usb_devices(status: u16, body: Value) -> Result<Vec<UsbDeviceInfo>> {
1288 if status == 404 {
1289 return Err(usb_devices_unsupported());
1290 }
1291 let resp = parse_command(status, body)?;
1292 let devices = resp
1293 .extra
1294 .get("devices")
1295 .cloned()
1296 .ok_or_else(|| Error::Decode("response has no 'devices' list".to_string()))?;
1297 serde_json::from_value(devices)
1298 .map_err(|e| Error::Decode(format!("invalid 'devices' shape: {e}")))
1299}
1300
1301pub(crate) fn usb_devices_unsupported() -> Error {
1302 Error::UnsupportedByBox {
1303 message: "this box does not serve GET /usb/devices (requires box software >= 0.33.0)"
1304 .to_string(),
1305 }
1306}
1307
1308pub(crate) fn map_route_missing(err: Error, unsupported: fn() -> Error) -> Error {
1312 match err {
1313 Error::Box {
1314 status: 404,
1315 ref message,
1316 } if message.contains("non-JSON") => unsupported(),
1317 other => other,
1318 }
1319}
1320
1321#[derive(Debug, Clone, Deserialize)]
1327pub struct DfuDevice {
1328 #[serde(default)]
1330 pub mode: String,
1331 #[serde(default)]
1333 pub vid: String,
1334 #[serde(default)]
1336 pub pid: String,
1337 #[serde(default, deserialize_with = "lenient::opt_i64")]
1339 pub devnum: Option<i64>,
1340 #[serde(default, deserialize_with = "lenient::opt_i64")]
1342 pub cfg: Option<i64>,
1343 #[serde(default, deserialize_with = "lenient::opt_i64")]
1345 pub intf: Option<i64>,
1346 #[serde(default, deserialize_with = "lenient::opt_i64")]
1348 pub alt: Option<i64>,
1349 #[serde(default)]
1351 pub name: Option<String>,
1352 #[serde(default)]
1354 pub serial: Option<String>,
1355 #[serde(default)]
1357 pub path: Option<String>,
1358}
1359
1360#[derive(Debug, Clone, Deserialize)]
1362pub struct DfuOutput {
1363 #[serde(default, deserialize_with = "lenient::opt_i64")]
1365 pub exit_code: Option<i64>,
1366 #[serde(default)]
1368 pub stdout: String,
1369 #[serde(default)]
1371 pub stderr: String,
1372}
1373
1374pub(crate) fn dfu_unsupported() -> Error {
1375 Error::UnsupportedByBox {
1376 message: "this box does not serve POST /usb/dfu (requires box software >= 0.33.0)"
1377 .to_string(),
1378 }
1379}
1380
1381#[derive(Debug, Clone, Deserialize)]
1389pub struct BoxLock {
1390 #[serde(default)]
1392 pub locked: bool,
1393 #[serde(default)]
1395 pub user: Option<String>,
1396 #[serde(default)]
1399 pub holder_type: Option<String>,
1400 #[serde(default)]
1402 pub locked_at: Option<String>,
1403 #[serde(default)]
1405 pub last_heartbeat: Option<String>,
1406 #[serde(default, deserialize_with = "lenient::opt_i64")]
1408 pub ttl_seconds: Option<i64>,
1409 #[serde(default)]
1412 pub previous_user: Option<String>,
1413}
1414
1415pub fn lock_status() -> HttpRequest {
1417 get("/lock")
1418}
1419
1420pub fn lock_acquire(user: &str, holder_type: &str, ttl_seconds: Option<u64>) -> HttpRequest {
1423 HttpRequest {
1424 method: Method::Post,
1425 path: "/lock".to_string(),
1426 body: Some(json!({
1427 "user": user,
1428 "holder_type": holder_type,
1429 "ttl_seconds": ttl_seconds,
1430 })),
1431 timeout: Timeout::Default,
1432 }
1433}
1434
1435pub fn lock_heartbeat(user: &str) -> HttpRequest {
1437 HttpRequest {
1438 method: Method::Post,
1439 path: "/lock/heartbeat".to_string(),
1440 body: Some(json!({ "user": user })),
1441 timeout: Timeout::Default,
1442 }
1443}
1444
1445pub fn unlock(user: &str, force: bool) -> HttpRequest {
1447 HttpRequest {
1448 method: Method::Post,
1449 path: "/unlock".to_string(),
1450 body: Some(json!({ "user": user, "force": force })),
1451 timeout: Timeout::Default,
1452 }
1453}
1454
1455pub fn parse_lock(status: u16, body: Value) -> Result<BoxLock> {
1459 if status == 200 {
1460 return serde_json::from_value(body)
1461 .map_err(|e| Error::Decode(format!("invalid lock state: {e}")));
1462 }
1463 let message = body
1464 .get("error")
1465 .and_then(Value::as_str)
1466 .unwrap_or("lock request failed")
1467 .to_string();
1468 Err(Error::Box { status, message })
1469}
1470
1471pub(crate) fn lock_unsupported() -> Error {
1472 Error::UnsupportedByBox {
1473 message: "this box does not serve the /lock endpoints on port 9000; \
1474 update the box software"
1475 .to_string(),
1476 }
1477}
1478
1479#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1497pub struct SafetyLimits {
1498 #[serde(default, skip_serializing_if = "Option::is_none")]
1502 pub max_voltage: Option<f64>,
1503 #[serde(default, skip_serializing_if = "Option::is_none")]
1505 pub max_current: Option<f64>,
1506 #[serde(default, skip_serializing_if = "Option::is_none")]
1509 pub allow_destructive: Option<bool>,
1510}
1511
1512impl SafetyLimits {
1513 pub fn is_empty(&self) -> bool {
1516 self.max_voltage.is_none() && self.max_current.is_none() && self.allow_destructive.is_none()
1517 }
1518}
1519
1520pub fn safety_limits_set(name: &str, limits: &SafetyLimits) -> HttpRequest {
1523 HttpRequest {
1524 method: Method::Put,
1525 path: format!("/nets/{name}/safety-limits"),
1526 body: Some(serde_json::to_value(limits).unwrap_or_else(|_| json!({}))),
1527 timeout: Timeout::Default,
1528 }
1529}
1530
1531pub fn parse_safety_limits(status: u16, body: Value) -> Result<Option<SafetyLimits>> {
1536 if status == 200 {
1537 return match body.get("safety_limits") {
1538 None | Some(Value::Null) => Ok(None),
1539 Some(limits) => serde_json::from_value(limits.clone())
1540 .map(Some)
1541 .map_err(|e| Error::Decode(format!("invalid safety_limits shape: {e}"))),
1542 };
1543 }
1544 let message = body
1545 .get("error")
1546 .and_then(Value::as_str)
1547 .unwrap_or("safety-limits request failed")
1548 .to_string();
1549 Err(Error::Box { status, message })
1550}
1551
1552pub(crate) fn safety_limits_unsupported() -> Error {
1553 Error::UnsupportedByBox {
1554 message: "this box does not serve PUT /nets/<name>/safety-limits \
1555 (requires box software >= 0.35.0)"
1556 .to_string(),
1557 }
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562 use super::*;
1563
1564 #[test]
1565 fn parse_success_envelope() {
1566 let resp = parse_command(
1567 200,
1568 json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
1569 )
1570 .unwrap();
1571 assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
1572 }
1573
1574 #[test]
1575 fn parse_failure_with_http_200_is_box_error() {
1576 let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
1578 match err {
1579 Error::Box { status, message } => {
1580 assert_eq!(status, 200);
1581 assert_eq!(message, "conflict");
1582 }
1583 other => panic!("unexpected error: {other:?}"),
1584 }
1585 }
1586
1587 #[test]
1588 fn parse_501_is_unsupported() {
1589 let err = parse_command(
1590 501,
1591 json!({"success": false, "error": "Role 'gpio' is not supported"}),
1592 )
1593 .unwrap_err();
1594 assert!(matches!(err, Error::UnsupportedByBox { .. }));
1595 }
1596
1597 #[test]
1598 fn base_url_normalization() {
1599 assert_eq!(base_url("192.168.1.42").unwrap(), "http://192.168.1.42:9000");
1600 assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
1601 assert_eq!(base_url("192.168.1.42:8080").unwrap(), "http://192.168.1.42:8080");
1602 assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
1603 assert!(base_url("").is_err());
1604 assert!(base_url("http://").is_err());
1605 }
1606
1607 #[test]
1608 fn lenient_numbers() {
1609 assert_eq!(as_f64(&json!("3.3")), Some(3.3));
1610 assert_eq!(as_f64(&json!(3.3)), Some(3.3));
1611 assert_eq!(as_bool(&json!("ON")), Some(true));
1612 assert_eq!(as_bool(&json!(0)), Some(false));
1613 assert_eq!(as_i64(&json!("42")), Some(42));
1614 }
1615
1616 #[test]
1617 fn supply_state_tolerates_nulls_and_strings() {
1618 let state: SupplyState = serde_json::from_value(json!({
1619 "netname": "supply1", "channel": "1", "error": null,
1620 "voltage": "3.3", "current": null, "enabled": 1,
1621 }))
1622 .unwrap();
1623 assert_eq!(state.channel, Some(1));
1624 assert_eq!(state.voltage, Some(3.3));
1625 assert_eq!(state.current, None);
1626 assert_eq!(state.enabled, Some(true));
1627 }
1628
1629 #[test]
1630 fn safety_limits_body_omits_unset_fields() {
1631 let req = safety_limits_set(
1635 "supply1",
1636 &SafetyLimits {
1637 max_voltage: Some(5.0),
1638 ..Default::default()
1639 },
1640 );
1641 assert_eq!(req.method, Method::Put);
1642 assert_eq!(req.path, "/nets/supply1/safety-limits");
1643 assert_eq!(req.body, Some(json!({ "max_voltage": 5.0 })));
1644
1645 let clear = safety_limits_set("supply1", &SafetyLimits::default());
1646 assert_eq!(clear.body, Some(json!({})));
1647 assert!(SafetyLimits::default().is_empty());
1648 }
1649
1650 #[test]
1651 fn parse_safety_limits_roundtrip_and_clear() {
1652 let applied = parse_safety_limits(
1653 200,
1654 json!({"ok": true, "name": "supply1",
1655 "safety_limits": {"max_voltage": 5.0, "allow_destructive": false}}),
1656 )
1657 .unwrap()
1658 .unwrap();
1659 assert_eq!(applied.max_voltage, Some(5.0));
1660 assert_eq!(applied.max_current, None);
1661 assert_eq!(applied.allow_destructive, Some(false));
1662
1663 let cleared =
1665 parse_safety_limits(200, json!({"ok": true, "safety_limits": null})).unwrap();
1666 assert!(cleared.is_none());
1667 }
1668
1669 #[test]
1670 fn parse_safety_limits_surfaces_box_refusals() {
1671 let err = parse_safety_limits(
1672 400,
1673 json!({"error": "max_power is not supported: ..."}),
1674 )
1675 .unwrap_err();
1676 match err {
1677 Error::Box { status, message } => {
1678 assert_eq!(status, 400);
1679 assert!(message.contains("max_power"));
1680 }
1681 other => panic!("unexpected error: {other:?}"),
1682 }
1683 }
1684
1685 #[test]
1686 fn net_record_carries_safety_limits() {
1687 let rec: NetRecord = serde_json::from_value(json!({
1688 "name": "supply1", "role": "power-supply",
1689 "safety_limits": {"max_voltage": 12.0, "max_current": 2.5},
1690 }))
1691 .unwrap();
1692 let limits = rec.safety_limits.unwrap();
1693 assert_eq!(limits.max_voltage, Some(12.0));
1694 assert_eq!(limits.max_current, Some(2.5));
1695 assert_eq!(limits.allow_destructive, None);
1696
1697 let rec: NetRecord =
1699 serde_json::from_value(json!({"name": "gpio1", "role": "gpio"})).unwrap();
1700 assert!(rec.safety_limits.is_none());
1701 }
1702
1703 #[test]
1704 fn capabilities_parse_safety_limits_flag() {
1705 let caps: BoxCapabilities =
1706 serde_json::from_value(json!({"netCommand": true, "safetyLimits": true})).unwrap();
1707 assert!(caps.safety_limits);
1708 let caps: BoxCapabilities = serde_json::from_value(json!({"netCommand": true})).unwrap();
1710 assert!(!caps.safety_limits);
1711 }
1712}