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(match status {
323 503 if message.contains("busy") => Error::ProbeBusy { message },
325 403 => Error::SafetyRefused { message },
327 _ => Error::Box { status, message },
328 })
329}
330
331pub fn debug_health_request() -> HttpRequest {
333 get("/health")
334}
335
336#[derive(Debug, Clone, Deserialize)]
338#[non_exhaustive]
339pub struct DebugHealth {
340 #[serde(default)]
342 pub status: String,
343 #[serde(default)]
345 pub version: Option<String>,
346 #[serde(default)]
350 pub features: Vec<String>,
351}
352
353impl DebugHealth {
354 pub fn supports(&self, feature: &str) -> bool {
356 self.features.iter().any(|f| f == feature)
357 }
358}
359
360pub fn require_debug_feature(status: u16, body: Value, feature: &str, since: &str) -> Result<()> {
363 let health: DebugHealth = serde_json::from_value(parse_debug(status, body)?)?;
364 if health.supports(feature) {
365 Ok(())
366 } else {
367 Err(Error::UnsupportedByBox {
368 message: format!(
369 "the box debug service does not support '{feature}' (box software {since})"
370 ),
371 })
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
377#[non_exhaustive]
378pub struct EraseRange {
379 pub start: u64,
381 pub end: u64,
383 pub length: u64,
385 pub source: String,
389 #[serde(default)]
391 pub text: String,
392}
393
394#[derive(Debug, Clone, Deserialize)]
396#[non_exhaustive]
397pub struct EraseReport {
398 #[serde(default)]
400 pub output: String,
401 #[serde(default)]
404 pub backend: Option<String>,
405 #[serde(default)]
408 pub erase_range: Option<EraseRange>,
409}
410
411#[derive(Debug, Clone)]
413#[non_exhaustive]
414pub struct FlashReport {
415 pub output: Vec<String>,
417 pub backend: Option<String>,
419 pub programmed: Option<bool>,
423}
424
425fn output_lines(body: &Value) -> Vec<String> {
427 match body.get("output") {
428 Some(Value::Array(items)) => items
429 .iter()
430 .map(|v| match v {
431 Value::String(s) => s.clone(),
432 other => other.to_string(),
433 })
434 .collect(),
435 Some(Value::String(s)) => s.lines().map(str::to_string).collect(),
436 _ => Vec::new(),
437 }
438}
439
440pub fn parse_flash(body: Value) -> Result<FlashReport> {
444 let output = output_lines(&body);
445 let programmed = body.get("programmed").and_then(Value::as_bool);
446 if programmed == Some(false) {
447 let error = body
448 .get("error")
449 .and_then(Value::as_str)
450 .unwrap_or("the probe reported that nothing was programmed")
451 .to_string();
452 return Err(Error::FlashFailed { error, output });
453 }
454 Ok(FlashReport {
455 output,
456 backend: body
457 .get("backend")
458 .and_then(Value::as_str)
459 .map(str::to_string),
460 programmed,
461 })
462}
463
464#[derive(Debug, Clone, Deserialize)]
466pub struct GdbServer {
467 #[serde(default)]
469 pub status: Option<String>,
470 #[serde(default, deserialize_with = "lenient::opt_i64")]
472 pub gdb_port: Option<i64>,
473 #[serde(default, deserialize_with = "lenient::opt_i64")]
475 pub swo_port: Option<i64>,
476 #[serde(default, deserialize_with = "lenient::opt_i64")]
478 pub telnet_port: Option<i64>,
479 #[serde(default, deserialize_with = "lenient::opt_i64")]
481 pub tcl_port: Option<i64>,
482 #[serde(default, deserialize_with = "lenient::opt_i64")]
484 pub rtt_telnet_port: Option<i64>,
485 #[serde(default, deserialize_with = "lenient::opt_i64")]
487 pub pid: Option<i64>,
488}
489
490#[derive(Debug, Clone, Deserialize)]
492pub struct DebugConnection {
493 #[serde(default)]
495 pub status: Option<String>,
496 #[serde(default)]
498 pub device: Option<String>,
499 #[serde(default)]
501 pub probe: Option<String>,
502 #[serde(default)]
504 pub serial: Option<String>,
505 #[serde(default)]
507 pub backend: Option<String>,
508 #[serde(default)]
510 pub message: Option<String>,
511 #[serde(default, deserialize_with = "lenient::opt_i64")]
513 pub pid: Option<i64>,
514 #[serde(default)]
516 pub gdb_server: Option<GdbServer>,
517}
518
519#[derive(Debug, Clone, Deserialize)]
521#[non_exhaustive]
522pub struct DebugInfo {
523 #[serde(default)]
525 pub net_name: Option<String>,
526 #[serde(default)]
528 pub device: Option<String>,
529 #[serde(default)]
531 pub arch: Option<String>,
532 #[serde(default)]
534 pub probe: Option<String>,
535 #[serde(default)]
537 pub serial: Option<String>,
538 #[serde(default)]
540 pub backend: Option<String>,
541 #[serde(default)]
545 pub connected: bool,
546 #[serde(default, deserialize_with = "lenient::opt_bool")]
548 pub gdbserver_running: Option<bool>,
549 #[serde(default, deserialize_with = "lenient::opt_bool")]
554 pub target_attached: Option<bool>,
555}
556
557#[derive(Debug, Clone, Deserialize)]
559#[non_exhaustive]
560pub struct DebugStatus {
561 #[serde(default)]
565 pub connected: bool,
566 #[serde(default, deserialize_with = "lenient::opt_bool")]
568 pub gdbserver_running: Option<bool>,
569 #[serde(default, deserialize_with = "lenient::opt_bool")]
574 pub target_attached: Option<bool>,
575 #[serde(default, deserialize_with = "lenient::opt_i64")]
577 pub pid: Option<i64>,
578 #[serde(default)]
580 pub serial: Option<String>,
581 #[serde(default)]
583 pub backend: Option<String>,
584}
585
586pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
588 let hex = body
589 .get("data")
590 .and_then(Value::as_str)
591 .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
592 decode_hex(hex)
593}
594
595pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
597 let s = s.trim();
598 if s.len() % 2 != 0 {
599 return Err(Error::Decode("odd-length hex string".to_string()));
600 }
601 (0..s.len())
602 .step_by(2)
603 .map(|i| {
604 u8::from_str_radix(&s[i..i + 2], 16)
605 .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
606 })
607 .collect()
608}
609
610pub(crate) fn as_f64(v: &Value) -> Option<f64> {
618 match v {
619 Value::Number(n) => n.as_f64(),
620 Value::String(s) => s.trim().parse().ok(),
621 Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
622 _ => None,
623 }
624}
625
626pub(crate) fn as_i64(v: &Value) -> Option<i64> {
627 match v {
628 Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
629 Value::String(s) => s
630 .trim()
631 .parse::<i64>()
632 .ok()
633 .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
634 Value::Bool(b) => Some(i64::from(*b)),
635 _ => None,
636 }
637}
638
639pub(crate) fn as_bool(v: &Value) -> Option<bool> {
640 match v {
641 Value::Bool(b) => Some(*b),
642 Value::Number(n) => n.as_f64().map(|f| f != 0.0),
643 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
644 "true" | "on" | "1" | "yes" | "enabled" => Some(true),
645 "false" | "off" | "0" | "no" | "disabled" => Some(false),
646 _ => None,
647 },
648 _ => None,
649 }
650}
651
652pub(crate) mod lenient {
653 use serde::{Deserialize, Deserializer};
657 use serde_json::Value;
658
659 pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
660 let v = Option::<Value>::deserialize(d)?;
661 Ok(v.as_ref().and_then(super::as_f64))
662 }
663
664 pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
665 let v = Option::<Value>::deserialize(d)?;
666 Ok(v.as_ref().and_then(super::as_i64))
667 }
668
669 pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
670 let v = Option::<Value>::deserialize(d)?;
671 Ok(v.as_ref().and_then(super::as_bool))
672 }
673}
674
675pub fn value_f64(resp: CommandResponse) -> Result<f64> {
681 resp.value
682 .as_ref()
683 .and_then(as_f64)
684 .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
685}
686
687pub fn value_i64(resp: CommandResponse) -> Result<i64> {
689 resp.value
690 .as_ref()
691 .and_then(as_i64)
692 .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
693}
694
695pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
698 let arr = resp
699 .value
700 .as_ref()
701 .and_then(Value::as_array)
702 .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
703 arr.iter()
704 .map(|v| {
705 as_i64(v)
706 .and_then(|n| u32::try_from(n).ok())
707 .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
708 })
709 .collect()
710}
711
712pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
714 let v = resp
715 .value
716 .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
717 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
718}
719
720pub fn warnings(resp: &CommandResponse) -> Vec<String> {
723 match resp.extra.get("warnings") {
724 Some(Value::Array(items)) => items
725 .iter()
726 .map(|v| match v {
727 Value::String(s) => s.clone(),
728 other => other.to_string(),
729 })
730 .collect(),
731 _ => Vec::new(),
732 }
733}
734
735pub(crate) fn unknown_action_compat(err: Error, action: &str, since: &str) -> Error {
739 match err {
740 Error::Box {
741 status: 400,
742 ref message,
743 } if message.contains(&format!("Unknown action '{action}'")) => Error::UnsupportedByBox {
744 message: format!("the '{action}' action requires box software {since}"),
745 },
746 other => other,
747 }
748}
749
750pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
752 let v = resp
753 .state
754 .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
755 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
756}
757
758pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
761 match body {
762 Value::Array(a) => a,
763 Value::Object(mut o) => match o.remove("nets") {
764 Some(Value::Array(a)) => a,
765 _ => vec![],
766 },
767 _ => vec![],
768 }
769}
770
771pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
773 let list = Value::Array(nets_list_values(body));
774 serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
775}
776
777pub fn unit(_resp: CommandResponse) -> Result<()> {
779 Ok(())
780}
781
782pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
784 Ok(resp)
785}
786
787#[derive(Debug, Clone, Deserialize)]
793pub struct NetRecord {
794 #[serde(default)]
796 pub name: String,
797 #[serde(default)]
799 pub role: String,
800 #[serde(default)]
802 pub instrument: Option<String>,
803 #[serde(default)]
805 pub pin: Option<Value>,
806 #[serde(default)]
808 pub channel: Option<Value>,
809 #[serde(default)]
811 pub address: Option<String>,
812 #[serde(default)]
814 pub params: Option<Map<String, Value>>,
815 #[serde(default)]
818 pub safety_limits: Option<SafetyLimits>,
819 #[serde(flatten)]
821 pub extra: Map<String, Value>,
822}
823
824#[derive(Debug, Clone, Deserialize)]
826pub struct Health {
827 #[serde(default)]
829 pub status: String,
830 #[serde(default)]
832 pub service: Option<String>,
833 #[serde(default)]
835 pub version: Option<String>,
836}
837
838#[derive(Debug, Clone, Deserialize)]
840pub struct NetSummary {
841 #[serde(default)]
843 pub name: String,
844 #[serde(default, rename = "type")]
846 pub net_type: String,
847}
848
849#[derive(Debug, Clone, Default, Deserialize)]
851#[non_exhaustive]
852pub struct BoxCapabilities {
853 #[serde(default, rename = "netCommand")]
856 pub net_command: bool,
857 #[serde(default, rename = "netCommandRoles")]
861 pub net_command_roles: Vec<String>,
862 #[serde(default, rename = "bleCommand")]
864 pub ble_command: bool,
865 #[serde(default, rename = "wifiCommand")]
867 pub wifi_command: bool,
868 #[serde(default, rename = "blufiCommand")]
870 pub blufi_command: bool,
871 #[serde(default, rename = "customDevices")]
875 pub custom_devices: bool,
876 #[serde(default)]
879 pub binaries: bool,
880 #[serde(default, rename = "safetyLimits")]
885 pub safety_limits: bool,
886 #[serde(default, rename = "customDrivers")]
890 pub custom_drivers: bool,
891 #[serde(default, rename = "customDriverDeps")]
894 pub custom_driver_deps: bool,
895 #[serde(default, rename = "netMetadataSync")]
898 pub net_metadata_sync: bool,
899 #[serde(default, rename = "boxMetadataSync")]
902 pub box_metadata_sync: bool,
903 #[serde(default, rename = "benchManifest")]
906 pub bench_manifest: bool,
907 #[serde(default, rename = "netsStateEnabled")]
910 pub nets_state_enabled: bool,
911 #[serde(default, rename = "netsStateStream")]
914 pub nets_state_stream: bool,
915}
916
917#[derive(Debug, Clone, Deserialize)]
919#[non_exhaustive]
920pub struct BoxStatus {
921 #[serde(default)]
923 pub healthy: bool,
924 #[serde(default)]
926 pub version: String,
927 #[serde(default)]
929 pub nets: Vec<NetSummary>,
930 #[serde(default)]
932 pub capabilities: BoxCapabilities,
933}
934
935#[derive(Debug, Clone, Deserialize)]
940pub struct SupplyState {
941 #[serde(default)]
943 pub netname: Option<String>,
944 #[serde(default, deserialize_with = "lenient::opt_i64")]
946 pub channel: Option<i64>,
947 #[serde(default)]
949 pub error: Option<String>,
950 #[serde(default, deserialize_with = "lenient::opt_f64")]
952 pub voltage: Option<f64>,
953 #[serde(default, deserialize_with = "lenient::opt_f64")]
955 pub current: Option<f64>,
956 #[serde(default, deserialize_with = "lenient::opt_f64")]
958 pub power: Option<f64>,
959 #[serde(default, deserialize_with = "lenient::opt_bool")]
961 pub enabled: Option<bool>,
962 #[serde(default)]
964 pub mode: Option<String>,
965 #[serde(default, deserialize_with = "lenient::opt_f64")]
967 pub voltage_set: Option<f64>,
968 #[serde(default, deserialize_with = "lenient::opt_f64")]
970 pub current_set: Option<f64>,
971 #[serde(default, deserialize_with = "lenient::opt_f64")]
973 pub voltage_max: Option<f64>,
974 #[serde(default, deserialize_with = "lenient::opt_f64")]
976 pub current_max: Option<f64>,
977 #[serde(default, deserialize_with = "lenient::opt_f64")]
979 pub ocp_limit: Option<f64>,
980 #[serde(default, deserialize_with = "lenient::opt_bool")]
982 pub ocp_tripped: Option<bool>,
983 #[serde(default, deserialize_with = "lenient::opt_f64")]
985 pub ovp_limit: Option<f64>,
986 #[serde(default, deserialize_with = "lenient::opt_bool")]
988 pub ovp_tripped: Option<bool>,
989}
990
991#[derive(Debug, Clone, Deserialize)]
995pub struct BatteryState {
996 #[serde(default)]
998 pub netname: Option<String>,
999 #[serde(default, deserialize_with = "lenient::opt_i64")]
1001 pub channel: Option<i64>,
1002 #[serde(default)]
1004 pub error: Option<String>,
1005 #[serde(default, deserialize_with = "lenient::opt_f64")]
1007 pub terminal_voltage: Option<f64>,
1008 #[serde(default, deserialize_with = "lenient::opt_f64")]
1010 pub current: Option<f64>,
1011 #[serde(default, deserialize_with = "lenient::opt_f64")]
1013 pub esr: Option<f64>,
1014 #[serde(default, deserialize_with = "lenient::opt_f64")]
1016 pub soc: Option<f64>,
1017 #[serde(default, deserialize_with = "lenient::opt_f64")]
1019 pub voc: Option<f64>,
1020 #[serde(default, deserialize_with = "lenient::opt_bool")]
1022 pub enabled: Option<bool>,
1023 #[serde(default)]
1025 pub mode: Option<String>,
1026 #[serde(default)]
1028 pub model: Option<String>,
1029 #[serde(default, deserialize_with = "lenient::opt_f64")]
1031 pub capacity: Option<f64>,
1032 #[serde(default, deserialize_with = "lenient::opt_f64")]
1034 pub current_limit: Option<f64>,
1035 #[serde(default, deserialize_with = "lenient::opt_f64")]
1037 pub ocp_limit: Option<f64>,
1038 #[serde(default, deserialize_with = "lenient::opt_f64")]
1040 pub ovp_limit: Option<f64>,
1041 #[serde(default, deserialize_with = "lenient::opt_f64")]
1043 pub volt_full: Option<f64>,
1044 #[serde(default, deserialize_with = "lenient::opt_f64")]
1046 pub volt_empty: Option<f64>,
1047 #[serde(default, deserialize_with = "lenient::opt_bool")]
1049 pub ocp_tripped: Option<bool>,
1050 #[serde(default, deserialize_with = "lenient::opt_bool")]
1052 pub ovp_tripped: Option<bool>,
1053}
1054
1055#[derive(Debug, Clone, Deserialize)]
1057pub struct EloadState {
1058 #[serde(default)]
1060 pub mode: Option<String>,
1061 #[serde(default, deserialize_with = "lenient::opt_bool")]
1063 pub input_enabled: Option<bool>,
1064 #[serde(default, deserialize_with = "lenient::opt_f64")]
1066 pub measured_voltage: Option<f64>,
1067 #[serde(default, deserialize_with = "lenient::opt_f64")]
1069 pub measured_current: Option<f64>,
1070 #[serde(default, deserialize_with = "lenient::opt_f64")]
1072 pub measured_power: Option<f64>,
1073 #[serde(flatten)]
1075 pub extra: Map<String, Value>,
1076}
1077
1078#[derive(Debug, Clone, Deserialize)]
1080pub struct WattReading {
1081 #[serde(default, deserialize_with = "lenient::opt_f64")]
1083 pub current: Option<f64>,
1084 #[serde(default, deserialize_with = "lenient::opt_f64")]
1086 pub voltage: Option<f64>,
1087 #[serde(default, deserialize_with = "lenient::opt_f64")]
1089 pub power: Option<f64>,
1090 #[serde(default, deserialize_with = "lenient::opt_f64")]
1092 pub duration_s: Option<f64>,
1093}
1094
1095#[derive(Debug, Clone, Deserialize)]
1097pub struct EnergyReading {
1098 #[serde(default, deserialize_with = "lenient::opt_f64")]
1100 pub energy_j: Option<f64>,
1101 #[serde(default, deserialize_with = "lenient::opt_f64")]
1103 pub charge_c: Option<f64>,
1104 #[serde(default, deserialize_with = "lenient::opt_f64")]
1106 pub duration_s: Option<f64>,
1107 #[serde(flatten)]
1109 pub extra: Map<String, Value>,
1110}
1111
1112#[derive(Debug, Clone, Default, Deserialize)]
1114pub struct StatSummary {
1115 #[serde(default, deserialize_with = "lenient::opt_f64")]
1117 pub mean: Option<f64>,
1118 #[serde(default, deserialize_with = "lenient::opt_f64")]
1120 pub min: Option<f64>,
1121 #[serde(default, deserialize_with = "lenient::opt_f64")]
1123 pub max: Option<f64>,
1124 #[serde(default, deserialize_with = "lenient::opt_f64")]
1126 pub std: Option<f64>,
1127 #[serde(flatten)]
1129 pub extra: Map<String, Value>,
1130}
1131
1132#[derive(Debug, Clone, Deserialize)]
1134pub struct EnergyStats {
1135 #[serde(default)]
1137 pub current: Option<StatSummary>,
1138 #[serde(default)]
1140 pub voltage: Option<StatSummary>,
1141 #[serde(default)]
1143 pub power: Option<StatSummary>,
1144 #[serde(flatten)]
1146 pub extra: Map<String, Value>,
1147}
1148
1149#[derive(Debug, Clone, Copy, PartialEq)]
1156pub struct ArmPosition {
1157 pub x: f64,
1159 pub y: f64,
1161 pub z: f64,
1163}
1164
1165pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
1167 let arr = resp
1168 .value
1169 .as_ref()
1170 .and_then(Value::as_array)
1171 .ok_or_else(|| {
1172 Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value))
1173 })?;
1174 match arr.as_slice() {
1175 [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
1176 (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
1177 _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
1178 },
1179 _ => Err(Error::Decode(format!(
1180 "expected 3-element arm position, got {} elements",
1181 arr.len()
1182 ))),
1183 }
1184}
1185
1186#[derive(Debug, Clone, Deserialize)]
1188#[non_exhaustive]
1189pub struct WebcamStream {
1190 #[serde(default)]
1192 pub url: String,
1193 #[serde(default, deserialize_with = "lenient::opt_i64")]
1195 pub port: Option<i64>,
1196 #[serde(default)]
1198 pub already_running: bool,
1199}
1200
1201#[derive(Debug, Clone, Deserialize)]
1203#[non_exhaustive]
1204pub struct WebcamStatus {
1205 #[serde(default)]
1207 pub running: bool,
1208 #[serde(default)]
1210 pub url: Option<String>,
1211 #[serde(default, deserialize_with = "lenient::opt_i64")]
1213 pub port: Option<i64>,
1214 #[serde(default)]
1216 pub video_device: Option<String>,
1217 #[serde(default)]
1220 pub source: Option<String>,
1221 #[serde(default)]
1223 pub started_by: Option<String>,
1224}
1225
1226#[derive(Debug, Clone, Deserialize)]
1228pub struct RouterSystemInfo {
1229 #[serde(default)]
1231 pub name: Option<String>,
1232 #[serde(default)]
1234 pub version: Option<String>,
1235 #[serde(default)]
1237 pub board: Option<String>,
1238 #[serde(default)]
1240 pub architecture: Option<String>,
1241 #[serde(default)]
1243 pub uptime: Option<String>,
1244 #[serde(default, deserialize_with = "lenient::opt_i64")]
1246 pub cpu_load: Option<i64>,
1247 #[serde(default, deserialize_with = "lenient::opt_i64")]
1249 pub free_memory: Option<i64>,
1250 #[serde(default, deserialize_with = "lenient::opt_i64")]
1252 pub total_memory: Option<i64>,
1253 #[serde(default, deserialize_with = "lenient::opt_i64")]
1255 pub free_hdd_space: Option<i64>,
1256 #[serde(flatten)]
1258 pub extra: Map<String, Value>,
1259}
1260
1261#[derive(Debug, Clone, Deserialize)]
1263pub struct BleDevice {
1264 #[serde(default)]
1266 pub name: String,
1267 #[serde(default)]
1269 pub address: String,
1270 #[serde(default, deserialize_with = "lenient::opt_i64")]
1272 pub rssi: Option<i64>,
1273 #[serde(default)]
1275 pub uuids: Vec<String>,
1276}
1277
1278#[derive(Debug, Clone, Deserialize)]
1280pub struct BleCharacteristic {
1281 #[serde(default)]
1283 pub uuid: String,
1284 #[serde(default)]
1286 pub description: Option<String>,
1287 #[serde(default)]
1289 pub properties: Vec<String>,
1290}
1291
1292#[derive(Debug, Clone, Deserialize)]
1294pub struct BleService {
1295 #[serde(default)]
1297 pub uuid: String,
1298 #[serde(default)]
1300 pub description: Option<String>,
1301 #[serde(default)]
1303 pub characteristics: Vec<BleCharacteristic>,
1304}
1305
1306#[derive(Debug, Clone, Deserialize)]
1308pub struct BleDeviceInfo {
1309 #[serde(default)]
1311 pub address: String,
1312 #[serde(default)]
1314 pub connected: bool,
1315 #[serde(default)]
1317 pub services: Vec<BleService>,
1318}
1319
1320#[derive(Debug, Clone, Deserialize)]
1322pub struct WifiInterface {
1323 #[serde(default)]
1325 pub interface: String,
1326 #[serde(default)]
1328 pub ssid: String,
1329 #[serde(default)]
1331 pub state: String,
1332}
1333
1334#[derive(Debug, Clone, Deserialize)]
1336pub struct WifiAccessPoint {
1337 #[serde(default)]
1339 pub ssid: Option<String>,
1340 #[serde(default)]
1342 pub address: Option<String>,
1343 #[serde(default, deserialize_with = "lenient::opt_i64")]
1345 pub strength: Option<i64>,
1346 #[serde(default)]
1348 pub security: Option<String>,
1349}
1350
1351#[derive(Debug, Clone, Deserialize)]
1353pub struct WifiConnection {
1354 #[serde(default)]
1356 pub ssid: String,
1357 #[serde(default)]
1359 pub connected: bool,
1360 #[serde(default)]
1362 pub interface: Option<String>,
1363 #[serde(default)]
1365 pub method: Option<String>,
1366}
1367
1368#[derive(Debug, Clone, Deserialize)]
1371pub struct BlufiStatus {
1372 #[serde(default)]
1374 pub device_name: Option<String>,
1375 #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
1377 pub op_mode: Option<i64>,
1378 #[serde(default, rename = "opModeName")]
1380 pub op_mode_name: Option<String>,
1381 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1383 pub sta_conn: Option<i64>,
1384 #[serde(default, rename = "staConnName")]
1386 pub sta_conn_name: Option<String>,
1387 #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
1389 pub soft_ap_conn: Option<i64>,
1390}
1391
1392#[derive(Debug, Clone, Deserialize)]
1394pub struct BlufiDeviceInfo {
1395 #[serde(default)]
1397 pub version: Option<String>,
1398 #[serde(flatten)]
1400 pub status: BlufiStatus,
1401}
1402
1403#[derive(Debug, Clone, Deserialize)]
1405pub struct BlufiProvisionResult {
1406 #[serde(default)]
1408 pub device_name: Option<String>,
1409 #[serde(default)]
1411 pub ssid: String,
1412 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1414 pub sta_conn: Option<i64>,
1415 #[serde(default, rename = "staConnName")]
1417 pub sta_conn_name: Option<String>,
1418}
1419
1420#[derive(Debug, Clone, Deserialize)]
1422pub struct BlufiNetwork {
1423 #[serde(default)]
1425 pub ssid: String,
1426 #[serde(default, deserialize_with = "lenient::opt_i64")]
1428 pub rssi: Option<i64>,
1429}
1430
1431pub(crate) fn value_list_field<T: DeserializeOwned>(
1434 resp: CommandResponse,
1435 field: &str,
1436) -> Result<Vec<T>> {
1437 let list = resp
1438 .value
1439 .as_ref()
1440 .and_then(|v| v.get(field))
1441 .cloned()
1442 .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
1443 serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
1444}
1445
1446#[derive(Debug, Clone, Default)]
1454pub struct UsbDeviceFilter {
1455 pub vid: Option<String>,
1457 pub pid: Option<String>,
1459 pub serial: Option<String>,
1461}
1462
1463#[derive(Debug, Clone, Deserialize)]
1467pub struct UsbDeviceInfo {
1468 #[serde(default)]
1470 pub sysfs_name: String,
1471 #[serde(default)]
1473 pub vid: Option<String>,
1474 #[serde(default)]
1476 pub pid: Option<String>,
1477 #[serde(default)]
1479 pub serial: Option<String>,
1480 #[serde(default)]
1482 pub product: Option<String>,
1483 #[serde(default)]
1485 pub manufacturer: Option<String>,
1486 #[serde(default)]
1488 pub busnum: Option<String>,
1489 #[serde(default)]
1491 pub devnum: Option<String>,
1492 #[serde(default)]
1494 pub devpath: Option<String>,
1495 #[serde(default)]
1497 pub device_class: Option<String>,
1498 #[serde(default)]
1500 pub speed: Option<String>,
1501}
1502
1503fn encode_query_value(s: &str) -> String {
1505 let mut out = String::with_capacity(s.len());
1506 for b in s.bytes() {
1507 match b {
1508 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1509 out.push(b as char)
1510 }
1511 _ => out.push_str(&format!("%{b:02X}")),
1512 }
1513 }
1514 out
1515}
1516
1517pub fn usb_devices(filter: &UsbDeviceFilter) -> HttpRequest {
1519 let mut query = Vec::new();
1520 for (key, value) in [
1521 ("vid", &filter.vid),
1522 ("pid", &filter.pid),
1523 ("serial", &filter.serial),
1524 ] {
1525 if let Some(value) = value {
1526 query.push(format!("{key}={}", encode_query_value(value)));
1527 }
1528 }
1529 let path = if query.is_empty() {
1530 "/usb/devices".to_string()
1531 } else {
1532 format!("/usb/devices?{}", query.join("&"))
1533 };
1534 HttpRequest {
1535 method: Method::Get,
1536 path,
1537 body: None,
1538 timeout: Timeout::Default,
1539 }
1540}
1541
1542pub fn parse_usb_devices(status: u16, body: Value) -> Result<Vec<UsbDeviceInfo>> {
1544 if status == 404 {
1545 return Err(usb_devices_unsupported());
1546 }
1547 let resp = parse_command(status, body)?;
1548 let devices = resp
1549 .extra
1550 .get("devices")
1551 .cloned()
1552 .ok_or_else(|| Error::Decode("response has no 'devices' list".to_string()))?;
1553 serde_json::from_value(devices)
1554 .map_err(|e| Error::Decode(format!("invalid 'devices' shape: {e}")))
1555}
1556
1557pub(crate) fn usb_devices_unsupported() -> Error {
1558 Error::UnsupportedByBox {
1559 message: "this box does not serve GET /usb/devices (requires box software >= 0.33.0)"
1560 .to_string(),
1561 }
1562}
1563
1564pub(crate) fn map_route_missing(err: Error, unsupported: fn() -> Error) -> Error {
1568 match err {
1569 Error::Box {
1570 status: 404,
1571 ref message,
1572 } if message.contains("non-JSON") => unsupported(),
1573 other => other,
1574 }
1575}
1576
1577#[derive(Debug, Clone, Deserialize)]
1583pub struct DfuDevice {
1584 #[serde(default)]
1586 pub mode: String,
1587 #[serde(default)]
1589 pub vid: String,
1590 #[serde(default)]
1592 pub pid: String,
1593 #[serde(default, deserialize_with = "lenient::opt_i64")]
1595 pub devnum: Option<i64>,
1596 #[serde(default, deserialize_with = "lenient::opt_i64")]
1598 pub cfg: Option<i64>,
1599 #[serde(default, deserialize_with = "lenient::opt_i64")]
1601 pub intf: Option<i64>,
1602 #[serde(default, deserialize_with = "lenient::opt_i64")]
1604 pub alt: Option<i64>,
1605 #[serde(default)]
1607 pub name: Option<String>,
1608 #[serde(default)]
1610 pub serial: Option<String>,
1611 #[serde(default)]
1613 pub path: Option<String>,
1614}
1615
1616#[derive(Debug, Clone, Deserialize)]
1618pub struct DfuOutput {
1619 #[serde(default, deserialize_with = "lenient::opt_i64")]
1621 pub exit_code: Option<i64>,
1622 #[serde(default)]
1624 pub stdout: String,
1625 #[serde(default)]
1627 pub stderr: String,
1628}
1629
1630pub fn parse_dfu(status: u16, body: Value) -> Result<CommandResponse> {
1636 let failed = !(status == 200 && body.get("success").and_then(Value::as_bool) == Some(true));
1637 if failed {
1638 if let Some(output) = body
1639 .get("value")
1640 .and_then(|v| serde_json::from_value::<DfuOutput>(v.clone()).ok())
1641 .filter(|o| o.exit_code.is_some())
1642 {
1643 return Err(Error::DfuFailed {
1644 status,
1645 exit_code: output.exit_code,
1646 stdout: output.stdout,
1647 stderr: output.stderr,
1648 });
1649 }
1650 }
1651 parse_command(status, body)
1652}
1653
1654pub(crate) fn dfu_unsupported() -> Error {
1655 Error::UnsupportedByBox {
1656 message: "this box does not serve POST /usb/dfu (requires box software >= 0.33.0)"
1657 .to_string(),
1658 }
1659}
1660
1661#[derive(Debug, Clone, Deserialize)]
1669pub struct BoxLock {
1670 #[serde(default)]
1672 pub locked: bool,
1673 #[serde(default)]
1675 pub user: Option<String>,
1676 #[serde(default)]
1679 pub holder_type: Option<String>,
1680 #[serde(default)]
1682 pub locked_at: Option<String>,
1683 #[serde(default)]
1685 pub last_heartbeat: Option<String>,
1686 #[serde(default, deserialize_with = "lenient::opt_i64")]
1688 pub ttl_seconds: Option<i64>,
1689 #[serde(default)]
1692 pub previous_user: Option<String>,
1693}
1694
1695pub fn lock_status() -> HttpRequest {
1697 get("/lock")
1698}
1699
1700pub fn lock_acquire(user: &str, holder_type: &str, ttl_seconds: Option<u64>) -> HttpRequest {
1703 HttpRequest {
1704 method: Method::Post,
1705 path: "/lock".to_string(),
1706 body: Some(json!({
1707 "user": user,
1708 "holder_type": holder_type,
1709 "ttl_seconds": ttl_seconds,
1710 })),
1711 timeout: Timeout::Default,
1712 }
1713}
1714
1715pub fn lock_heartbeat(user: &str) -> HttpRequest {
1717 HttpRequest {
1718 method: Method::Post,
1719 path: "/lock/heartbeat".to_string(),
1720 body: Some(json!({ "user": user })),
1721 timeout: Timeout::Default,
1722 }
1723}
1724
1725pub fn unlock(user: &str, force: bool) -> HttpRequest {
1727 HttpRequest {
1728 method: Method::Post,
1729 path: "/unlock".to_string(),
1730 body: Some(json!({ "user": user, "force": force })),
1731 timeout: Timeout::Default,
1732 }
1733}
1734
1735pub fn parse_lock(status: u16, body: Value) -> Result<BoxLock> {
1739 if status == 200 {
1740 return serde_json::from_value(body)
1741 .map_err(|e| Error::Decode(format!("invalid lock state: {e}")));
1742 }
1743 let message = body
1744 .get("error")
1745 .and_then(Value::as_str)
1746 .unwrap_or("lock request failed")
1747 .to_string();
1748 Err(Error::Box { status, message })
1749}
1750
1751pub(crate) fn lock_unsupported() -> Error {
1752 Error::UnsupportedByBox {
1753 message: "this box does not serve the /lock endpoints on port 9000; \
1754 update the box software"
1755 .to_string(),
1756 }
1757}
1758
1759#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
1777pub struct SafetyLimits {
1778 #[serde(default, skip_serializing_if = "Option::is_none")]
1782 pub max_voltage: Option<f64>,
1783 #[serde(default, skip_serializing_if = "Option::is_none")]
1785 pub max_current: Option<f64>,
1786 #[serde(default, skip_serializing_if = "Option::is_none")]
1789 pub allow_destructive: Option<bool>,
1790}
1791
1792impl SafetyLimits {
1793 pub fn is_empty(&self) -> bool {
1796 self.max_voltage.is_none() && self.max_current.is_none() && self.allow_destructive.is_none()
1797 }
1798}
1799
1800pub fn safety_limits_set(name: &str, limits: &SafetyLimits) -> HttpRequest {
1803 HttpRequest {
1804 method: Method::Put,
1805 path: format!("/nets/{name}/safety-limits"),
1806 body: Some(serde_json::to_value(limits).unwrap_or_else(|_| json!({}))),
1807 timeout: Timeout::Default,
1808 }
1809}
1810
1811pub fn parse_safety_limits(status: u16, body: Value) -> Result<Option<SafetyLimits>> {
1816 if status == 200 {
1817 return match body.get("safety_limits") {
1818 None | Some(Value::Null) => Ok(None),
1819 Some(limits) => serde_json::from_value(limits.clone())
1820 .map(Some)
1821 .map_err(|e| Error::Decode(format!("invalid safety_limits shape: {e}"))),
1822 };
1823 }
1824 let message = body
1825 .get("error")
1826 .and_then(Value::as_str)
1827 .unwrap_or("safety-limits request failed")
1828 .to_string();
1829 Err(Error::Box { status, message })
1830}
1831
1832pub(crate) fn safety_limits_unsupported() -> Error {
1833 Error::UnsupportedByBox {
1834 message: "this box does not serve PUT /nets/<name>/safety-limits \
1835 (requires box software >= 0.35.0)"
1836 .to_string(),
1837 }
1838}
1839
1840#[derive(Debug, Clone, Deserialize)]
1851#[non_exhaustive]
1852pub struct NetState {
1853 pub name: String,
1855 #[serde(default)]
1857 pub role: String,
1858 #[serde(default)]
1861 pub state: Option<String>,
1862 #[serde(default)]
1867 pub reason: Option<String>,
1868 #[serde(default)]
1874 pub reason_code: Option<String>,
1875 #[serde(default, deserialize_with = "lenient::opt_bool")]
1879 pub enabled: Option<bool>,
1880 #[serde(flatten)]
1882 pub extra: Map<String, Value>,
1883}
1884
1885pub fn nets_state() -> HttpRequest {
1889 HttpRequest {
1890 method: Method::Get,
1891 path: "/nets/state".to_string(),
1892 body: None,
1893 timeout: Timeout::After(Duration::from_secs(15)),
1894 }
1895}
1896
1897pub fn parse_nets_state(status: u16, body: Value) -> Result<Vec<NetState>> {
1900 if status != 200 {
1901 let message = body
1902 .get("error")
1903 .and_then(Value::as_str)
1904 .unwrap_or("nets/state request failed")
1905 .to_string();
1906 return Err(Error::Box { status, message });
1907 }
1908 serde_json::from_value(body)
1909 .map_err(|e| Error::Decode(format!("invalid nets/state shape: {e}")))
1910}
1911
1912pub(crate) fn nets_state_unsupported() -> Error {
1913 Error::UnsupportedByBox {
1914 message: "this box does not serve GET /nets/state (requires box software >= 0.34.0)"
1915 .to_string(),
1916 }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921 use super::*;
1922
1923 #[test]
1924 fn parse_success_envelope() {
1925 let resp = parse_command(
1926 200,
1927 json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
1928 )
1929 .unwrap();
1930 assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
1931 }
1932
1933 #[test]
1934 fn parse_failure_with_http_200_is_box_error() {
1935 let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
1937 match err {
1938 Error::Box { status, message } => {
1939 assert_eq!(status, 200);
1940 assert_eq!(message, "conflict");
1941 }
1942 other => panic!("unexpected error: {other:?}"),
1943 }
1944 }
1945
1946 #[test]
1947 fn parse_501_is_unsupported() {
1948 let err = parse_command(
1949 501,
1950 json!({"success": false, "error": "Role 'gpio' is not supported"}),
1951 )
1952 .unwrap_err();
1953 assert!(matches!(err, Error::UnsupportedByBox { .. }));
1954 }
1955
1956 #[test]
1957 fn base_url_normalization() {
1958 assert_eq!(
1959 base_url("192.168.1.42").unwrap(),
1960 "http://192.168.1.42:9000"
1961 );
1962 assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
1963 assert_eq!(
1964 base_url("192.168.1.42:8080").unwrap(),
1965 "http://192.168.1.42:8080"
1966 );
1967 assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
1968 assert!(base_url("").is_err());
1969 assert!(base_url("http://").is_err());
1970 }
1971
1972 #[test]
1973 fn lenient_numbers() {
1974 assert_eq!(as_f64(&json!("3.3")), Some(3.3));
1975 assert_eq!(as_f64(&json!(3.3)), Some(3.3));
1976 assert_eq!(as_bool(&json!("ON")), Some(true));
1977 assert_eq!(as_bool(&json!(0)), Some(false));
1978 assert_eq!(as_i64(&json!("42")), Some(42));
1979 }
1980
1981 #[test]
1982 fn supply_state_tolerates_nulls_and_strings() {
1983 let state: SupplyState = serde_json::from_value(json!({
1984 "netname": "supply1", "channel": "1", "error": null,
1985 "voltage": "3.3", "current": null, "enabled": 1,
1986 }))
1987 .unwrap();
1988 assert_eq!(state.channel, Some(1));
1989 assert_eq!(state.voltage, Some(3.3));
1990 assert_eq!(state.current, None);
1991 assert_eq!(state.enabled, Some(true));
1992 }
1993
1994 #[test]
1995 fn safety_limits_body_omits_unset_fields() {
1996 let req = safety_limits_set(
2000 "supply1",
2001 &SafetyLimits {
2002 max_voltage: Some(5.0),
2003 ..Default::default()
2004 },
2005 );
2006 assert_eq!(req.method, Method::Put);
2007 assert_eq!(req.path, "/nets/supply1/safety-limits");
2008 assert_eq!(req.body, Some(json!({ "max_voltage": 5.0 })));
2009
2010 let clear = safety_limits_set("supply1", &SafetyLimits::default());
2011 assert_eq!(clear.body, Some(json!({})));
2012 assert!(SafetyLimits::default().is_empty());
2013 }
2014
2015 #[test]
2016 fn parse_safety_limits_roundtrip_and_clear() {
2017 let applied = parse_safety_limits(
2018 200,
2019 json!({"ok": true, "name": "supply1",
2020 "safety_limits": {"max_voltage": 5.0, "allow_destructive": false}}),
2021 )
2022 .unwrap()
2023 .unwrap();
2024 assert_eq!(applied.max_voltage, Some(5.0));
2025 assert_eq!(applied.max_current, None);
2026 assert_eq!(applied.allow_destructive, Some(false));
2027
2028 let cleared = parse_safety_limits(200, json!({"ok": true, "safety_limits": null})).unwrap();
2030 assert!(cleared.is_none());
2031 }
2032
2033 #[test]
2034 fn parse_safety_limits_surfaces_box_refusals() {
2035 let err = parse_safety_limits(400, json!({"error": "max_power is not supported: ..."}))
2036 .unwrap_err();
2037 match err {
2038 Error::Box { status, message } => {
2039 assert_eq!(status, 400);
2040 assert!(message.contains("max_power"));
2041 }
2042 other => panic!("unexpected error: {other:?}"),
2043 }
2044 }
2045
2046 #[test]
2047 fn net_record_carries_safety_limits() {
2048 let rec: NetRecord = serde_json::from_value(json!({
2049 "name": "supply1", "role": "power-supply",
2050 "safety_limits": {"max_voltage": 12.0, "max_current": 2.5},
2051 }))
2052 .unwrap();
2053 let limits = rec.safety_limits.unwrap();
2054 assert_eq!(limits.max_voltage, Some(12.0));
2055 assert_eq!(limits.max_current, Some(2.5));
2056 assert_eq!(limits.allow_destructive, None);
2057
2058 let rec: NetRecord =
2060 serde_json::from_value(json!({"name": "gpio1", "role": "gpio"})).unwrap();
2061 assert!(rec.safety_limits.is_none());
2062 }
2063
2064 #[test]
2065 fn capabilities_parse_safety_limits_flag() {
2066 let caps: BoxCapabilities =
2067 serde_json::from_value(json!({"netCommand": true, "safetyLimits": true})).unwrap();
2068 assert!(caps.safety_limits);
2069 let caps: BoxCapabilities = serde_json::from_value(json!({"netCommand": true})).unwrap();
2071 assert!(!caps.safety_limits);
2072 }
2073}