lager-net 0.4.0

First-class Rust client for Lager box nets: drive power supplies, batteries, e-loads, GPIO, ADC, DAC, SPI, I2C, UART and more from cargo tests.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
//! Sans-io wire layer: request builders and response parsers for the Lager
//! box HTTP API on port 9000.
//!
//! Everything in this module is pure data-in/data-out. Both the blocking
//! client ([`crate::LagerBox`]) and the async client run the exact same
//! builders and parsers, so the two transports cannot drift apart.

use std::time::Duration;

use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};

use crate::error::{Error, Result};

/// Default port of the box HTTP server (`box_http_server.py`).
pub const DEFAULT_PORT: u16 = 9000;

/// Port of the box debug service (`lager.debug.service`), published on the
/// box host. Debug nets talk here rather than to the port-9000 server.
pub const DEBUG_SERVICE_PORT: u16 = 8765;

/// Default HTTP timeout for quick net commands, matching the Lager CLI's
/// 10-second budget.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);

/// HTTP method of a request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
    /// HTTP GET.
    Get,
    /// HTTP POST with a JSON body.
    Post,
    /// HTTP PUT with a JSON body (`/nets/<name>/safety-limits`).
    Put,
}

/// Client-side timeout policy for one request.
///
/// Mirrors the Lager CLI's budgets: quick commands get the 10s default,
/// while actions that block on the box for a caller-controlled duration
/// (watt/energy integration windows, `wait_for_level`) widen or drop the
/// client timeout so a healthy request is never aborted mid-measurement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Timeout {
    /// Use the client's configured default (10s unless overridden).
    Default,
    /// Use this specific timeout.
    After(Duration),
    /// No client-side timeout at all (e.g. an unbounded `wait_for_level`).
    Unbounded,
}

/// One fully-described HTTP request to the box, transport-agnostic.
#[derive(Debug, Clone)]
pub struct HttpRequest {
    /// HTTP method.
    pub method: Method,
    /// Path relative to the box base URL, e.g. `"/net/command"`.
    pub path: String,
    /// JSON body for POST requests.
    pub body: Option<Value>,
    /// Client-side timeout policy.
    pub timeout: Timeout,
}

/// A complete operation: the request to send plus the parser that turns the
/// box's response envelope into a typed value. Net handles build these; the
/// sync/async clients only execute them.
pub struct Op<T> {
    /// The request to send.
    pub req: HttpRequest,
    /// Parser applied to the successful response envelope.
    pub parse: fn(CommandResponse) -> Result<T>,
}

// ---------------------------------------------------------------------------
// Response envelope
// ---------------------------------------------------------------------------

/// The common response envelope every box command endpoint returns:
/// `{"success": bool, "action": ..., "message": ..., "value": ..., "state": ...}`.
#[derive(Debug, Clone, Deserialize)]
pub struct CommandResponse {
    /// Whether the box executed the command.
    #[serde(default)]
    pub success: bool,
    /// Echo of the action that ran.
    #[serde(default)]
    pub action: Option<String>,
    /// Human-readable result message (what the CLI prints).
    #[serde(default)]
    pub message: Option<String>,
    /// Structured result value, shape depends on the action.
    #[serde(default)]
    pub value: Option<Value>,
    /// Structured state object (supply/battery `state`, usb port state).
    #[serde(default)]
    pub state: Option<Value>,
    /// Error message when `success` is false.
    #[serde(default)]
    pub error: Option<String>,
    /// Any endpoint-specific extra fields (e.g. SPI's top-level `word_size`).
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// Interpret a raw `(status, body)` pair as the command envelope, applying
/// the box's error conventions:
///
/// - 200 + `success: true` is the only success shape.
/// - 501 means the box image predates the endpoint ([`Error::UnsupportedByBox`]).
/// - Everything else (including `success: false` with HTTP 200, which the box
///   uses for cross-role instrument conflicts) is [`Error::Box`].
pub fn parse_command(status: u16, body: Value) -> Result<CommandResponse> {
    let resp: CommandResponse = serde_json::from_value(body)
        .map_err(|e| Error::Decode(format!("invalid response envelope: {e}")))?;
    if status == 200 && resp.success {
        return Ok(resp);
    }
    let message = resp
        .error
        .or(resp.message)
        .unwrap_or_else(|| format!("HTTP {status}"));
    if status == 501 {
        return Err(Error::UnsupportedByBox { message });
    }
    Err(Error::Box { status, message })
}

// ---------------------------------------------------------------------------
// Generic request builders
// ---------------------------------------------------------------------------

/// Build a `POST /net/command` request (gpio, adc, dac, thermocouple,
/// watt-meter, eload, spi, i2c, energy-analyzer).
///
/// The `role` is sent as a hint; the box resolves the authoritative role from
/// its `saved_nets.json` and verifies the hint, so a typo'd net name or a
/// role mismatch fails loudly instead of driving the wrong instrument.
///
/// `role: None` omits the hint entirely and defers fully to the box's
/// resolution. Used for roles with saved-record aliases (router nets may be
/// saved as `"router"` or the legacy `"mikrotik"`; the box dispatches both
/// to the same handler, but a hint must match the saved string exactly).
pub fn net_command(
    netname: &str,
    role: impl Into<Option<&'static str>>,
    action: &str,
    params: Value,
    timeout: Timeout,
) -> HttpRequest {
    let mut body = json!({
        "netname": netname,
        "action": action,
        "params": params,
    });
    if let Some(role) = role.into() {
        body["role"] = json!(role);
    }
    HttpRequest {
        method: Method::Post,
        path: "/net/command".to_string(),
        body: Some(body),
        timeout,
    }
}

/// Build a `POST /supply/command` request.
pub fn supply_command(netname: &str, action: &str, params: Value) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: "/supply/command".to_string(),
        body: Some(json!({
            "netname": netname,
            "action": action,
            "params": params,
        })),
        timeout: Timeout::Default,
    }
}

/// Build a `POST /battery/command` request.
pub fn battery_command(netname: &str, action: &str, params: Value) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: "/battery/command".to_string(),
        body: Some(json!({
            "netname": netname,
            "action": action,
            "params": params,
        })),
        timeout: Timeout::Default,
    }
}

/// Build a `POST /usb/command` request (no `params` object on this endpoint).
pub fn usb_command(netname: &str, action: &str) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: "/usb/command".to_string(),
        body: Some(json!({
            "netname": netname,
            "action": action,
        })),
        timeout: Timeout::Default,
    }
}

/// Build a box-level command request (`POST /ble/command`, `/wifi/command`,
/// `/blufi/command`). These endpoints drive the box's own hardware (its
/// Bluetooth adapter or wlan interface) rather than a saved net, so the body
/// carries only `{action, params}` — no `netname`.
pub fn box_command(path: &str, action: &str, params: Value, timeout: Timeout) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: path.to_string(),
        body: Some(json!({
            "action": action,
            "params": params,
        })),
        timeout,
    }
}

/// Build a plain GET request (discovery/health endpoints).
pub fn get(path: &str) -> HttpRequest {
    HttpRequest {
        method: Method::Get,
        path: path.to_string(),
        body: None,
        timeout: Timeout::Default,
    }
}

/// Normalize a user-supplied host string into a base URL.
///
/// Accepts a bare host/IP (`"192.168.1.42"`, `"mybox.tailnet.ts.net"`), a
/// `host:port` pair, or a full URL. The scheme defaults to `http` and the
/// port to [`DEFAULT_PORT`] (9000).
pub(crate) fn base_url(host: &str) -> Result<String> {
    base_url_with_port(host, DEFAULT_PORT)
}

/// Like [`base_url`] but uses `default_port` when the host has no explicit
/// port. Used for the debug-service URL, which defaults to 8765.
pub(crate) fn base_url_with_port(host: &str, default_port: u16) -> Result<String> {
    let input = host.trim();
    let (scheme, rest) = match input.split_once("://") {
        Some((scheme, rest)) => (scheme, rest),
        None => ("http", input),
    };
    let rest = rest.trim_end_matches('/');
    if scheme.is_empty() || rest.is_empty() {
        return Err(Error::Config(format!("invalid box host '{host}'")));
    }
    if rest.contains(':') {
        Ok(format!("{scheme}://{rest}"))
    } else {
        Ok(format!("{scheme}://{rest}:{default_port}"))
    }
}

/// Derive a sibling service base URL on the same host but a different port,
/// e.g. turn `http://192.168.1.42:9000` into `http://192.168.1.42:8765` for
/// the debug service. `base` is expected to be normalized by [`base_url`].
pub(crate) fn service_base(base: &str, port: u16) -> String {
    let (scheme, rest) = base.split_once("://").unwrap_or(("http", base));
    let host = rest.rsplit_once(':').map(|(h, _)| h).unwrap_or(rest);
    format!("{scheme}://{host}:{port}")
}

// ---------------------------------------------------------------------------
// Debug service (:8765) — separate protocol from the :9000 command envelope
// ---------------------------------------------------------------------------

/// Build a `POST /debug/<op>` request for the debug service.
pub fn debug_request(path: &str, body: Value, timeout: Timeout) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: path.to_string(),
        body: Some(body),
        timeout,
    }
}

/// Interpret a debug-service `(status, body)` pair. The debug service does
/// not use the `success` envelope: 200 is success, and errors arrive as
/// `{"error": msg, "status": "error"}` with a 4xx/5xx code.
pub fn parse_debug(status: u16, body: Value) -> Result<Value> {
    if status == 200 {
        return Ok(body);
    }
    let message = body
        .get("error")
        .and_then(Value::as_str)
        .or_else(|| body.get("message").and_then(Value::as_str))
        .unwrap_or("debug service request failed")
        .to_string();
    Err(Error::Box { status, message })
}

/// GDB-server sub-object of a [`DebugConnection`].
#[derive(Debug, Clone, Deserialize)]
pub struct GdbServer {
    /// Server state, e.g. `"started"` or `"already_running"`.
    #[serde(default)]
    pub status: Option<String>,
    /// GDB protocol port.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub gdb_port: Option<i64>,
    /// SWO output port (J-Link only).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub swo_port: Option<i64>,
    /// Telnet I/O port.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub telnet_port: Option<i64>,
    /// TCL/RPC port (OpenOCD only).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub tcl_port: Option<i64>,
    /// RTT telnet port.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub rtt_telnet_port: Option<i64>,
    /// Server process id.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub pid: Option<i64>,
}

/// Result of a debug `connect`.
#[derive(Debug, Clone, Deserialize)]
pub struct DebugConnection {
    /// Connection status, e.g. `"connected"`.
    #[serde(default)]
    pub status: Option<String>,
    /// Resolved device/target type.
    #[serde(default)]
    pub device: Option<String>,
    /// Probe instrument name.
    #[serde(default)]
    pub probe: Option<String>,
    /// Probe serial.
    #[serde(default)]
    pub serial: Option<String>,
    /// Debug backend that started (`"jlink"` or `"openocd"`).
    #[serde(default)]
    pub backend: Option<String>,
    /// Human-readable message.
    #[serde(default)]
    pub message: Option<String>,
    /// Backend process id.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub pid: Option<i64>,
    /// GDB-server details, if a server was started.
    #[serde(default)]
    pub gdb_server: Option<GdbServer>,
}

/// Result of a debug `info` query.
#[derive(Debug, Clone, Deserialize)]
pub struct DebugInfo {
    /// Net name.
    #[serde(default)]
    pub net_name: Option<String>,
    /// Resolved device/target type.
    #[serde(default)]
    pub device: Option<String>,
    /// Target architecture.
    #[serde(default)]
    pub arch: Option<String>,
    /// Probe instrument name.
    #[serde(default)]
    pub probe: Option<String>,
    /// Probe serial.
    #[serde(default)]
    pub serial: Option<String>,
    /// Debug backend (`"jlink"` or `"openocd"`).
    #[serde(default)]
    pub backend: Option<String>,
    /// Whether a gdbserver/daemon is currently running for this probe.
    #[serde(default)]
    pub connected: bool,
}

/// Result of a debug `status` query.
#[derive(Debug, Clone, Deserialize)]
pub struct DebugStatus {
    /// Whether a gdbserver/daemon is currently running for this probe.
    #[serde(default)]
    pub connected: bool,
    /// Backend process id, when connected.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub pid: Option<i64>,
    /// Probe serial.
    #[serde(default)]
    pub serial: Option<String>,
    /// Debug backend (`"jlink"` or `"openocd"`).
    #[serde(default)]
    pub backend: Option<String>,
}

/// Extract the hex `data` field from a `/debug/memrd` response into bytes.
pub fn debug_memory_bytes(body: &Value) -> Result<Vec<u8>> {
    let hex = body
        .get("data")
        .and_then(Value::as_str)
        .ok_or_else(|| Error::Decode("memrd response missing 'data'".to_string()))?;
    decode_hex(hex)
}

/// Decode a hex string into bytes.
pub(crate) fn decode_hex(s: &str) -> Result<Vec<u8>> {
    let s = s.trim();
    if s.len() % 2 != 0 {
        return Err(Error::Decode("odd-length hex string".to_string()));
    }
    (0..s.len())
        .step_by(2)
        .map(|i| {
            u8::from_str_radix(&s[i..i + 2], 16)
                .map_err(|_| Error::Decode(format!("invalid hex byte '{}'", &s[i..i + 2])))
        })
        .collect()
}

// ---------------------------------------------------------------------------
// Lenient deserialization helpers
// ---------------------------------------------------------------------------
//
// Instrument drivers occasionally hand numbers back as strings (SCPI query
// results). These helpers accept a JSON number, a numeric string, or null.

pub(crate) fn as_f64(v: &Value) -> Option<f64> {
    match v {
        Value::Number(n) => n.as_f64(),
        Value::String(s) => s.trim().parse().ok(),
        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
        _ => None,
    }
}

pub(crate) fn as_i64(v: &Value) -> Option<i64> {
    match v {
        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
        Value::String(s) => s
            .trim()
            .parse::<i64>()
            .ok()
            .or_else(|| s.trim().parse::<f64>().ok().map(|f| f as i64)),
        Value::Bool(b) => Some(i64::from(*b)),
        _ => None,
    }
}

pub(crate) fn as_bool(v: &Value) -> Option<bool> {
    match v {
        Value::Bool(b) => Some(*b),
        Value::Number(n) => n.as_f64().map(|f| f != 0.0),
        Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
            "true" | "on" | "1" | "yes" | "enabled" => Some(true),
            "false" | "off" | "0" | "no" | "disabled" => Some(false),
            _ => None,
        },
        _ => None,
    }
}

pub(crate) mod lenient {
    //! `deserialize_with` adapters for fields that may arrive as numbers,
    //! numeric strings, or null.

    use serde::{Deserialize, Deserializer};
    use serde_json::Value;

    pub fn opt_f64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
        let v = Option::<Value>::deserialize(d)?;
        Ok(v.as_ref().and_then(super::as_f64))
    }

    pub fn opt_i64<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
        let v = Option::<Value>::deserialize(d)?;
        Ok(v.as_ref().and_then(super::as_i64))
    }

    pub fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
        let v = Option::<Value>::deserialize(d)?;
        Ok(v.as_ref().and_then(super::as_bool))
    }
}

// ---------------------------------------------------------------------------
// Typed extraction from the envelope
// ---------------------------------------------------------------------------

/// Extract the `value` field as an `f64` (accepting numeric strings).
pub fn value_f64(resp: CommandResponse) -> Result<f64> {
    resp.value
        .as_ref()
        .and_then(as_f64)
        .ok_or_else(|| Error::Decode(format!("expected numeric 'value', got {:?}", resp.value)))
}

/// Extract the `value` field as an `i64` (accepting numeric strings).
pub fn value_i64(resp: CommandResponse) -> Result<i64> {
    resp.value
        .as_ref()
        .and_then(as_i64)
        .ok_or_else(|| Error::Decode(format!("expected integer 'value', got {:?}", resp.value)))
}

/// Extract the `value` field as a list of integers (I2C bytes/addresses,
/// SPI words).
pub fn value_int_list(resp: CommandResponse) -> Result<Vec<u32>> {
    let arr = resp
        .value
        .as_ref()
        .and_then(Value::as_array)
        .ok_or_else(|| Error::Decode(format!("expected list 'value', got {:?}", resp.value)))?;
    arr.iter()
        .map(|v| {
            as_i64(v)
                .and_then(|n| u32::try_from(n).ok())
                .ok_or_else(|| Error::Decode(format!("non-integer element in 'value': {v:?}")))
        })
        .collect()
}

/// Deserialize the `value` field into a typed struct.
pub fn value_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
    let v = resp
        .value
        .ok_or_else(|| Error::Decode("response has no 'value' field".to_string()))?;
    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'value' shape: {e}")))
}

/// Deserialize the `state` field into a typed struct.
pub fn state_as<T: DeserializeOwned>(resp: CommandResponse) -> Result<T> {
    let v = resp
        .state
        .ok_or_else(|| Error::Decode("response has no 'state' field".to_string()))?;
    serde_json::from_value(v).map_err(|e| Error::Decode(format!("invalid 'state' shape: {e}")))
}

/// Interpret a nets-list body into raw JSON values. `/nets/list` returns a
/// bare array; the older `/uart/nets/list` wraps it in `{"nets": [...]}`.
pub(crate) fn nets_list_values(body: Value) -> Vec<Value> {
    match body {
        Value::Array(a) => a,
        Value::Object(mut o) => match o.remove("nets") {
            Some(Value::Array(a)) => a,
            _ => vec![],
        },
        _ => vec![],
    }
}

/// Interpret a nets-list body into typed [`NetRecord`]s.
pub(crate) fn nets_from_body(body: Value) -> Result<Vec<NetRecord>> {
    let list = Value::Array(nets_list_values(body));
    serde_json::from_value(list).map_err(|e| Error::Decode(format!("invalid nets list: {e}")))
}

/// Discard the payload, keeping only success/failure.
pub fn unit(_resp: CommandResponse) -> Result<()> {
    Ok(())
}

/// Keep the whole envelope (for callers that want `message` etc.).
pub fn envelope(resp: CommandResponse) -> Result<CommandResponse> {
    Ok(resp)
}

// ---------------------------------------------------------------------------
// Shared response types
// ---------------------------------------------------------------------------

/// One saved-net record from `GET /nets/list` (the box's `saved_nets.json`).
#[derive(Debug, Clone, Deserialize)]
pub struct NetRecord {
    /// Net name, e.g. `"supply1"`.
    #[serde(default)]
    pub name: String,
    /// Role string, e.g. `"power-supply"`, `"gpio"`, `"adc"`.
    #[serde(default)]
    pub role: String,
    /// Instrument backing this net, e.g. `"Rigol DP832"`, `"LabJack T7"`.
    #[serde(default)]
    pub instrument: Option<String>,
    /// Pin / channel identifier (number or string depending on role).
    #[serde(default)]
    pub pin: Option<Value>,
    /// Channel (multi-channel instruments).
    #[serde(default)]
    pub channel: Option<Value>,
    /// VISA or device address.
    #[serde(default)]
    pub address: Option<String>,
    /// Role-specific saved parameters (SPI mode, UART baudrate, ...).
    #[serde(default)]
    pub params: Option<Map<String, Value>>,
    /// Safety ceilings the box enforces on this net (box >= 0.35.0).
    /// `None` means unrestricted.
    #[serde(default)]
    pub safety_limits: Option<SafetyLimits>,
    /// Everything else in the record (mappings, scope_points, ...).
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// Response of `GET /health`.
#[derive(Debug, Clone, Deserialize)]
pub struct Health {
    /// `"healthy"` when the box HTTP server is up.
    #[serde(default)]
    pub status: String,
    /// Service identifier.
    #[serde(default)]
    pub service: Option<String>,
    /// Service version string.
    #[serde(default)]
    pub version: Option<String>,
}

/// One net summary inside [`BoxStatus`].
#[derive(Debug, Clone, Deserialize)]
pub struct NetSummary {
    /// Net name.
    #[serde(default)]
    pub name: String,
    /// Net type name (e.g. `"PowerSupply"`, `"GPIO"`).
    #[serde(default, rename = "type")]
    pub net_type: String,
}

/// Capabilities advertised by the box in `GET /status`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BoxCapabilities {
    /// Whether the box serves `POST /net/command` (Tier-1 nets over HTTP).
    /// When false the box software predates this crate's contract.
    #[serde(default, rename = "netCommand")]
    pub net_command: bool,
    /// Roles served by `POST /net/command` on this box. Empty on box images
    /// that predate role advertising (which still serve the original Tier-1
    /// roles when `net_command` is true).
    #[serde(default, rename = "netCommandRoles")]
    pub net_command_roles: Vec<String>,
    /// Whether the box serves `POST /ble/command`.
    #[serde(default, rename = "bleCommand")]
    pub ble_command: bool,
    /// Whether the box serves `POST /wifi/command`.
    #[serde(default, rename = "wifiCommand")]
    pub wifi_command: bool,
    /// Whether the box serves `POST /blufi/command`.
    #[serde(default, rename = "blufiCommand")]
    pub blufi_command: bool,
    /// Whether the box serves the `/custom-devices/*` endpoints (the
    /// `lager nets assign` backend). Not used by this crate; surfaced for
    /// callers probing box features.
    #[serde(default, rename = "customDevices")]
    pub custom_devices: bool,
    /// Whether the box serves `/binaries/*` and `/download-file`. Not used
    /// by this crate; surfaced for callers probing box features.
    #[serde(default)]
    pub binaries: bool,
    /// Whether the box serves `PUT /nets/<name>/safety-limits` (box >=
    /// 0.35.0). The flag mirrors route registration rather than the version
    /// string, so a box whose nets handler failed to import reads `false`
    /// here even if its version says otherwise.
    #[serde(default, rename = "safetyLimits")]
    pub safety_limits: bool,
}

/// Response of `GET /status`.
#[derive(Debug, Clone, Deserialize)]
pub struct BoxStatus {
    /// Whether the box reports itself healthy.
    #[serde(default)]
    pub healthy: bool,
    /// Box software version.
    #[serde(default)]
    pub version: String,
    /// Configured nets (name + type only; use `nets()` for full records).
    #[serde(default)]
    pub nets: Vec<NetSummary>,
    /// Endpoint capabilities.
    #[serde(default)]
    pub capabilities: BoxCapabilities,
}

/// Structured power-supply state from the `state` action on
/// `POST /supply/command`. Fields are `None` when the individual read failed
/// (the box returns a full-shaped dict even on a degraded read; `error`
/// carries the reason).
#[derive(Debug, Clone, Deserialize)]
pub struct SupplyState {
    /// Net name echo.
    #[serde(default)]
    pub netname: Option<String>,
    /// Instrument channel driving this net.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub channel: Option<i64>,
    /// Transport-level error when the whole state gather failed.
    #[serde(default)]
    pub error: Option<String>,
    /// Measured output voltage (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub voltage: Option<f64>,
    /// Measured output current (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub current: Option<f64>,
    /// Measured output power (W).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub power: Option<f64>,
    /// Whether the output is enabled.
    #[serde(default, deserialize_with = "lenient::opt_bool")]
    pub enabled: Option<bool>,
    /// Operating mode (e.g. `"CV"`, `"CC"`).
    #[serde(default)]
    pub mode: Option<String>,
    /// Voltage setpoint (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub voltage_set: Option<f64>,
    /// Current limit setpoint (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub current_set: Option<f64>,
    /// Hardware maximum voltage (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub voltage_max: Option<f64>,
    /// Hardware maximum current (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub current_max: Option<f64>,
    /// Over-current protection limit (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub ocp_limit: Option<f64>,
    /// Whether OCP has tripped.
    #[serde(default, deserialize_with = "lenient::opt_bool")]
    pub ocp_tripped: Option<bool>,
    /// Over-voltage protection limit (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub ovp_limit: Option<f64>,
    /// Whether OVP has tripped.
    #[serde(default, deserialize_with = "lenient::opt_bool")]
    pub ovp_tripped: Option<bool>,
}

/// Structured battery-simulator state from the `state` action on
/// `POST /battery/command`. Fields are `None` when the individual read
/// failed.
#[derive(Debug, Clone, Deserialize)]
pub struct BatteryState {
    /// Net name echo.
    #[serde(default)]
    pub netname: Option<String>,
    /// Instrument channel driving this net.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub channel: Option<i64>,
    /// Transport-level error when the whole state gather failed.
    #[serde(default)]
    pub error: Option<String>,
    /// Simulated terminal voltage (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub terminal_voltage: Option<f64>,
    /// Measured current (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub current: Option<f64>,
    /// Equivalent series resistance (ohm).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub esr: Option<f64>,
    /// State of charge (%).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub soc: Option<f64>,
    /// Open-circuit voltage (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub voc: Option<f64>,
    /// Whether the simulator output is enabled.
    #[serde(default, deserialize_with = "lenient::opt_bool")]
    pub enabled: Option<bool>,
    /// Simulation mode (`"static"` / `"dynamic"`).
    #[serde(default)]
    pub mode: Option<String>,
    /// Battery model / part number.
    #[serde(default)]
    pub model: Option<String>,
    /// Battery capacity (Ah).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub capacity: Option<f64>,
    /// Current limit (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub current_limit: Option<f64>,
    /// Over-current protection limit (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub ocp_limit: Option<f64>,
    /// Over-voltage protection limit (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub ovp_limit: Option<f64>,
    /// Voltage considered "full" (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub volt_full: Option<f64>,
    /// Voltage considered "empty" (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub volt_empty: Option<f64>,
    /// Whether OCP has tripped.
    #[serde(default, deserialize_with = "lenient::opt_bool")]
    pub ocp_tripped: Option<bool>,
    /// Whether OVP has tripped.
    #[serde(default, deserialize_with = "lenient::opt_bool")]
    pub ovp_tripped: Option<bool>,
}

/// Structured e-load state from the `state` action on `POST /net/command`.
#[derive(Debug, Clone, Deserialize)]
pub struct EloadState {
    /// Active mode (`"cc"`, `"cv"`, `"cr"`, `"cp"`).
    #[serde(default)]
    pub mode: Option<String>,
    /// Whether the load input is enabled.
    #[serde(default, deserialize_with = "lenient::opt_bool")]
    pub input_enabled: Option<bool>,
    /// Measured voltage (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub measured_voltage: Option<f64>,
    /// Measured current (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub measured_current: Option<f64>,
    /// Measured power (W).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub measured_power: Option<f64>,
    /// Any extra driver-specific fields.
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// Combined current/voltage/power reading from a watt-meter `all` action.
#[derive(Debug, Clone, Deserialize)]
pub struct WattReading {
    /// Mean current over the window (A).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub current: Option<f64>,
    /// Mean voltage over the window (V).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub voltage: Option<f64>,
    /// Mean power over the window (W).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub power: Option<f64>,
    /// Measurement window (s).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub duration_s: Option<f64>,
}

/// Integrated energy reading from an energy-analyzer `read_energy` action.
#[derive(Debug, Clone, Deserialize)]
pub struct EnergyReading {
    /// Integrated energy (J).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub energy_j: Option<f64>,
    /// Integrated charge (C).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub charge_c: Option<f64>,
    /// Actual integration window (s).
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub duration_s: Option<f64>,
    /// Any extra analyzer-specific fields.
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// Statistical summary of one signal inside [`EnergyStats`].
#[derive(Debug, Clone, Default, Deserialize)]
pub struct StatSummary {
    /// Mean value.
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub mean: Option<f64>,
    /// Minimum value.
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub min: Option<f64>,
    /// Maximum value.
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub max: Option<f64>,
    /// Standard deviation.
    #[serde(default, deserialize_with = "lenient::opt_f64")]
    pub std: Option<f64>,
    /// Any extra analyzer-specific fields.
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// Statistics from an energy-analyzer `read_stats` action.
#[derive(Debug, Clone, Deserialize)]
pub struct EnergyStats {
    /// Current statistics (A).
    #[serde(default)]
    pub current: Option<StatSummary>,
    /// Voltage statistics (V).
    #[serde(default)]
    pub voltage: Option<StatSummary>,
    /// Power statistics (W).
    #[serde(default)]
    pub power: Option<StatSummary>,
    /// Any extra analyzer-specific fields.
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

// ---------------------------------------------------------------------------
// Arm / webcam / router / BLE / WiFi / BluFi response types
// ---------------------------------------------------------------------------

/// Cartesian position of a robot arm's end effector (mm), from the `value:
/// [x, y, z]` list the arm actions return.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ArmPosition {
    /// X coordinate (mm).
    pub x: f64,
    /// Y coordinate (mm).
    pub y: f64,
    /// Z coordinate (mm).
    pub z: f64,
}

/// Parse an arm response's `value: [x, y, z]` into an [`ArmPosition`].
pub fn value_arm_position(resp: CommandResponse) -> Result<ArmPosition> {
    let arr = resp
        .value
        .as_ref()
        .and_then(Value::as_array)
        .ok_or_else(|| Error::Decode(format!("expected [x, y, z] 'value', got {:?}", resp.value)))?;
    match arr.as_slice() {
        [x, y, z] => match (as_f64(x), as_f64(y), as_f64(z)) {
            (Some(x), Some(y), Some(z)) => Ok(ArmPosition { x, y, z }),
            _ => Err(Error::Decode(format!("non-numeric arm position: {arr:?}"))),
        },
        _ => Err(Error::Decode(format!(
            "expected 3-element arm position, got {} elements",
            arr.len()
        ))),
    }
}

/// Result of starting a webcam stream (`start` action).
#[derive(Debug, Clone, Deserialize)]
pub struct WebcamStream {
    /// MJPEG stream URL viewers should open.
    #[serde(default)]
    pub url: String,
    /// TCP port the stream is served on.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub port: Option<i64>,
    /// `true` when a stream was already up and the box reused it.
    #[serde(default)]
    pub already_running: bool,
}

/// Current state of a webcam stream (`status`/`url` actions).
#[derive(Debug, Clone, Deserialize)]
pub struct WebcamStatus {
    /// Whether a stream is currently running for this net.
    #[serde(default)]
    pub running: bool,
    /// Stream URL, when running.
    #[serde(default)]
    pub url: Option<String>,
    /// Stream TCP port, when running.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub port: Option<i64>,
    /// Backing video device (e.g. `/dev/video0`), when running.
    #[serde(default)]
    pub video_device: Option<String>,
}

/// System information for a router net (`system_info` action).
#[derive(Debug, Clone, Deserialize)]
pub struct RouterSystemInfo {
    /// Router identity name.
    #[serde(default)]
    pub name: Option<String>,
    /// RouterOS version.
    #[serde(default)]
    pub version: Option<String>,
    /// Board model, e.g. `"hAP ac^2"`.
    #[serde(default)]
    pub board: Option<String>,
    /// CPU architecture.
    #[serde(default)]
    pub architecture: Option<String>,
    /// Uptime string, e.g. `"1w2d3h"`.
    #[serde(default)]
    pub uptime: Option<String>,
    /// CPU load (%).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub cpu_load: Option<i64>,
    /// Free RAM (bytes).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub free_memory: Option<i64>,
    /// Total RAM (bytes).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub total_memory: Option<i64>,
    /// Free storage (bytes).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub free_hdd_space: Option<i64>,
    /// Any extra fields.
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// One device found by a BLE or BluFi scan.
#[derive(Debug, Clone, Deserialize)]
pub struct BleDevice {
    /// Advertised name (falls back to the address when unnamed).
    #[serde(default)]
    pub name: String,
    /// BLE MAC address, `XX:XX:XX:XX:XX:XX`.
    #[serde(default)]
    pub address: String,
    /// Signal strength (dBm).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub rssi: Option<i64>,
    /// Advertised service UUIDs.
    #[serde(default)]
    pub uuids: Vec<String>,
}

/// One GATT characteristic inside a [`BleService`].
#[derive(Debug, Clone, Deserialize)]
pub struct BleCharacteristic {
    /// Characteristic UUID.
    #[serde(default)]
    pub uuid: String,
    /// Human-readable description, when known.
    #[serde(default)]
    pub description: Option<String>,
    /// Supported operations, e.g. `["read", "notify"]`.
    #[serde(default)]
    pub properties: Vec<String>,
}

/// One GATT service enumerated from a connected BLE device.
#[derive(Debug, Clone, Deserialize)]
pub struct BleService {
    /// Service UUID.
    #[serde(default)]
    pub uuid: String,
    /// Human-readable description, when known.
    #[serde(default)]
    pub description: Option<String>,
    /// Characteristics under this service.
    #[serde(default)]
    pub characteristics: Vec<BleCharacteristic>,
}

/// Result of a BLE `info`/`connect`: the device's GATT database.
#[derive(Debug, Clone, Deserialize)]
pub struct BleDeviceInfo {
    /// Device address.
    #[serde(default)]
    pub address: String,
    /// Whether the box reached the device.
    #[serde(default)]
    pub connected: bool,
    /// Enumerated GATT services.
    #[serde(default)]
    pub services: Vec<BleService>,
}

/// Status of one wireless interface on the box (`wifi status`).
#[derive(Debug, Clone, Deserialize)]
pub struct WifiInterface {
    /// Interface name, e.g. `"wlan0"`.
    #[serde(default)]
    pub interface: String,
    /// Connected SSID, or a placeholder like `"Not Connected"`.
    #[serde(default)]
    pub ssid: String,
    /// Connection state, e.g. `"Connected"` / `"Disconnected"`.
    #[serde(default)]
    pub state: String,
}

/// One access point found by a box-side WiFi scan.
#[derive(Debug, Clone, Deserialize)]
pub struct WifiAccessPoint {
    /// Network SSID (`"Hidden"` for hidden networks).
    #[serde(default)]
    pub ssid: Option<String>,
    /// BSSID / AP MAC address.
    #[serde(default)]
    pub address: Option<String>,
    /// Signal strength (approximate %, 0-100).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub strength: Option<i64>,
    /// `"Open"` or `"Secured"`.
    #[serde(default)]
    pub security: Option<String>,
}

/// Result of connecting the box to a WiFi network.
#[derive(Debug, Clone, Deserialize)]
pub struct WifiConnection {
    /// SSID the box joined.
    #[serde(default)]
    pub ssid: String,
    /// Whether the connection succeeded (always true in a success envelope).
    #[serde(default)]
    pub connected: bool,
    /// Interface used, e.g. `"wlan0"`.
    #[serde(default)]
    pub interface: Option<String>,
    /// Connection method, e.g. `"nmcli"` or `"wpa_supplicant"`.
    #[serde(default)]
    pub method: Option<String>,
}

/// WiFi state reported by a BluFi target device (`status`, and embedded in
/// `connect`). Codes follow the ESP32 BluFi protocol.
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiStatus {
    /// Target device name echo.
    #[serde(default)]
    pub device_name: Option<String>,
    /// Operation mode code (0 NULL, 1 STA, 2 SoftAP, 3 STA+SoftAP).
    #[serde(default, rename = "opMode", deserialize_with = "lenient::opt_i64")]
    pub op_mode: Option<i64>,
    /// Human-readable operation mode.
    #[serde(default, rename = "opModeName")]
    pub op_mode_name: Option<String>,
    /// Station connection code (0 connected, 1 failed, 2 connecting, 3 no IP).
    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
    pub sta_conn: Option<i64>,
    /// Human-readable station connection state.
    #[serde(default, rename = "staConnName")]
    pub sta_conn_name: Option<String>,
    /// SoftAP connection count/state code.
    #[serde(default, rename = "softAPConn", deserialize_with = "lenient::opt_i64")]
    pub soft_ap_conn: Option<i64>,
}

/// Result of a BluFi `connect`: firmware version plus WiFi state.
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiDeviceInfo {
    /// Target firmware version, when the device reports one.
    #[serde(default)]
    pub version: Option<String>,
    /// WiFi state of the target.
    #[serde(flatten)]
    pub status: BlufiStatus,
}

/// Result of a BluFi `provision`.
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiProvisionResult {
    /// Target device name echo.
    #[serde(default)]
    pub device_name: Option<String>,
    /// SSID the target was provisioned onto.
    #[serde(default)]
    pub ssid: String,
    /// Station connection code after provisioning (0 = connected).
    #[serde(default, rename = "staConn", deserialize_with = "lenient::opt_i64")]
    pub sta_conn: Option<i64>,
    /// Human-readable station connection state.
    #[serde(default, rename = "staConnName")]
    pub sta_conn_name: Option<String>,
}

/// One network seen by a BluFi target's own WiFi scan (`wifi_scan`).
#[derive(Debug, Clone, Deserialize)]
pub struct BlufiNetwork {
    /// Network SSID.
    #[serde(default)]
    pub ssid: String,
    /// Signal strength at the target (dBm).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub rssi: Option<i64>,
}

/// Extract a named list field out of the `value` object (e.g. `devices`
/// from a BLE scan, `access_points` from a WiFi scan).
pub(crate) fn value_list_field<T: DeserializeOwned>(
    resp: CommandResponse,
    field: &str,
) -> Result<Vec<T>> {
    let list = resp
        .value
        .as_ref()
        .and_then(|v| v.get(field))
        .cloned()
        .ok_or_else(|| Error::Decode(format!("response 'value' has no '{field}' list")))?;
    serde_json::from_value(list)
        .map_err(|e| Error::Decode(format!("invalid '{field}' shape: {e}")))
}

// ---------------------------------------------------------------------------
// USB bus enumeration (GET /usb/devices)
// ---------------------------------------------------------------------------

/// Optional filters for [`crate::LagerBox::usb_devices_matching`], applied
/// box-side. `vid`/`pid` are hex strings (with or without `0x`); `serial`
/// is an exact iSerial match. An empty filter returns every device.
#[derive(Debug, Clone, Default)]
pub struct UsbDeviceFilter {
    /// USB vendor id, hex (e.g. `"0483"`).
    pub vid: Option<String>,
    /// USB product id, hex (e.g. `"df11"`).
    pub pid: Option<String>,
    /// Exact iSerial string.
    pub serial: Option<String>,
}

/// One USB device on the box's bus, read from sysfs by `GET /usb/devices`.
/// String fields are `None` when the device does not expose the descriptor
/// (e.g. `serial` on devices with no iSerial).
#[derive(Debug, Clone, Deserialize)]
pub struct UsbDeviceInfo {
    /// sysfs entry name, e.g. `"1-1.4"`.
    #[serde(default)]
    pub sysfs_name: String,
    /// Vendor id, lowercase hex (e.g. `"0483"`).
    #[serde(default)]
    pub vid: Option<String>,
    /// Product id, lowercase hex (e.g. `"df11"`).
    #[serde(default)]
    pub pid: Option<String>,
    /// iSerial descriptor.
    #[serde(default)]
    pub serial: Option<String>,
    /// Product descriptor string.
    #[serde(default)]
    pub product: Option<String>,
    /// Manufacturer descriptor string.
    #[serde(default)]
    pub manufacturer: Option<String>,
    /// Bus number.
    #[serde(default)]
    pub busnum: Option<String>,
    /// Device number on the bus (changes on re-enumeration).
    #[serde(default)]
    pub devnum: Option<String>,
    /// Hub port path, e.g. `"1.4"`.
    #[serde(default)]
    pub devpath: Option<String>,
    /// bDeviceClass, hex.
    #[serde(default)]
    pub device_class: Option<String>,
    /// Negotiated speed in Mbps (`"1.5"`, `"12"`, `"480"`, ...).
    #[serde(default)]
    pub speed: Option<String>,
}

/// Percent-encode one query-string value (RFC 3986 unreserved set).
fn encode_query_value(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(b as char)
            }
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

/// Build a `GET /usb/devices` request with optional box-side filters.
pub fn usb_devices(filter: &UsbDeviceFilter) -> HttpRequest {
    let mut query = Vec::new();
    for (key, value) in [
        ("vid", &filter.vid),
        ("pid", &filter.pid),
        ("serial", &filter.serial),
    ] {
        if let Some(value) = value {
            query.push(format!("{key}={}", encode_query_value(value)));
        }
    }
    let path = if query.is_empty() {
        "/usb/devices".to_string()
    } else {
        format!("/usb/devices?{}", query.join("&"))
    };
    HttpRequest {
        method: Method::Get,
        path,
        body: None,
        timeout: Timeout::Default,
    }
}

/// Parse a `GET /usb/devices` response into device records.
pub fn parse_usb_devices(status: u16, body: Value) -> Result<Vec<UsbDeviceInfo>> {
    if status == 404 {
        return Err(usb_devices_unsupported());
    }
    let resp = parse_command(status, body)?;
    let devices = resp
        .extra
        .get("devices")
        .cloned()
        .ok_or_else(|| Error::Decode("response has no 'devices' list".to_string()))?;
    serde_json::from_value(devices)
        .map_err(|e| Error::Decode(format!("invalid 'devices' shape: {e}")))
}

pub(crate) fn usb_devices_unsupported() -> Error {
    Error::UnsupportedByBox {
        message: "this box does not serve GET /usb/devices (requires box software >= 0.33.0)"
            .to_string(),
    }
}

/// Map a route-missing 404 (Flask's non-JSON default page) to
/// [`Error::UnsupportedByBox`] with an endpoint-specific message. Real box
/// errors carry JSON bodies and pass through untouched.
pub(crate) fn map_route_missing(err: Error, unsupported: fn() -> Error) -> Error {
    match err {
        Error::Box {
            status: 404,
            ref message,
        } if message.contains("non-JSON") => unsupported(),
        other => other,
    }
}

// ---------------------------------------------------------------------------
// DFU (POST /usb/dfu)
// ---------------------------------------------------------------------------

/// One device reported by `dfu-util -l` (via [`crate::nets::dfu::Dfu::list`]).
#[derive(Debug, Clone, Deserialize)]
pub struct DfuDevice {
    /// `"DFU"` (in DFU mode) or `"Runtime"` (app running, DFU-capable).
    #[serde(default)]
    pub mode: String,
    /// Vendor id, lowercase hex.
    #[serde(default)]
    pub vid: String,
    /// Product id, lowercase hex.
    #[serde(default)]
    pub pid: String,
    /// Device number on the bus.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub devnum: Option<i64>,
    /// Configuration index.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub cfg: Option<i64>,
    /// Interface index.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub intf: Option<i64>,
    /// Alternate setting index.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub alt: Option<i64>,
    /// Interface name (e.g. `"@Internal Flash /0x08000000/..."`).
    #[serde(default)]
    pub name: Option<String>,
    /// Device serial.
    #[serde(default)]
    pub serial: Option<String>,
    /// Hub port path, e.g. `"1-1.4"`.
    #[serde(default)]
    pub path: Option<String>,
}

/// Captured output of one box-side `dfu-util` run.
#[derive(Debug, Clone, Deserialize)]
pub struct DfuOutput {
    /// dfu-util exit code (0 on the success envelope).
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub exit_code: Option<i64>,
    /// Captured stdout.
    #[serde(default)]
    pub stdout: String,
    /// Captured stderr (dfu-util writes progress here).
    #[serde(default)]
    pub stderr: String,
}

pub(crate) fn dfu_unsupported() -> Error {
    Error::UnsupportedByBox {
        message: "this box does not serve POST /usb/dfu (requires box software >= 0.33.0)"
            .to_string(),
    }
}

// ---------------------------------------------------------------------------
// Box lock / reservation (GET/POST /lock, /lock/heartbeat, /unlock)
// ---------------------------------------------------------------------------

/// Box lock state, as returned by the `/lock` family of endpoints (the same
/// ones `lager boxes lock` uses). `locked: false` with everything else
/// `None` means the box is free.
#[derive(Debug, Clone, Deserialize)]
pub struct BoxLock {
    /// Whether the box is currently locked.
    #[serde(default)]
    pub locked: bool,
    /// Lock holder.
    #[serde(default)]
    pub user: Option<String>,
    /// Holder classification (`"user"`, `"ci"`, `"ephemeral"`, or another
    /// service's reservation origin).
    #[serde(default)]
    pub holder_type: Option<String>,
    /// When the lock was acquired (ISO 8601 UTC).
    #[serde(default)]
    pub locked_at: Option<String>,
    /// Last heartbeat (ISO 8601 UTC).
    #[serde(default)]
    pub last_heartbeat: Option<String>,
    /// Lock TTL in seconds; `None` means the lock never auto-expires.
    #[serde(default, deserialize_with = "lenient::opt_i64")]
    pub ttl_seconds: Option<i64>,
    /// On acquire: the holder before this call (`None` when the box was
    /// free). Distinguishes "just acquired" from "already held it".
    #[serde(default)]
    pub previous_user: Option<String>,
}

/// Build a `GET /lock` request.
pub fn lock_status() -> HttpRequest {
    get("/lock")
}

/// Build a `POST /lock` request. `ttl_seconds: None` sends JSON `null`
/// (an eternal lock, matching `lager boxes lock`).
pub fn lock_acquire(user: &str, holder_type: &str, ttl_seconds: Option<u64>) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: "/lock".to_string(),
        body: Some(json!({
            "user": user,
            "holder_type": holder_type,
            "ttl_seconds": ttl_seconds,
        })),
        timeout: Timeout::Default,
    }
}

/// Build a `POST /lock/heartbeat` request.
pub fn lock_heartbeat(user: &str) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: "/lock/heartbeat".to_string(),
        body: Some(json!({ "user": user })),
        timeout: Timeout::Default,
    }
}

/// Build a `POST /unlock` request.
pub fn unlock(user: &str, force: bool) -> HttpRequest {
    HttpRequest {
        method: Method::Post,
        path: "/unlock".to_string(),
        body: Some(json!({ "user": user, "force": force })),
        timeout: Timeout::Default,
    }
}

/// Parse a `/lock` family response. These endpoints return the raw lock
/// dict on 200 and `{"error": ..., "lock": {...}}` on contention
/// (409 acquire, 403 heartbeat/unlock, 404 heartbeat on an unlocked box).
pub fn parse_lock(status: u16, body: Value) -> Result<BoxLock> {
    if status == 200 {
        return serde_json::from_value(body)
            .map_err(|e| Error::Decode(format!("invalid lock state: {e}")));
    }
    let message = body
        .get("error")
        .and_then(Value::as_str)
        .unwrap_or("lock request failed")
        .to_string();
    Err(Error::Box { status, message })
}

pub(crate) fn lock_unsupported() -> Error {
    Error::UnsupportedByBox {
        message: "this box does not serve the /lock endpoints on port 9000; \
                  update the box software"
            .to_string(),
    }
}

// ---------------------------------------------------------------------------
// Per-net safety limits (PUT /nets/<name>/safety-limits, box >= 0.35.0)
// ---------------------------------------------------------------------------

/// Voltage/current ceilings and the destructive-op switch enforced by the
/// box on a saved net.
///
/// A `PUT` **replaces** the net's whole limits record with the fields set
/// here: a `None` field is *removed* from the net, not preserved. Read the
/// current limits first (they ride along on `/nets/list` records as
/// [`NetRecord::safety_limits`]) if you mean to change one ceiling and keep
/// the others.
///
/// There is deliberately no `max_power`: one setter call establishes either
/// voltage or current, never both, so the box cannot evaluate a power
/// ceiling honestly and refuses the key rather than storing something
/// nothing enforces.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct SafetyLimits {
    /// Voltage ceiling in volts. Must be positive; also caps inline `ovp=`
    /// trip settings, which would otherwise lift the instrument's own guard
    /// above the net ceiling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_voltage: Option<f64>,
    /// Current ceiling in amps. Must be positive; also caps inline `ocp=`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_current: Option<f64>,
    /// `Some(false)` makes the box refuse erase and flash on this net.
    /// Absent means allowed (the box treats a missing key as unrestricted).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub allow_destructive: Option<bool>,
}

impl SafetyLimits {
    /// True when no field is set — as a PUT body this clears the net's
    /// limits, returning it to unrestricted.
    pub fn is_empty(&self) -> bool {
        self.max_voltage.is_none() && self.max_current.is_none() && self.allow_destructive.is_none()
    }
}

/// Build a `PUT /nets/<name>/safety-limits` request. An empty `limits`
/// serialises to `{}`, the box's documented "back to unrestricted" body.
pub fn safety_limits_set(name: &str, limits: &SafetyLimits) -> HttpRequest {
    HttpRequest {
        method: Method::Put,
        path: format!("/nets/{name}/safety-limits"),
        body: Some(serde_json::to_value(limits).unwrap_or_else(|_| json!({}))),
        timeout: Timeout::Default,
    }
}

/// Parse a `PUT /nets/<name>/safety-limits` response into the applied
/// limits (`None` when the body cleared them). Validation refusals (unknown
/// key, `max_power`, non-positive ceiling) and a missing net come back as
/// [`Error::Box`] carrying the box's message.
pub fn parse_safety_limits(status: u16, body: Value) -> Result<Option<SafetyLimits>> {
    if status == 200 {
        return match body.get("safety_limits") {
            None | Some(Value::Null) => Ok(None),
            Some(limits) => serde_json::from_value(limits.clone())
                .map(Some)
                .map_err(|e| Error::Decode(format!("invalid safety_limits shape: {e}"))),
        };
    }
    let message = body
        .get("error")
        .and_then(Value::as_str)
        .unwrap_or("safety-limits request failed")
        .to_string();
    Err(Error::Box { status, message })
}

pub(crate) fn safety_limits_unsupported() -> Error {
    Error::UnsupportedByBox {
        message: "this box does not serve PUT /nets/<name>/safety-limits \
                  (requires box software >= 0.35.0)"
            .to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_success_envelope() {
        let resp = parse_command(
            200,
            json!({"success": true, "action": "read", "message": "1.5 V", "value": 1.5}),
        )
        .unwrap();
        assert_eq!(resp.value.as_ref().and_then(as_f64), Some(1.5));
    }

    #[test]
    fn parse_failure_with_http_200_is_box_error() {
        // Cross-role conflicts are reported as success=false with HTTP 200.
        let err = parse_command(200, json!({"success": false, "error": "conflict"})).unwrap_err();
        match err {
            Error::Box { status, message } => {
                assert_eq!(status, 200);
                assert_eq!(message, "conflict");
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn parse_501_is_unsupported() {
        let err = parse_command(
            501,
            json!({"success": false, "error": "Role 'gpio' is not supported"}),
        )
        .unwrap_err();
        assert!(matches!(err, Error::UnsupportedByBox { .. }));
    }

    #[test]
    fn base_url_normalization() {
        assert_eq!(base_url("192.168.1.42").unwrap(), "http://192.168.1.42:9000");
        assert_eq!(base_url("mybox.local/").unwrap(), "http://mybox.local:9000");
        assert_eq!(base_url("192.168.1.42:8080").unwrap(), "http://192.168.1.42:8080");
        assert_eq!(base_url("http://box:9000").unwrap(), "http://box:9000");
        assert!(base_url("").is_err());
        assert!(base_url("http://").is_err());
    }

    #[test]
    fn lenient_numbers() {
        assert_eq!(as_f64(&json!("3.3")), Some(3.3));
        assert_eq!(as_f64(&json!(3.3)), Some(3.3));
        assert_eq!(as_bool(&json!("ON")), Some(true));
        assert_eq!(as_bool(&json!(0)), Some(false));
        assert_eq!(as_i64(&json!("42")), Some(42));
    }

    #[test]
    fn supply_state_tolerates_nulls_and_strings() {
        let state: SupplyState = serde_json::from_value(json!({
            "netname": "supply1", "channel": "1", "error": null,
            "voltage": "3.3", "current": null, "enabled": 1,
        }))
        .unwrap();
        assert_eq!(state.channel, Some(1));
        assert_eq!(state.voltage, Some(3.3));
        assert_eq!(state.current, None);
        assert_eq!(state.enabled, Some(true));
    }

    #[test]
    fn safety_limits_body_omits_unset_fields() {
        // The box refuses unknown keys and treats an explicit null as "do
        // not store this key", so unset fields must vanish from the body
        // entirely rather than ride along as nulls.
        let req = safety_limits_set(
            "supply1",
            &SafetyLimits {
                max_voltage: Some(5.0),
                ..Default::default()
            },
        );
        assert_eq!(req.method, Method::Put);
        assert_eq!(req.path, "/nets/supply1/safety-limits");
        assert_eq!(req.body, Some(json!({ "max_voltage": 5.0 })));

        let clear = safety_limits_set("supply1", &SafetyLimits::default());
        assert_eq!(clear.body, Some(json!({})));
        assert!(SafetyLimits::default().is_empty());
    }

    #[test]
    fn parse_safety_limits_roundtrip_and_clear() {
        let applied = parse_safety_limits(
            200,
            json!({"ok": true, "name": "supply1",
                   "safety_limits": {"max_voltage": 5.0, "allow_destructive": false}}),
        )
        .unwrap()
        .unwrap();
        assert_eq!(applied.max_voltage, Some(5.0));
        assert_eq!(applied.max_current, None);
        assert_eq!(applied.allow_destructive, Some(false));

        // A clearing PUT echoes `safety_limits: null`.
        let cleared =
            parse_safety_limits(200, json!({"ok": true, "safety_limits": null})).unwrap();
        assert!(cleared.is_none());
    }

    #[test]
    fn parse_safety_limits_surfaces_box_refusals() {
        let err = parse_safety_limits(
            400,
            json!({"error": "max_power is not supported: ..."}),
        )
        .unwrap_err();
        match err {
            Error::Box { status, message } => {
                assert_eq!(status, 400);
                assert!(message.contains("max_power"));
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn net_record_carries_safety_limits() {
        let rec: NetRecord = serde_json::from_value(json!({
            "name": "supply1", "role": "power-supply",
            "safety_limits": {"max_voltage": 12.0, "max_current": 2.5},
        }))
        .unwrap();
        let limits = rec.safety_limits.unwrap();
        assert_eq!(limits.max_voltage, Some(12.0));
        assert_eq!(limits.max_current, Some(2.5));
        assert_eq!(limits.allow_destructive, None);

        // Absent key means unrestricted, not an error.
        let rec: NetRecord =
            serde_json::from_value(json!({"name": "gpio1", "role": "gpio"})).unwrap();
        assert!(rec.safety_limits.is_none());
    }

    #[test]
    fn capabilities_parse_safety_limits_flag() {
        let caps: BoxCapabilities =
            serde_json::from_value(json!({"netCommand": true, "safetyLimits": true})).unwrap();
        assert!(caps.safety_limits);
        // A box predating the flag omits the key, which reads as false.
        let caps: BoxCapabilities = serde_json::from_value(json!({"netCommand": true})).unwrap();
        assert!(!caps.safety_limits);
    }
}