blues_notecard/
card.rs

1//! https://dev.blues.io/reference/notecard-api/card-requests/
2
3#[allow(unused_imports)]
4use defmt::{debug, error, info, trace, warn};
5use embedded_hal::blocking::delay::DelayMs;
6use embedded_hal::blocking::i2c::{Read, SevenBitAddress, Write};
7use serde::{Deserialize, Serialize};
8
9use super::{str_string, FutureResponse, NoteError, Notecard};
10
11pub struct Card<'a, IOM: Write<SevenBitAddress> + Read<SevenBitAddress>, const BS: usize> {
12    note: &'a mut Notecard<IOM, BS>,
13}
14
15/// https://dev.blues.io/api-reference/notecard-api/card-requests/latest/#card-transport
16pub enum Transport {
17    Reset,
18    WifiCell,
19    Wifi,
20    Cell,
21    NTN,
22    WifiNTN,
23    CellNTN,
24    WifiCellNTN,
25}
26
27impl Transport {
28    pub fn str(&self) -> &'static str {
29        use Transport::*;
30
31        match self {
32            Reset => "-",
33            WifiCell => "wifi-cell",
34            Wifi => "wifi",
35            Cell => "cell",
36            NTN => "ntn",
37            WifiNTN => "wifi-ntn",
38            CellNTN => "cell-ntn",
39            WifiCellNTN => "wifi-cell-ntn",
40        }
41    }
42}
43
44impl<'a, IOM: Write<SevenBitAddress> + Read<SevenBitAddress>, const BS: usize> Card<'a, IOM, BS> {
45    pub fn from(note: &mut Notecard<IOM, BS>) -> Card<'_, IOM, BS> {
46        Card { note }
47    }
48
49    /// Retrieves current date and time information. Upon power-up, the Notecard must complete a
50    /// sync to Notehub in order to obtain time and location data. Before the time is obtained,
51    /// this request will return `{"zone":"UTC,Unknown"}`.
52    pub fn time(
53        self,
54        delay: &mut impl DelayMs<u16>,
55    ) -> Result<FutureResponse<'a, res::Time, IOM, BS>, NoteError> {
56        self.note.request_raw(delay, b"{\"req\":\"card.time\"}\n")?;
57        Ok(FutureResponse::from(self.note))
58    }
59
60    /// Returns general information about the Notecard's operating status.
61    pub fn status(
62        self,
63        delay: &mut impl DelayMs<u16>,
64    ) -> Result<FutureResponse<'a, res::Status, IOM, BS>, NoteError> {
65        self.note
66            .request_raw(delay, b"{\"req\":\"card.status\"}\n")?;
67        Ok(FutureResponse::from(self.note))
68    }
69
70    /// Performs a firmware restart of the Notecard.
71    pub fn restart(
72        self,
73        delay: &mut impl DelayMs<u16>,
74    ) -> Result<FutureResponse<'a, res::Empty, IOM, BS>, NoteError> {
75        self.note
76            .request_raw(delay, b"{\"req\":\"card.restart\"}\n")?;
77        Ok(FutureResponse::from(self.note))
78    }
79
80    /// Retrieves the current location of the Notecard.
81    pub fn location(
82        self,
83        delay: &mut impl DelayMs<u16>,
84    ) -> Result<FutureResponse<'a, res::Location, IOM, BS>, NoteError> {
85        self.note
86            .request_raw(delay, b"{\"req\":\"card.location\"}\n")?;
87        Ok(FutureResponse::from(self.note))
88    }
89
90    /// Sets location-related configuration settings. Retrieves the current location mode when passed with no argument.
91    pub fn location_mode(
92        self,
93        delay: &mut impl DelayMs<u16>,
94        mode: Option<&str>,
95        seconds: Option<u32>,
96        vseconds: Option<&str>,
97        delete: Option<bool>,
98        max: Option<u32>,
99        lat: Option<f32>,
100        lon: Option<f32>,
101        minutes: Option<u32>,
102    ) -> Result<FutureResponse<'a, res::LocationMode, IOM, BS>, NoteError> {
103        self.note.request(
104            delay,
105            req::LocationMode {
106                req: "card.location.mode",
107                mode: str_string(mode)?,
108                seconds,
109                vseconds: str_string(vseconds)?,
110                delete,
111                max,
112                lat,
113                lon,
114                minutes,
115            },
116        )?;
117        Ok(FutureResponse::from(self.note))
118    }
119
120    /// Store location data in a Notefile at the `periodic` interval, or using specified `heartbeat`.
121    /// Only available when `card.location.mode` has been set to `periodic`.
122    pub fn location_track(
123        self,
124        delay: &mut impl DelayMs<u16>,
125        start: bool,
126        heartbeat: bool,
127        sync: bool,
128        hours: Option<i32>,
129        file: Option<&str>,
130    ) -> Result<FutureResponse<'a, res::LocationTrack, IOM, BS>, NoteError> {
131        self.note.request(
132            delay,
133            req::LocationTrack {
134                req: "card.location.track",
135                start: start.then(|| true),
136                stop: (!start).then(|| true),
137                heartbeat: heartbeat.then(|| true),
138                sync: sync.then(|| true),
139                hours,
140                file: str_string(file)?,
141            },
142        )?;
143
144        Ok(FutureResponse::from(self.note))
145    }
146
147    pub fn wireless(
148        self,
149        delay: &mut impl DelayMs<u16>,
150        mode: Option<&str>,
151        apn: Option<&str>,
152        method: Option<&str>,
153        hours: Option<u32>,
154    ) -> Result<FutureResponse<'a, res::Wireless, IOM, BS>, NoteError> {
155        self.note.request(
156            delay,
157            req::Wireless {
158                req: "card.wireless",
159                mode: str_string(mode)?,
160                method: str_string(method)?,
161                apn: str_string(apn)?,
162                hours,
163            },
164        )?;
165
166        Ok(FutureResponse::from(self.note))
167    }
168
169    /// Returns firmware version information for the Notecard.
170    pub fn version(
171        self,
172        delay: &mut impl DelayMs<u16>,
173    ) -> Result<FutureResponse<'a, res::Version, IOM, BS>, NoteError> {
174        self.note
175            .request_raw(delay, b"{\"req\":\"card.version\"}\n")?;
176        Ok(FutureResponse::from(self.note))
177    }
178
179    /// Configure Notecard Outboard Firmware Update feature
180    /// Added in v3.5.1 Notecard Firmware.
181    pub fn dfu(
182        self,
183        delay: &mut impl DelayMs<u16>,
184        name: Option<req::DFUName>,
185        on: Option<bool>,
186        stop: Option<bool>,
187    ) -> Result<FutureResponse<'a, res::DFU, IOM, BS>, NoteError> {
188        self.note.request(delay, req::DFU::new(name, on, stop))?;
189        Ok(FutureResponse::from(self.note))
190    }
191
192    pub fn transport(
193        self,
194        delay: &mut impl DelayMs<u16>,
195        method: Transport,
196        allow: Option<bool>,
197        umin: Option<bool>,
198    ) -> Result<FutureResponse<'a, res::Transport, IOM, BS>, NoteError> {
199        self.note.request(
200            delay,
201            req::Transport {
202                req: "card.transport",
203                method: method.str(),
204                allow,
205                umin,
206            },
207        )?;
208        Ok(FutureResponse::from(self.note))
209    }
210}
211
212pub mod req {
213    use super::*;
214
215    #[derive(Deserialize, Serialize, defmt::Format, Default)]
216    pub struct Transport {
217        pub req: &'static str,
218
219        pub method: &'static str,
220
221        #[serde(skip_serializing_if = "Option::is_none")]
222        pub allow: Option<bool>,
223
224        #[serde(skip_serializing_if = "Option::is_none")]
225        pub umin: Option<bool>,
226    }
227
228    #[derive(Deserialize, Serialize, defmt::Format, Default)]
229    pub struct Wireless {
230        pub req: &'static str,
231
232        #[serde(skip_serializing_if = "Option::is_none")]
233        pub mode: Option<heapless::String<20>>,
234
235        #[serde(skip_serializing_if = "Option::is_none")]
236        pub apn: Option<heapless::String<120>>,
237
238        #[serde(skip_serializing_if = "Option::is_none")]
239        pub method: Option<heapless::String<120>>,
240
241        #[serde(skip_serializing_if = "Option::is_none")]
242        pub hours: Option<u32>,
243    }
244
245    #[derive(Deserialize, Serialize, defmt::Format, Default)]
246    pub struct LocationTrack {
247        pub req: &'static str,
248
249        #[serde(skip_serializing_if = "Option::is_none")]
250        pub start: Option<bool>,
251
252        #[serde(skip_serializing_if = "Option::is_none")]
253        pub heartbeat: Option<bool>,
254
255        #[serde(skip_serializing_if = "Option::is_none")]
256        pub sync: Option<bool>,
257
258        #[serde(skip_serializing_if = "Option::is_none")]
259        pub stop: Option<bool>,
260
261        #[serde(skip_serializing_if = "Option::is_none")]
262        pub hours: Option<i32>,
263
264        #[serde(skip_serializing_if = "Option::is_none")]
265        pub file: Option<heapless::String<20>>,
266    }
267
268    #[derive(Deserialize, Serialize, defmt::Format, Default)]
269    pub struct LocationMode {
270        pub req: &'static str,
271
272        #[serde(skip_serializing_if = "Option::is_none")]
273        pub mode: Option<heapless::String<20>>,
274
275        #[serde(skip_serializing_if = "Option::is_none")]
276        pub seconds: Option<u32>,
277
278        #[serde(skip_serializing_if = "Option::is_none")]
279        pub vseconds: Option<heapless::String<20>>,
280
281        #[serde(skip_serializing_if = "Option::is_none")]
282        pub delete: Option<bool>,
283
284        #[serde(skip_serializing_if = "Option::is_none")]
285        pub max: Option<u32>,
286
287        #[serde(skip_serializing_if = "Option::is_none")]
288        pub lat: Option<f32>,
289
290        #[serde(skip_serializing_if = "Option::is_none")]
291        pub lon: Option<f32>,
292
293        #[serde(skip_serializing_if = "Option::is_none")]
294        pub minutes: Option<u32>,
295    }
296
297    #[derive(Deserialize, Serialize, defmt::Format, PartialEq, Debug)]
298    #[serde(rename_all = "lowercase")]
299    pub enum DFUName {
300        Esp32,
301        Stm32,
302        #[serde(rename = "stm32-bi")]
303        Stm32Bi,
304        McuBoot,
305        #[serde(rename = "-")]
306        Reset,
307    }
308
309    #[derive(Deserialize, Serialize, defmt::Format)]
310    pub struct DFU {
311        pub req: &'static str,
312
313        #[serde(skip_serializing_if = "Option::is_none")]
314        pub name: Option<req::DFUName>,
315
316        #[serde(skip_serializing_if = "Option::is_none")]
317        pub on: Option<bool>,
318
319        #[serde(skip_serializing_if = "Option::is_none")]
320        pub off: Option<bool>,
321
322        #[serde(skip_serializing_if = "Option::is_none")]
323        pub stop: Option<bool>,
324
325        #[serde(skip_serializing_if = "Option::is_none")]
326        pub start: Option<bool>,
327    }
328
329    impl DFU {
330        pub fn new(name: Option<req::DFUName>, on: Option<bool>, stop: Option<bool>) -> Self {
331            // The `on`/`off` and `stop`/`start` parameters are exclusive
332            // When on is `true` we set `on` to `Some(True)` and `off` to `None`.
333            // When on is `false` we set `on` to `None` and `off` to `Some(True)`.
334            // This way we are not sending the `on` and `off` parameters together.
335            // Same thing applies to the `stop`/`start` parameter.
336            Self {
337                req: "card.dfu",
338                name,
339                on: on.and_then(|v| if v { Some(true) } else { None }),
340                off: on.and_then(|v| if v { None } else { Some(true) }),
341                stop: stop.and_then(|v| if v { Some(true) } else { None }),
342                start: stop.and_then(|v| if v { None } else { Some(true) }),
343            }
344        }
345    }
346}
347
348pub mod res {
349    use super::*;
350
351    #[derive(Deserialize, defmt::Format)]
352    pub struct Empty {}
353
354    #[derive(Deserialize, defmt::Format)]
355    pub struct LocationTrack {
356        pub start: Option<bool>,
357        pub stop: Option<bool>,
358        pub heartbeat: Option<bool>,
359        pub seconds: Option<u32>,
360        pub hours: Option<i32>,
361        pub file: Option<heapless::String<20>>,
362    }
363
364    #[derive(Deserialize, defmt::Format)]
365    pub struct LocationMode {
366        pub mode: heapless::String<60>,
367        pub seconds: Option<u32>,
368        pub vseconds: Option<heapless::String<40>>,
369        pub max: Option<u32>,
370        pub lat: Option<f64>,
371        pub lon: Option<f64>,
372        pub minutes: Option<u32>,
373    }
374
375    #[derive(Deserialize, defmt::Format)]
376    pub struct Location {
377        pub status: heapless::String<120>,
378        pub mode: heapless::String<120>,
379        pub lat: Option<f64>,
380        pub lon: Option<f64>,
381        pub time: Option<u32>,
382        pub max: Option<u32>,
383    }
384
385    #[derive(Deserialize, defmt::Format)]
386    pub struct Time {
387        pub time: Option<u32>,
388        pub area: Option<heapless::String<120>>,
389        pub zone: Option<heapless::String<120>>,
390        pub minutes: Option<i32>,
391        pub lat: Option<f64>,
392        pub lon: Option<f64>,
393        pub country: Option<heapless::String<120>>,
394    }
395
396    #[derive(Deserialize, defmt::Format)]
397    pub struct Status {
398        pub status: heapless::String<40>,
399        #[serde(default)]
400        pub usb: bool,
401        pub storage: usize,
402        pub time: Option<u64>,
403        #[serde(default)]
404        pub connected: bool,
405    }
406
407    #[derive(Deserialize, defmt::Format)]
408    pub struct WirelessNet {
409        iccid: Option<heapless::String<24>>,
410        imsi: Option<heapless::String<24>>,
411        imei: Option<heapless::String<24>>,
412        modem: Option<heapless::String<35>>,
413        band: Option<heapless::String<24>>,
414        rat: Option<heapless::String<24>>,
415        ratr: Option<heapless::String<24>>,
416        internal: Option<bool>,
417        rssir: Option<i32>,
418        rssi: Option<i32>,
419        rsrp: Option<i32>,
420        sinr: Option<i32>,
421        rsrq: Option<i32>,
422        bars: Option<i32>,
423        mcc: Option<i32>,
424        mnc: Option<i32>,
425        lac: Option<i32>,
426        cid: Option<i32>,
427        modem_temp: Option<i32>,
428        updated: Option<u32>,
429    }
430
431    #[derive(Deserialize, defmt::Format)]
432    pub struct Wireless {
433        pub status: Option<heapless::String<24>>,
434        pub mode: Option<heapless::String<24>>,
435        pub count: Option<u8>,
436        pub net: Option<WirelessNet>,
437    }
438
439    #[derive(Deserialize, defmt::Format)]
440    pub struct VersionInner {
441        pub org: heapless::String<24>,
442        pub product: heapless::String<24>,
443        pub version: heapless::String<24>,
444        pub ver_major: u8,
445        pub ver_minor: u8,
446        pub ver_patch: u8,
447        pub ver_build: u32,
448        pub built: heapless::String<24>,
449        pub target: Option<heapless::String<5>>,
450    }
451
452    #[derive(Deserialize, defmt::Format)]
453    pub struct Version {
454        pub body: VersionInner,
455        pub version: heapless::String<24>,
456        pub device: heapless::String<24>,
457        pub name: heapless::String<30>,
458        pub board: heapless::String<24>,
459        pub sku: heapless::String<24>,
460        pub api: Option<u16>,
461        pub cell: Option<bool>,
462        pub gps: Option<bool>,
463        pub ordering_code: Option<heapless::String<50>>,
464    }
465
466    #[derive(Deserialize, defmt::Format)]
467    pub struct DFU {
468        pub name: req::DFUName,
469    }
470
471    #[derive(Deserialize, defmt::Format)]
472    pub struct Transport {
473        pub method: heapless::String<120>,
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::NotecardError;
481
482    #[test]
483    fn test_version() {
484        let r = br##"{
485  "body": {
486    "org":       "Blues Wireless",
487    "product":   "Notecard",
488    "version":   "notecard-1.5.0",
489    "ver_major": 1,
490    "ver_minor": 5,
491    "ver_patch": 0,
492    "ver_build": 11236,
493    "built":     "Sep 2 2020 08:45:10"
494  },
495  "version": "notecard-1.5.0.11236",
496  "device":  "dev:000000000000000",
497  "name":    "Blues Wireless Notecard",
498  "board":   "1.11",
499  "sku":     "NOTE-WBNA500",
500  "api":     1
501}"##;
502        serde_json_core::from_slice::<res::Version>(r).unwrap();
503    }
504
505    #[test]
506    fn test_version_411() {
507        let r = br##"{"version":"notecard-4.1.1.4015681","device":"dev:000000000000000","name":"Blues Wireless Notecard","sku":"NOTE-WBEX-500","board":"1.11","api":4,"body":{"org":"Blues Wireless","product":"Notecard","version":"notecard-4.1.1","ver_major":4,"ver_minor":1,"ver_patch":1,"ver_build":4015681,"built":"Dec  5 2022 12:54:58"}}"##;
508        serde_json_core::from_slice::<res::Version>(r).unwrap();
509    }
510
511    #[test]
512    fn test_version_752() {
513        let r = br##"{"version":"notecard-7.5.2.17004","device":"dev:861059067974133","name":"Blues Wireless Notecard","sku":"NOTE-NBGLN","ordering_code":"EB0WT1N0AXBA","board":"5.13","cell":true,"gps":true,"body":{"org":"Blues Wireless","product":"Notecard","target":"u5","version":"notecard-u5-7.5.2","ver_major":7,"ver_minor":5,"ver_patch":2,"ver_build":17004,"built":"Nov 26 2024 14:01:26"}}"##;
514        serde_json_core::from_slice::<res::Version>(r).unwrap();
515    }
516
517    #[test]
518    fn test_card_wireless() {
519        let r = br##"{"status":"{modem-on}","count":3,"net":{"iccid":"89011703278520607527","imsi":"310170852060752","imei":"864475044204278","modem":"BG95M3LAR02A03_01.006.01.006","band":"GSM 900","rat":"gsm","rssir":-77,"rssi":-77,"bars":3,"mcc":242,"mnc":1,"lac":11001,"cid":12313,"updated":1643923524}}"##;
520        serde_json_core::from_slice::<res::Wireless>(r).unwrap();
521
522        let r = br##"{"status":"{cell-registration-wait}","net":{"iccid":"89011703278520606586","imsi":"310170852060658","imei":"864475044197092","modem":"BG95M3LAR02A03_01.006.01.006"}}"##;
523        serde_json_core::from_slice::<res::Wireless>(r).unwrap();
524
525        let r = br##"{"status":"{modem-off}","net":{}}"##;
526        serde_json_core::from_slice::<res::Wireless>(r).unwrap();
527
528        let r = br##"{"status":"{network-up}","mode":"auto","count":3,"net":{"iccid":"89011703278520578660","imsi":"310170852057866","imei":"867730051260788","modem":"BG95M3LAR02A03_01.006.01.006","band":"GSM 900","rat":"gsm","rssir":-77,"rssi":-78,"bars":3,"mcc":242,"mnc":1,"lac":11,"cid":12286,"updated":1646227929}}"##;
529        serde_json_core::from_slice::<res::Wireless>(r).unwrap();
530
531        // NTN
532        let r = br##"{"mode":"auto","count":2,"net":{"iccid":"89011704278930030582","imsi":"310170893003058","imei":"860264054655247","modem":"EG91EXGAR08A05M1G_01.001.01.001","band":"LTE BAND 20","rat":"lte","ratr":"\"LTE\"","internal":true,"rssir":-59,"rssi":-60,"rsrp":-92,"sinr":15,"rsrq":-9,"bars":2,"mcc":242,"mnc":2,"lac":2501,"cid":35398693,"modem_temp":34,"updated":1746004605}}"##;
533        serde_json_core::from_slice::<res::Wireless>(r).unwrap();
534    }
535
536    #[test]
537    fn test_card_time_ok() {
538        let r = br##"
539        {
540          "time": 1599769214,
541          "area": "Beverly, MA",
542          "zone": "CDT,America/New York",
543          "minutes": -300,
544          "lat": 42.5776,
545          "lon": -70.87134,
546          "country": "US"
547        }
548        "##;
549
550        serde_json_core::from_slice::<res::Time>(r).unwrap();
551    }
552
553    #[test]
554    fn test_card_time_sa() {
555        let r = br##"
556        {
557          "time": 1599769214,
558          "area": "Kommetjie Western Cape",
559          "zone": "Africa/Johannesburg",
560          "minutes": -300,
561          "lat": 42.5776,
562          "lon": -70.87134,
563          "country": "ZA"
564        }
565        "##;
566
567        serde_json_core::from_slice::<res::Time>(r).unwrap();
568    }
569
570    #[test]
571    fn test_card_time_err() {
572        let r = br##"{"err":"time is not yet set","zone":"UTC,Unknown"}"##;
573        serde_json_core::from_slice::<NotecardError>(r).unwrap();
574    }
575
576    #[test]
577    pub fn test_status_ok() {
578        serde_json_core::from_str::<res::Status>(
579            r#"
580          {
581            "status":    "{normal}",
582            "usb":       true,
583            "storage":   8,
584            "time":      1599684765,
585            "connected": true
586          }"#,
587        )
588        .unwrap();
589    }
590
591    #[test]
592    pub fn test_status_mising() {
593        serde_json_core::from_str::<res::Status>(
594            r#"
595          {
596            "status":    "{normal}",
597            "usb":       true,
598            "storage":   8
599          }"#,
600        )
601        .unwrap();
602    }
603
604    #[test]
605    fn test_partial_location_mode() {
606        serde_json_core::from_str::<res::LocationMode>(r#"{"seconds":60,"mode":"periodic"}"#)
607            .unwrap();
608    }
609
610    #[test]
611    fn test_parse_exceed_string_size() {
612        serde_json_core::from_str::<res::LocationMode>(
613            r#"{"seconds":60,"mode":"periodicperiodicperiodicperiodicperiodicperiodicperiodic"}"#,
614        )
615        .ok();
616    }
617
618    #[test]
619    fn test_location_searching() {
620        serde_json_core::from_str::<res::Location>(
621            r#"{"status":"GPS search (111 sec, 32/33 dB SNR, 0/1 sats) {gps-active} {gps-signal} {gps-sats}","mode":"continuous"}"#).unwrap();
622    }
623
624    #[test]
625    fn test_location_mode_err() {
626        let r = br##"{"err":"seconds: field seconds: unmarshal: expected a int32 {io}"}"##;
627        serde_json_core::from_slice::<NotecardError>(r).unwrap();
628    }
629
630    #[test]
631    fn test_dfu_name() {
632        let (res, _) = serde_json_core::from_str::<req::DFUName>(r#""esp32""#).unwrap();
633        assert_eq!(res, req::DFUName::Esp32);
634        let (res, _) = serde_json_core::from_str::<req::DFUName>(r#""stm32""#).unwrap();
635        assert_eq!(res, req::DFUName::Stm32);
636        let (res, _) = serde_json_core::from_str::<req::DFUName>(r#""stm32-bi""#).unwrap();
637        assert_eq!(res, req::DFUName::Stm32Bi);
638        let (res, _) = serde_json_core::from_str::<req::DFUName>(r#""mcuboot""#).unwrap();
639        assert_eq!(res, req::DFUName::McuBoot);
640        let (res, _) = serde_json_core::from_str::<req::DFUName>(r#""-""#).unwrap();
641        assert_eq!(res, req::DFUName::Reset);
642    }
643
644    #[test]
645    fn test_dfu_req() {
646        // Test basic request
647        let req = req::DFU::new(None, None, None);
648        let res: heapless::String<1024> = serde_json_core::to_string(&req).unwrap();
649        assert_eq!(res, r#"{"req":"card.dfu"}"#);
650
651        // Test name & on request
652        let req = req::DFU::new(Some(req::DFUName::Esp32), Some(true), None);
653        let res: heapless::String<256> = serde_json_core::to_string(&req).unwrap();
654        assert_eq!(res, r#"{"req":"card.dfu","name":"esp32","on":true}"#);
655
656        // Test off request
657        let req = req::DFU::new(None, Some(false), None);
658        let res: heapless::String<256> = serde_json_core::to_string(&req).unwrap();
659        assert_eq!(res, r#"{"req":"card.dfu","off":true}"#);
660
661        // Test stop request
662        let req = req::DFU::new(None, None, Some(true));
663        let res: heapless::String<256> = serde_json_core::to_string(&req).unwrap();
664        assert_eq!(res, r#"{"req":"card.dfu","stop":true}"#);
665
666        // Test start request
667        let req = req::DFU::new(None, None, Some(false));
668        let res: heapless::String<256> = serde_json_core::to_string(&req).unwrap();
669        assert_eq!(res, r#"{"req":"card.dfu","start":true}"#);
670    }
671
672    #[test]
673    fn test_dfu_res() {
674        serde_json_core::from_str::<res::DFU>(r#"{"name": "stm32"}"#).unwrap();
675    }
676}