1use std::time::Duration;
9
10use serde::de::DeserializeOwned;
11use serde::Deserialize;
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}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Timeout {
44 Default,
46 After(Duration),
48 Unbounded,
50}
51
52#[derive(Debug, Clone)]
54pub struct HttpRequest {
55 pub method: Method,
57 pub path: String,
59 pub body: Option<Value>,
61 pub timeout: Timeout,
63}
64
65pub struct Op<T> {
69 pub req: HttpRequest,
71 pub parse: fn(CommandResponse) -> Result<T>,
73}
74
75#[derive(Debug, Clone, Deserialize)]
82pub struct CommandResponse {
83 #[serde(default)]
85 pub success: bool,
86 #[serde(default)]
88 pub action: Option<String>,
89 #[serde(default)]
91 pub message: Option<String>,
92 #[serde(default)]
94 pub value: Option<Value>,
95 #[serde(default)]
97 pub state: Option<Value>,
98 #[serde(default)]
100 pub error: Option<String>,
101 #[serde(flatten)]
103 pub extra: Map<String, Value>,
104}
105
106pub fn parse_command(status: u16, body: Value) -> Result<CommandResponse> {
114 let resp: CommandResponse = serde_json::from_value(body)
115 .map_err(|e| Error::Decode(format!("invalid response envelope: {e}")))?;
116 if status == 200 && resp.success {
117 return Ok(resp);
118 }
119 let message = resp
120 .error
121 .or(resp.message)
122 .unwrap_or_else(|| format!("HTTP {status}"));
123 if status == 501 {
124 return Err(Error::UnsupportedByBox { message });
125 }
126 Err(Error::Box { status, message })
127}
128
129pub fn net_command(
145 netname: &str,
146 role: impl Into<Option<&'static str>>,
147 action: &str,
148 params: Value,
149 timeout: Timeout,
150) -> HttpRequest {
151 let mut body = json!({
152 "netname": netname,
153 "action": action,
154 "params": params,
155 });
156 if let Some(role) = role.into() {
157 body["role"] = json!(role);
158 }
159 HttpRequest {
160 method: Method::Post,
161 path: "/net/command".to_string(),
162 body: Some(body),
163 timeout,
164 }
165}
166
167pub fn supply_command(netname: &str, action: &str, params: Value) -> HttpRequest {
169 HttpRequest {
170 method: Method::Post,
171 path: "/supply/command".to_string(),
172 body: Some(json!({
173 "netname": netname,
174 "action": action,
175 "params": params,
176 })),
177 timeout: Timeout::Default,
178 }
179}
180
181pub fn battery_command(netname: &str, action: &str, params: Value) -> HttpRequest {
183 HttpRequest {
184 method: Method::Post,
185 path: "/battery/command".to_string(),
186 body: Some(json!({
187 "netname": netname,
188 "action": action,
189 "params": params,
190 })),
191 timeout: Timeout::Default,
192 }
193}
194
195pub fn usb_command(netname: &str, action: &str) -> HttpRequest {
197 HttpRequest {
198 method: Method::Post,
199 path: "/usb/command".to_string(),
200 body: Some(json!({
201 "netname": netname,
202 "action": action,
203 })),
204 timeout: Timeout::Default,
205 }
206}
207
208pub fn box_command(path: &str, action: &str, params: Value, timeout: Timeout) -> HttpRequest {
213 HttpRequest {
214 method: Method::Post,
215 path: path.to_string(),
216 body: Some(json!({
217 "action": action,
218 "params": params,
219 })),
220 timeout,
221 }
222}
223
224pub fn get(path: &str) -> HttpRequest {
226 HttpRequest {
227 method: Method::Get,
228 path: path.to_string(),
229 body: None,
230 timeout: Timeout::Default,
231 }
232}
233
234pub(crate) fn base_url(host: &str) -> Result<String> {
240 base_url_with_port(host, DEFAULT_PORT)
241}
242
243pub(crate) fn base_url_with_port(host: &str, default_port: u16) -> Result<String> {
246 let input = host.trim();
247 let (scheme, rest) = match input.split_once("://") {
248 Some((scheme, rest)) => (scheme, rest),
249 None => ("http", input),
250 };
251 let rest = rest.trim_end_matches('/');
252 if scheme.is_empty() || rest.is_empty() {
253 return Err(Error::Config(format!("invalid box host '{host}'")));
254 }
255 if rest.contains(':') {
256 Ok(format!("{scheme}://{rest}"))
257 } else {
258 Ok(format!("{scheme}://{rest}:{default_port}"))
259 }
260}
261
262pub(crate) fn service_base(base: &str, port: u16) -> String {
266 let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
267 let host = rest.rsplit_once(':').map(|(h, _)| h).unwrap_or(rest);
268 format!("{scheme}://{host}:{port}")
269}
270
271pub fn debug_request(path: &str, body: Value, timeout: Timeout) -> HttpRequest {
277 HttpRequest {
278 method: Method::Post,
279 path: path.to_string(),
280 body: Some(body),
281 timeout,
282 }
283}
284
285pub fn parse_debug(status: u16, body: Value) -> Result<Value> {
289 if status == 200 {
290 return Ok(body);
291 }
292 let message = body
293 .get("error")
294 .and_then(Value::as_str)
295 .or_else(|| body.get("message").and_then(Value::as_str))
296 .unwrap_or("debug service request failed")
297 .to_string();
298 Err(Error::Box { status, message })
299}
300
301#[derive(Debug, Clone, Deserialize)]
303pub struct GdbServer {
304 #[serde(default)]
306 pub status: Option<String>,
307 #[serde(default, deserialize_with = "lenient::opt_i64")]
309 pub gdb_port: Option<i64>,
310 #[serde(default, deserialize_with = "lenient::opt_i64")]
312 pub swo_port: Option<i64>,
313 #[serde(default, deserialize_with = "lenient::opt_i64")]
315 pub telnet_port: Option<i64>,
316 #[serde(default, deserialize_with = "lenient::opt_i64")]
318 pub tcl_port: Option<i64>,
319 #[serde(default, deserialize_with = "lenient::opt_i64")]
321 pub rtt_telnet_port: Option<i64>,
322 #[serde(default, deserialize_with = "lenient::opt_i64")]
324 pub pid: Option<i64>,
325}
326
327#[derive(Debug, Clone, Deserialize)]
329pub struct DebugConnection {
330 #[serde(default)]
332 pub status: Option<String>,
333 #[serde(default)]
335 pub device: Option<String>,
336 #[serde(default)]
338 pub probe: Option<String>,
339 #[serde(default)]
341 pub serial: Option<String>,
342 #[serde(default)]
344 pub backend: Option<String>,
345 #[serde(default)]
347 pub message: Option<String>,
348 #[serde(default, deserialize_with = "lenient::opt_i64")]
350 pub pid: Option<i64>,
351 #[serde(default)]
353 pub gdb_server: Option<GdbServer>,
354}
355
356#[derive(Debug, Clone, Deserialize)]
358pub struct DebugInfo {
359 #[serde(default)]
361 pub net_name: Option<String>,
362 #[serde(default)]
364 pub device: Option<String>,
365 #[serde(default)]
367 pub arch: Option<String>,
368 #[serde(default)]
370 pub probe: Option<String>,
371 #[serde(default)]
373 pub serial: Option<String>,
374 #[serde(default)]
376 pub backend: Option<String>,
377 #[serde(default)]
379 pub connected: bool,
380}
381
382#[derive(Debug, Clone, Deserialize)]
384pub struct DebugStatus {
385 #[serde(default)]
387 pub connected: bool,
388 #[serde(default, deserialize_with = "lenient::opt_i64")]
390 pub pid: Option<i64>,
391 #[serde(default)]
393 pub serial: Option<String>,
394 #[serde(default)]
396 pub backend: Option<String>,
397}
398
399pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
401 let hex = body
402 .get("data")
403 .and_then(Value::as_str)
404 .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
405 decode_hex(hex)
406}
407
408pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
410 let s = s.trim();
411 if s.len() % 2 != 0 {
412 return Err(Error::Decode("odd-length hex string".to_string()));
413 }
414 (0..s.len())
415 .step_by(2)
416 .map(|i| {
417 u8::from_str_radix(&s[i..i + 2], 16)
418 .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
419 })
420 .collect()
421}
422
423pub(crate) fn as_f64(v: &Value) -> Option<f64> {
431 match v {
432 Value::Number(n) => n.as_f64(),
433 Value::String(s) => s.trim().parse().ok(),
434 Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
435 _ => None,
436 }
437}
438
439pub(crate) fn as_i64(v: &Value) -> Option<i64> {
440 match v {
441 Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
442 Value::String(s) => s
443 .trim()
444 .parse::<i64>()
445 .ok()
446 .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
447 Value::Bool(b) => Some(i64::from(*b)),
448 _ => None,
449 }
450}
451
452pub(crate) fn as_bool(v: &Value) -> Option<bool> {
453 match v {
454 Value::Bool(b) => Some(*b),
455 Value::Number(n) => n.as_f64().map(|f| f != 0.0),
456 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
457 "true" | "on" | "1" | "yes" | "enabled" => Some(true),
458 "false" | "off" | "0" | "no" | "disabled" => Some(false),
459 _ => None,
460 },
461 _ => None,
462 }
463}
464
465pub(crate) mod lenient {
466 use serde::{Deserialize, Deserializer};
470 use serde_json::Value;
471
472 pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
473 let v = Option::<Value>::deserialize(d)?;
474 Ok(v.as_ref().and_then(super::as_f64))
475 }
476
477 pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
478 let v = Option::<Value>::deserialize(d)?;
479 Ok(v.as_ref().and_then(super::as_i64))
480 }
481
482 pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
483 let v = Option::<Value>::deserialize(d)?;
484 Ok(v.as_ref().and_then(super::as_bool))
485 }
486}
487
488pub fn value_f64(resp: CommandResponse) -> Result<f64> {
494 resp.value
495 .as_ref()
496 .and_then(as_f64)
497 .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
498}
499
500pub fn value_i64(resp: CommandResponse) -> Result<i64> {
502 resp.value
503 .as_ref()
504 .and_then(as_i64)
505 .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
506}
507
508pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
511 let arr = resp
512 .value
513 .as_ref()
514 .and_then(Value::as_array)
515 .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
516 arr.iter()
517 .map(|v| {
518 as_i64(v)
519 .and_then(|n| u32::try_from(n).ok())
520 .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
521 })
522 .collect()
523}
524
525pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
527 let v = resp
528 .value
529 .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
530 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
531}
532
533pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
535 let v = resp
536 .state
537 .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
538 serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
539}
540
541pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
544 match body {
545 Value::Array(a) => a,
546 Value::Object(mut o) => match o.remove("nets") {
547 Some(Value::Array(a)) => a,
548 _ => vec![],
549 },
550 _ => vec![],
551 }
552}
553
554pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
556 let list = Value::Array(nets_list_values(body));
557 serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
558}
559
560pub fn unit(_resp: CommandResponse) -> Result<()> {
562 Ok(())
563}
564
565pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
567 Ok(resp)
568}
569
570#[derive(Debug, Clone, Deserialize)]
576pub struct NetRecord {
577 #[serde(default)]
579 pub name: String,
580 #[serde(default)]
582 pub role: String,
583 #[serde(default)]
585 pub instrument: Option<String>,
586 #[serde(default)]
588 pub pin: Option<Value>,
589 #[serde(default)]
591 pub channel: Option<Value>,
592 #[serde(default)]
594 pub address: Option<String>,
595 #[serde(default)]
597 pub params: Option<Map<String, Value>>,
598 #[serde(flatten)]
600 pub extra: Map<String, Value>,
601}
602
603#[derive(Debug, Clone, Deserialize)]
605pub struct Health {
606 #[serde(default)]
608 pub status: String,
609 #[serde(default)]
611 pub service: Option<String>,
612 #[serde(default)]
614 pub version: Option<String>,
615}
616
617#[derive(Debug, Clone, Deserialize)]
619pub struct NetSummary {
620 #[serde(default)]
622 pub name: String,
623 #[serde(default, rename = "type")]
625 pub net_type: String,
626}
627
628#[derive(Debug, Clone, Default, Deserialize)]
630pub struct BoxCapabilities {
631 #[serde(default, rename = "netCommand")]
634 pub net_command: bool,
635 #[serde(default, rename = "netCommandRoles")]
639 pub net_command_roles: Vec<String>,
640 #[serde(default, rename = "bleCommand")]
642 pub ble_command: bool,
643 #[serde(default, rename = "wifiCommand")]
645 pub wifi_command: bool,
646 #[serde(default, rename = "blufiCommand")]
648 pub blufi_command: bool,
649 #[serde(default, rename = "customDevices")]
653 pub custom_devices: bool,
654 #[serde(default)]
657 pub binaries: bool,
658}
659
660#[derive(Debug, Clone, Deserialize)]
662pub struct BoxStatus {
663 #[serde(default)]
665 pub healthy: bool,
666 #[serde(default)]
668 pub version: String,
669 #[serde(default)]
671 pub nets: Vec<NetSummary>,
672 #[serde(default)]
674 pub capabilities: BoxCapabilities,
675}
676
677#[derive(Debug, Clone, Deserialize)]
682pub struct SupplyState {
683 #[serde(default)]
685 pub netname: Option<String>,
686 #[serde(default, deserialize_with = "lenient::opt_i64")]
688 pub channel: Option<i64>,
689 #[serde(default)]
691 pub error: Option<String>,
692 #[serde(default, deserialize_with = "lenient::opt_f64")]
694 pub voltage: Option<f64>,
695 #[serde(default, deserialize_with = "lenient::opt_f64")]
697 pub current: Option<f64>,
698 #[serde(default, deserialize_with = "lenient::opt_f64")]
700 pub power: Option<f64>,
701 #[serde(default, deserialize_with = "lenient::opt_bool")]
703 pub enabled: Option<bool>,
704 #[serde(default)]
706 pub mode: Option<String>,
707 #[serde(default, deserialize_with = "lenient::opt_f64")]
709 pub voltage_set: Option<f64>,
710 #[serde(default, deserialize_with = "lenient::opt_f64")]
712 pub current_set: Option<f64>,
713 #[serde(default, deserialize_with = "lenient::opt_f64")]
715 pub voltage_max: Option<f64>,
716 #[serde(default, deserialize_with = "lenient::opt_f64")]
718 pub current_max: Option<f64>,
719 #[serde(default, deserialize_with = "lenient::opt_f64")]
721 pub ocp_limit: Option<f64>,
722 #[serde(default, deserialize_with = "lenient::opt_bool")]
724 pub ocp_tripped: Option<bool>,
725 #[serde(default, deserialize_with = "lenient::opt_f64")]
727 pub ovp_limit: Option<f64>,
728 #[serde(default, deserialize_with = "lenient::opt_bool")]
730 pub ovp_tripped: Option<bool>,
731}
732
733#[derive(Debug, Clone, Deserialize)]
737pub struct BatteryState {
738 #[serde(default)]
740 pub netname: Option<String>,
741 #[serde(default, deserialize_with = "lenient::opt_i64")]
743 pub channel: Option<i64>,
744 #[serde(default)]
746 pub error: Option<String>,
747 #[serde(default, deserialize_with = "lenient::opt_f64")]
749 pub terminal_voltage: Option<f64>,
750 #[serde(default, deserialize_with = "lenient::opt_f64")]
752 pub current: Option<f64>,
753 #[serde(default, deserialize_with = "lenient::opt_f64")]
755 pub esr: Option<f64>,
756 #[serde(default, deserialize_with = "lenient::opt_f64")]
758 pub soc: Option<f64>,
759 #[serde(default, deserialize_with = "lenient::opt_f64")]
761 pub voc: Option<f64>,
762 #[serde(default, deserialize_with = "lenient::opt_bool")]
764 pub enabled: Option<bool>,
765 #[serde(default)]
767 pub mode: Option<String>,
768 #[serde(default)]
770 pub model: Option<String>,
771 #[serde(default, deserialize_with = "lenient::opt_f64")]
773 pub capacity: Option<f64>,
774 #[serde(default, deserialize_with = "lenient::opt_f64")]
776 pub current_limit: Option<f64>,
777 #[serde(default, deserialize_with = "lenient::opt_f64")]
779 pub ocp_limit: Option<f64>,
780 #[serde(default, deserialize_with = "lenient::opt_f64")]
782 pub ovp_limit: Option<f64>,
783 #[serde(default, deserialize_with = "lenient::opt_f64")]
785 pub volt_full: Option<f64>,
786 #[serde(default, deserialize_with = "lenient::opt_f64")]
788 pub volt_empty: Option<f64>,
789 #[serde(default, deserialize_with = "lenient::opt_bool")]
791 pub ocp_tripped: Option<bool>,
792 #[serde(default, deserialize_with = "lenient::opt_bool")]
794 pub ovp_tripped: Option<bool>,
795}
796
797#[derive(Debug, Clone, Deserialize)]
799pub struct EloadState {
800 #[serde(default)]
802 pub mode: Option<String>,
803 #[serde(default, deserialize_with = "lenient::opt_bool")]
805 pub input_enabled: Option<bool>,
806 #[serde(default, deserialize_with = "lenient::opt_f64")]
808 pub measured_voltage: Option<f64>,
809 #[serde(default, deserialize_with = "lenient::opt_f64")]
811 pub measured_current: Option<f64>,
812 #[serde(default, deserialize_with = "lenient::opt_f64")]
814 pub measured_power: Option<f64>,
815 #[serde(flatten)]
817 pub extra: Map<String, Value>,
818}
819
820#[derive(Debug, Clone, Deserialize)]
822pub struct WattReading {
823 #[serde(default, deserialize_with = "lenient::opt_f64")]
825 pub current: Option<f64>,
826 #[serde(default, deserialize_with = "lenient::opt_f64")]
828 pub voltage: Option<f64>,
829 #[serde(default, deserialize_with = "lenient::opt_f64")]
831 pub power: Option<f64>,
832 #[serde(default, deserialize_with = "lenient::opt_f64")]
834 pub duration_s: Option<f64>,
835}
836
837#[derive(Debug, Clone, Deserialize)]
839pub struct EnergyReading {
840 #[serde(default, deserialize_with = "lenient::opt_f64")]
842 pub energy_j: Option<f64>,
843 #[serde(default, deserialize_with = "lenient::opt_f64")]
845 pub charge_c: Option<f64>,
846 #[serde(default, deserialize_with = "lenient::opt_f64")]
848 pub duration_s: Option<f64>,
849 #[serde(flatten)]
851 pub extra: Map<String, Value>,
852}
853
854#[derive(Debug, Clone, Default, Deserialize)]
856pub struct StatSummary {
857 #[serde(default, deserialize_with = "lenient::opt_f64")]
859 pub mean: Option<f64>,
860 #[serde(default, deserialize_with = "lenient::opt_f64")]
862 pub min: Option<f64>,
863 #[serde(default, deserialize_with = "lenient::opt_f64")]
865 pub max: Option<f64>,
866 #[serde(default, deserialize_with = "lenient::opt_f64")]
868 pub std: Option<f64>,
869 #[serde(flatten)]
871 pub extra: Map<String, Value>,
872}
873
874#[derive(Debug, Clone, Deserialize)]
876pub struct EnergyStats {
877 #[serde(default)]
879 pub current: Option<StatSummary>,
880 #[serde(default)]
882 pub voltage: Option<StatSummary>,
883 #[serde(default)]
885 pub power: Option<StatSummary>,
886 #[serde(flatten)]
888 pub extra: Map<String, Value>,
889}
890
891#[derive(Debug, Clone, Copy, PartialEq)]
898pub struct ArmPosition {
899 pub x: f64,
901 pub y: f64,
903 pub z: f64,
905}
906
907pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
909 let arr = resp
910 .value
911 .as_ref()
912 .and_then(Value::as_array)
913 .ok_or_else(|| Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value)))?;
914 match arr.as_slice() {
915 [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
916 (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
917 _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
918 },
919 _ => Err(Error::Decode(format!(
920 "expected 3-element arm position, got {} elements",
921 arr.len()
922 ))),
923 }
924}
925
926#[derive(Debug, Clone, Deserialize)]
928pub struct WebcamStream {
929 #[serde(default)]
931 pub url: String,
932 #[serde(default, deserialize_with = "lenient::opt_i64")]
934 pub port: Option<i64>,
935 #[serde(default)]
937 pub already_running: bool,
938}
939
940#[derive(Debug, Clone, Deserialize)]
942pub struct WebcamStatus {
943 #[serde(default)]
945 pub running: bool,
946 #[serde(default)]
948 pub url: Option<String>,
949 #[serde(default, deserialize_with = "lenient::opt_i64")]
951 pub port: Option<i64>,
952 #[serde(default)]
954 pub video_device: Option<String>,
955}
956
957#[derive(Debug, Clone, Deserialize)]
959pub struct RouterSystemInfo {
960 #[serde(default)]
962 pub name: Option<String>,
963 #[serde(default)]
965 pub version: Option<String>,
966 #[serde(default)]
968 pub board: Option<String>,
969 #[serde(default)]
971 pub architecture: Option<String>,
972 #[serde(default)]
974 pub uptime: Option<String>,
975 #[serde(default, deserialize_with = "lenient::opt_i64")]
977 pub cpu_load: Option<i64>,
978 #[serde(default, deserialize_with = "lenient::opt_i64")]
980 pub free_memory: Option<i64>,
981 #[serde(default, deserialize_with = "lenient::opt_i64")]
983 pub total_memory: Option<i64>,
984 #[serde(default, deserialize_with = "lenient::opt_i64")]
986 pub free_hdd_space: Option<i64>,
987 #[serde(flatten)]
989 pub extra: Map<String, Value>,
990}
991
992#[derive(Debug, Clone, Deserialize)]
994pub struct BleDevice {
995 #[serde(default)]
997 pub name: String,
998 #[serde(default)]
1000 pub address: String,
1001 #[serde(default, deserialize_with = "lenient::opt_i64")]
1003 pub rssi: Option<i64>,
1004 #[serde(default)]
1006 pub uuids: Vec<String>,
1007}
1008
1009#[derive(Debug, Clone, Deserialize)]
1011pub struct BleCharacteristic {
1012 #[serde(default)]
1014 pub uuid: String,
1015 #[serde(default)]
1017 pub description: Option<String>,
1018 #[serde(default)]
1020 pub properties: Vec<String>,
1021}
1022
1023#[derive(Debug, Clone, Deserialize)]
1025pub struct BleService {
1026 #[serde(default)]
1028 pub uuid: String,
1029 #[serde(default)]
1031 pub description: Option<String>,
1032 #[serde(default)]
1034 pub characteristics: Vec<BleCharacteristic>,
1035}
1036
1037#[derive(Debug, Clone, Deserialize)]
1039pub struct BleDeviceInfo {
1040 #[serde(default)]
1042 pub address: String,
1043 #[serde(default)]
1045 pub connected: bool,
1046 #[serde(default)]
1048 pub services: Vec<BleService>,
1049}
1050
1051#[derive(Debug, Clone, Deserialize)]
1053pub struct WifiInterface {
1054 #[serde(default)]
1056 pub interface: String,
1057 #[serde(default)]
1059 pub ssid: String,
1060 #[serde(default)]
1062 pub state: String,
1063}
1064
1065#[derive(Debug, Clone, Deserialize)]
1067pub struct WifiAccessPoint {
1068 #[serde(default)]
1070 pub ssid: Option<String>,
1071 #[serde(default)]
1073 pub address: Option<String>,
1074 #[serde(default, deserialize_with = "lenient::opt_i64")]
1076 pub strength: Option<i64>,
1077 #[serde(default)]
1079 pub security: Option<String>,
1080}
1081
1082#[derive(Debug, Clone, Deserialize)]
1084pub struct WifiConnection {
1085 #[serde(default)]
1087 pub ssid: String,
1088 #[serde(default)]
1090 pub connected: bool,
1091 #[serde(default)]
1093 pub interface: Option<String>,
1094 #[serde(default)]
1096 pub method: Option<String>,
1097}
1098
1099#[derive(Debug, Clone, Deserialize)]
1102pub struct BlufiStatus {
1103 #[serde(default)]
1105 pub device_name: Option<String>,
1106 #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
1108 pub op_mode: Option<i64>,
1109 #[serde(default, rename = "opModeName")]
1111 pub op_mode_name: Option<String>,
1112 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1114 pub sta_conn: Option<i64>,
1115 #[serde(default, rename = "staConnName")]
1117 pub sta_conn_name: Option<String>,
1118 #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
1120 pub soft_ap_conn: Option<i64>,
1121}
1122
1123#[derive(Debug, Clone, Deserialize)]
1125pub struct BlufiDeviceInfo {
1126 #[serde(default)]
1128 pub version: Option<String>,
1129 #[serde(flatten)]
1131 pub status: BlufiStatus,
1132}
1133
1134#[derive(Debug, Clone, Deserialize)]
1136pub struct BlufiProvisionResult {
1137 #[serde(default)]
1139 pub device_name: Option<String>,
1140 #[serde(default)]
1142 pub ssid: String,
1143 #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
1145 pub sta_conn: Option<i64>,
1146 #[serde(default, rename = "staConnName")]
1148 pub sta_conn_name: Option<String>,
1149}
1150
1151#[derive(Debug, Clone, Deserialize)]
1153pub struct BlufiNetwork {
1154 #[serde(default)]
1156 pub ssid: String,
1157 #[serde(default, deserialize_with = "lenient::opt_i64")]
1159 pub rssi: Option<i64>,
1160}
1161
1162pub(crate) fn value_list_field<T: DeserializeOwned>(
1165 resp: CommandResponse,
1166 field: &str,
1167) -> Result<Vec<T>> {
1168 let list = resp
1169 .value
1170 .as_ref()
1171 .and_then(|v| v.get(field))
1172 .cloned()
1173 .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
1174 serde_json::from_value(list)
1175 .map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181
1182 #[test]
1183 fn parse_success_envelope() {
1184 let resp = parse_command(
1185 200,
1186 json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
1187 )
1188 .unwrap();
1189 assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
1190 }
1191
1192 #[test]
1193 fn parse_failure_with_http_200_is_box_error() {
1194 let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
1196 match err {
1197 Error::Box { status, message } => {
1198 assert_eq!(status, 200);
1199 assert_eq!(message, "conflict");
1200 }
1201 other => panic!("unexpected error: {other:?}"),
1202 }
1203 }
1204
1205 #[test]
1206 fn parse_501_is_unsupported() {
1207 let err = parse_command(
1208 501,
1209 json!({"success": false, "error": "Role 'gpio' is not supported"}),
1210 )
1211 .unwrap_err();
1212 assert!(matches!(err, Error::UnsupportedByBox { .. }));
1213 }
1214
1215 #[test]
1216 fn base_url_normalization() {
1217 assert_eq!(base_url("192.168.1.42").unwrap(), "http://192.168.1.42:9000");
1218 assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
1219 assert_eq!(base_url("192.168.1.42:8080").unwrap(), "http://192.168.1.42:8080");
1220 assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
1221 assert!(base_url("").is_err());
1222 assert!(base_url("http://").is_err());
1223 }
1224
1225 #[test]
1226 fn lenient_numbers() {
1227 assert_eq!(as_f64(&json!("3.3")), Some(3.3));
1228 assert_eq!(as_f64(&json!(3.3)), Some(3.3));
1229 assert_eq!(as_bool(&json!("ON")), Some(true));
1230 assert_eq!(as_bool(&json!(0)), Some(false));
1231 assert_eq!(as_i64(&json!("42")), Some(42));
1232 }
1233
1234 #[test]
1235 fn supply_state_tolerates_nulls_and_strings() {
1236 let state: SupplyState = serde_json::from_value(json!({
1237 "netname": "supply1", "channel": "1", "error": null,
1238 "voltage": "3.3", "current": null, "enabled": 1,
1239 }))
1240 .unwrap();
1241 assert_eq!(state.channel, Some(1));
1242 assert_eq!(state.voltage, Some(3.3));
1243 assert_eq!(state.current, None);
1244 assert_eq!(state.enabled, Some(true));
1245 }
1246}