kenwood_thd75/radio/
system.rs

1//! System-level radio methods: battery level, beep, lock, dual-band, frequency step, bluetooth, attenuator, auto-info.
2
3use crate::error::{Error, ProtocolError};
4use crate::protocol::{Command, Response};
5use crate::transport::Transport;
6use crate::types::{Band, BatteryLevel, ChannelMemory, DetectOutputMode, KeyLockType};
7
8use super::Radio;
9
10impl<T: Transport> Radio<T> {
11    /// Get beep setting (BE read).
12    ///
13    /// D75 RE: `BE x` (x: 0=off, 1=on).
14    ///
15    /// # Errors
16    ///
17    /// Returns an error if the command fails or the response is unexpected.
18    pub async fn get_beep(&mut self) -> Result<bool, Error> {
19        tracing::debug!("reading beep setting");
20        let response = self.execute(Command::GetBeep).await?;
21        match response {
22            Response::Beep { enabled } => Ok(enabled),
23            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
24                expected: "Beep".into(),
25                actual: format!("{other:?}").into_bytes(),
26            })),
27        }
28    }
29
30    /// Set beep on/off (BE write).
31    ///
32    /// D75 RE: `BE x` (x: 0=off, 1=on).
33    ///
34    /// # D75 firmware bug
35    ///
36    /// **The CAT `BE` write command is a firmware stub on the TH-D75.** It always returns `?`
37    /// regardless of the value sent. The read (`get_beep`) works, but writes are silently
38    /// ignored by the firmware.
39    ///
40    /// Use [`set_beep_via_mcp`](Self::set_beep_via_mcp) instead, which writes directly to
41    /// the verified MCP memory offset (`0x1071`) and actually changes the setting.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the command fails or the response is unexpected.
46    pub async fn set_beep(&mut self, enabled: bool) -> Result<(), Error> {
47        tracing::debug!(enabled, "setting beep");
48        let response = self.execute(Command::SetBeep { enabled }).await?;
49        match response {
50            Response::Beep { .. } => Ok(()),
51            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
52                expected: "Beep".into(),
53                actual: format!("{other:?}").into_bytes(),
54            })),
55        }
56    }
57
58    /// Get the battery charge level (BL read).
59    ///
60    /// Returns 0=Empty (Red), 1=1/3 (Yellow), 2=2/3 (Green), 3=Full (Green),
61    /// 4=Charging (USB power connected). Read-only.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the command fails or the response is unexpected.
66    pub async fn get_battery_level(&mut self) -> Result<BatteryLevel, Error> {
67        tracing::debug!("reading battery level");
68        let response = self.execute(Command::GetBatteryLevel).await?;
69        match response {
70            Response::BatteryLevel { level } => Ok(level),
71            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
72                expected: "BatteryLevel".into(),
73                actual: format!("{other:?}").into_bytes(),
74            })),
75        }
76    }
77
78    /// Set battery level display (BL write).
79    ///
80    /// # Warning
81    ///
82    /// The exact purpose of this command is unclear. It may control the battery
83    /// display indicator or be a calibration/test interface. The `display` and
84    /// `level` parameter semantics are undocumented.
85    ///
86    /// # Wire format
87    ///
88    /// `BL display,level\r` (7 bytes with comma).
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the command fails or the response is unexpected.
93    pub async fn set_battery_level(&mut self, bl_display: u8, level: u8) -> Result<(), Error> {
94        tracing::info!(
95            bl_display,
96            level,
97            "setting battery level display (BL write)"
98        );
99        let response = self
100            .execute(Command::SetBatteryLevel {
101                display: bl_display,
102                level,
103            })
104            .await?;
105        match response {
106            Response::BatteryLevel { .. } => Ok(()),
107            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
108                expected: "BatteryLevel".into(),
109                actual: format!("{other:?}").into_bytes(),
110            })),
111        }
112    }
113
114    /// Get the key lock state (LC read).
115    ///
116    /// On the TH-D75, the LC wire value is inverted: `LC 0` means locked,
117    /// `LC 1` means unlocked. This method inverts the response so that
118    /// `true` means locked (logical meaning). The MCP offset for the lock
119    /// setting is `0x1060`.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the command fails or the response is unexpected.
124    pub async fn get_lock(&mut self) -> Result<bool, Error> {
125        tracing::debug!("reading key lock state");
126        let response = self.execute(Command::GetLock).await?;
127        match response {
128            // Wire value is inverted: 0 = locked, 1 = unlocked.
129            Response::Lock { locked } => Ok(!locked),
130            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
131                expected: "Lock".into(),
132                actual: format!("{other:?}").into_bytes(),
133            })),
134        }
135    }
136
137    /// Set the key lock state (LC write).
138    ///
139    /// Accepts logical meaning: `true` = locked, `false` = unlocked.
140    /// Inverts before sending on the wire (`LC 0` = locked, `LC 1` =
141    /// unlocked on the D75).
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the command fails or the response is unexpected.
146    pub async fn set_lock(&mut self, locked: bool) -> Result<(), Error> {
147        tracing::info!(locked, "setting key lock");
148        // Invert: logical true (locked) → wire 0, logical false → wire 1.
149        let response = self.execute(Command::SetLock { locked: !locked }).await?;
150        match response {
151            Response::Lock { .. } => Ok(()),
152            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
153                expected: "Lock".into(),
154                actual: format!("{other:?}").into_bytes(),
155            })),
156        }
157    }
158
159    /// Set all lock/control fields (LC 6-field write).
160    ///
161    /// Sends the full `LC a,b,c,d,e,f` format to configure all lock parameters at once.
162    ///
163    /// # Parameters
164    ///
165    /// - `locked`: master lock enable (`true` = locked, `false` = unlocked). Inverted
166    ///   before sending on the wire (D75: `0` = locked, `1` = unlocked).
167    /// - `lock_type`: what to lock — key only, key+PTT, or key+PTT+dial.
168    /// - `lock_a`: lock Band A controls (`true` = locked).
169    /// - `lock_b`: lock Band B controls (`true` = locked).
170    /// - `lock_c`: lock Band C controls (`true` = locked).
171    /// - `lock_ptt`: lock the PTT button (`true` = locked, prevents transmission).
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the command fails or the response is unexpected.
176    #[allow(clippy::fn_params_excessive_bools)]
177    pub async fn set_lock_full(
178        &mut self,
179        locked: bool,
180        lock_type: KeyLockType,
181        lock_a: bool,
182        lock_b: bool,
183        lock_c: bool,
184        lock_ptt: bool,
185    ) -> Result<(), Error> {
186        tracing::info!(
187            locked,
188            ?lock_type,
189            lock_a,
190            lock_b,
191            lock_c,
192            lock_ptt,
193            "setting full lock configuration"
194        );
195        // Invert master lock: logical true (locked) → wire 0.
196        let response = self
197            .execute(Command::SetLockFull {
198                locked: !locked,
199                lock_type,
200                lock_a,
201                lock_b,
202                lock_c,
203                lock_ptt,
204            })
205            .await?;
206        match response {
207            Response::Lock { .. } => Ok(()),
208            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
209                expected: "Lock".into(),
210                actual: format!("{other:?}").into_bytes(),
211            })),
212        }
213    }
214
215    /// Get the dual-band enabled state (DL read).
216    ///
217    /// On the TH-D75, the DL wire value is inverted: `DL 0` means dual
218    /// band enabled, `DL 1` means single band. This method inverts the
219    /// response so that `true` means dual band is enabled (logical meaning).
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if the command fails or the response is unexpected.
224    pub async fn get_dual_band(&mut self) -> Result<bool, Error> {
225        tracing::debug!("reading dual-band state");
226        let response = self.execute(Command::GetDualBand).await?;
227        match response {
228            // Wire value is inverted: 0 = dual band, 1 = single band.
229            Response::DualBand { enabled } => Ok(!enabled),
230            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
231                expected: "DualBand".into(),
232                actual: format!("{other:?}").into_bytes(),
233            })),
234        }
235    }
236
237    /// Set the dual-band enabled state (DL write).
238    ///
239    /// Accepts logical meaning: `true` = dual band enabled, `false` =
240    /// single band. Inverts before sending on the wire (`DL 0` = dual
241    /// band, `DL 1` = single band on the D75).
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the command fails or the response is unexpected.
246    pub async fn set_dual_band(&mut self, enabled: bool) -> Result<(), Error> {
247        tracing::debug!(enabled, "setting dual-band state");
248        // Invert: logical true (dual band) → wire 0, logical false → wire 1.
249        let response = self
250            .execute(Command::SetDualBand { enabled: !enabled })
251            .await?;
252        match response {
253            Response::DualBand { .. } => Ok(()),
254            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
255                expected: "DualBand".into(),
256                actual: format!("{other:?}").into_bytes(),
257            })),
258        }
259    }
260
261    /// Step frequency down on the given band (DW action), then read back the
262    /// resulting frequency.
263    ///
264    /// Sends `DW` to step down, then issues `FQ` to read back the new
265    /// frequency. Returns the post-step [`ChannelMemory`].
266    ///
267    /// Per KI4LAX CAT reference: DW tunes the current band's frequency
268    /// down by the current step size. Counterpart to [`frequency_up`](super::Radio::frequency_up).
269    ///
270    /// # VFO mode requirement
271    ///
272    /// The target band must be in VFO mode for this command to take effect. In Memory mode,
273    /// the command may be ignored or return an error.
274    ///
275    /// # Step size
276    ///
277    /// The frequency moves by the band's current step size (see
278    /// [`get_step_size`](super::Radio::get_step_size) /
279    /// [`set_step_size`](super::Radio::set_step_size)). The step size varies by
280    /// band and mode — for example, 25 kHz for FM, 1 kHz for SSB.
281    ///
282    /// # Wire format
283    ///
284    /// `DW band\r` where band is 0 (A) or 1 (B). Despite the mnemonic suggesting "Dual Watch",
285    /// on the D75 this is strictly frequency-down.
286    ///
287    /// # Errors
288    ///
289    /// Returns an error if the command fails or the response is unexpected.
290    ///
291    /// [`ChannelMemory`]: crate::types::ChannelMemory
292    pub async fn frequency_down(&mut self, band: Band) -> Result<ChannelMemory, Error> {
293        tracing::debug!(?band, "stepping frequency down");
294        let response = self.execute(Command::FrequencyDown { band }).await?;
295        // The radio echoes either `DW\r` (parsed as FrequencyDown) or a bare
296        // OK depending on firmware version and AI mode state.
297        match response {
298            Response::FrequencyDown | Response::Ok => {}
299            other => {
300                return Err(Error::Protocol(ProtocolError::UnexpectedResponse {
301                    expected: "FrequencyDown".into(),
302                    actual: format!("{other:?}").into_bytes(),
303                }));
304            }
305        }
306        self.get_frequency(band).await
307    }
308
309    /// Step frequency down on the given band (DW action) without reading
310    /// back the result.
311    ///
312    /// This is the fire-and-forget variant of [`frequency_down`](Self::frequency_down).
313    /// Use this when you do not need the resulting frequency (saves one
314    /// round-trip).
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the command fails or the response is unexpected.
319    pub async fn frequency_down_blind(&mut self, band: Band) -> Result<(), Error> {
320        tracing::debug!(?band, "stepping frequency down (blind)");
321        let response = self.execute(Command::FrequencyDown { band }).await?;
322        match response {
323            Response::FrequencyDown | Response::Ok => Ok(()),
324            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
325                expected: "FrequencyDown".into(),
326                actual: format!("{other:?}").into_bytes(),
327            })),
328        }
329    }
330
331    /// Get the Bluetooth enabled state (BT read).
332    ///
333    /// # Errors
334    ///
335    /// Returns an error if the command fails or the response is unexpected.
336    pub async fn get_bluetooth(&mut self) -> Result<bool, Error> {
337        tracing::debug!("reading Bluetooth state");
338        let response = self.execute(Command::GetBluetooth).await?;
339        match response {
340            Response::Bluetooth { enabled } => Ok(enabled),
341            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
342                expected: "Bluetooth".into(),
343                actual: format!("{other:?}").into_bytes(),
344            })),
345        }
346    }
347
348    /// Set the Bluetooth enabled state (BT write).
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if the command fails or the response is unexpected.
353    pub async fn set_bluetooth(&mut self, enabled: bool) -> Result<(), Error> {
354        tracing::info!(enabled, "setting Bluetooth state");
355        let response = self.execute(Command::SetBluetooth { enabled }).await?;
356        match response {
357            Response::Bluetooth { .. } => Ok(()),
358            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
359                expected: "Bluetooth".into(),
360                actual: format!("{other:?}").into_bytes(),
361            })),
362        }
363    }
364
365    /// Get the attenuator state for the given band (RA read).
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the command fails or the response is unexpected.
370    pub async fn get_attenuator(&mut self, band: Band) -> Result<bool, Error> {
371        tracing::debug!(?band, "reading attenuator state");
372        let response = self.execute(Command::GetAttenuator { band }).await?;
373        match response {
374            Response::Attenuator { enabled, .. } => Ok(enabled),
375            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
376                expected: "Attenuator".into(),
377                actual: format!("{other:?}").into_bytes(),
378            })),
379        }
380    }
381
382    /// Set the attenuator state for the given band (RA write).
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if the command fails or the response is unexpected.
387    pub async fn set_attenuator(&mut self, band: Band, enabled: bool) -> Result<(), Error> {
388        tracing::debug!(?band, enabled, "setting attenuator state");
389        let response = self
390            .execute(Command::SetAttenuator { band, enabled })
391            .await?;
392        match response {
393            Response::Attenuator { .. } => Ok(()),
394            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
395                expected: "Attenuator".into(),
396                actual: format!("{other:?}").into_bytes(),
397            })),
398        }
399    }
400
401    /// Set the auto-info mode (AI write). This is a write-only command.
402    ///
403    /// When enabled (`AI 1`), the radio pushes unsolicited status updates over the serial
404    /// connection whenever internal state changes. This includes frequency changes (FQ),
405    /// mode changes (MD), squelch changes (SQ), and busy state transitions (BY). Without
406    /// AI mode, the only way to detect changes is to poll each command individually.
407    ///
408    /// Unsolicited frames pushed by the radio are delivered through the broadcast channel
409    /// returned by [`subscribe`](Self::subscribe). The `execute()` method routes solicited
410    /// responses (matching the sent command's mnemonic) to the caller and unsolicited frames
411    /// to the broadcast channel.
412    ///
413    /// This command is write-only — there is no `AI` read form. To check the current state,
414    /// you must track it in your application after calling this method.
415    ///
416    /// # Wire format
417    ///
418    /// `AI 0\r` (disable) or `AI 1\r` (enable).
419    ///
420    /// # Errors
421    ///
422    /// Returns an error if the command fails or the response is unexpected.
423    pub async fn set_auto_info(&mut self, enabled: bool) -> Result<(), Error> {
424        tracing::info!(enabled, "setting auto-info mode");
425        let response = self.execute(Command::SetAutoInfo { enabled }).await?;
426        match response {
427            Response::AutoInfo { .. } => Ok(()),
428            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
429                expected: "AutoInfo".into(),
430                actual: format!("{other:?}").into_bytes(),
431            })),
432        }
433    }
434
435    /// Get the radio type/region code (TY read).
436    ///
437    /// Returns a tuple of (region code, variant number). For example,
438    /// `("K", 2)` indicates a US-region radio, hardware variant 2.
439    ///
440    /// This command is not in the firmware's 53-command dispatch table.
441    ///
442    /// # Errors
443    ///
444    /// Returns an error if the command fails or the response is unexpected.
445    pub async fn get_radio_type(&mut self) -> Result<(String, u8), Error> {
446        tracing::debug!("reading radio type/region");
447        let response = self.execute(Command::GetRadioType).await?;
448        match response {
449            Response::RadioType { region, variant } => Ok((region, variant)),
450            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
451                expected: "RadioType".into(),
452                actual: format!("{other:?}").into_bytes(),
453            })),
454        }
455    }
456
457    /// Get the USB Out select state (IO read).
458    ///
459    /// Per KI4LAX CAT reference and Operating Tips §5.10.5:
460    /// 0 = AF (audio frequency output), 1 = IF (12 kHz centered IF signal
461    /// for SSB/CW/AM, 15 kHz bandwidth), 2 = Detect (pre-detection signal).
462    ///
463    /// Menu 102 (USB Out Select) controls this. IF/Detect output is only
464    /// available when in Single Band mode on Band B.
465    ///
466    /// # Errors
467    ///
468    /// Returns an error if the command fails or the response is unexpected.
469    pub async fn get_io_port(&mut self) -> Result<DetectOutputMode, Error> {
470        tracing::debug!("reading I/O port state");
471        let response = self.execute(Command::GetIoPort).await?;
472        match response {
473            Response::IoPort { value } => Ok(value),
474            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
475                expected: "IoPort".into(),
476                actual: format!("{other:?}").into_bytes(),
477            })),
478        }
479    }
480
481    /// Set the USB Out select state (IO write).
482    ///
483    /// See [`get_io_port`](Self::get_io_port) for value meanings.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if the command fails or the response is unexpected.
488    pub async fn set_io_port(&mut self, value: DetectOutputMode) -> Result<(), Error> {
489        tracing::debug!(?value, "setting I/O port output mode");
490        let response = self.execute(Command::SetIoPort { value }).await?;
491        match response {
492            Response::IoPort { .. } => Ok(()),
493            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
494                expected: "IoPort".into(),
495                actual: format!("{other:?}").into_bytes(),
496            })),
497        }
498    }
499
500    /// Query SD card / programming interface status (SD read).
501    ///
502    /// The firmware's SD handler primarily checks for `SD PROGRAM` to enter
503    /// MCP programming mode. The bare `SD` read response indicates
504    /// programming interface readiness, not SD card presence.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error if the command fails or the response is unexpected.
509    pub async fn get_sd_status(&mut self) -> Result<bool, Error> {
510        tracing::debug!("reading SD/programming interface status");
511        let response = self.execute(Command::GetSdCard).await?;
512        match response {
513            Response::SdCard { present } => Ok(present),
514            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
515                expected: "SdCard".into(),
516                actual: format!("{other:?}").into_bytes(),
517            })),
518        }
519    }
520
521    /// Get MCP status (0E read).
522    ///
523    /// Returns `N` (not available) in normal operating mode. This mnemonic
524    /// appears to be MCP-related.
525    ///
526    /// # Errors
527    ///
528    /// Returns an error if the command fails or the response is unexpected.
529    pub async fn get_mcp_status(&mut self) -> Result<String, Error> {
530        tracing::debug!("reading MCP status");
531        let response = self.execute(Command::GetMcpStatus).await?;
532        match response {
533            Response::McpStatus { value } => Ok(value),
534            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
535                expected: "McpStatus".into(),
536                actual: format!("{other:?}").into_bytes(),
537            })),
538        }
539    }
540
541    // -----------------------------------------------------------------------
542    // MCP-based setting writes (for settings where CAT writes are rejected)
543    //
544    // The TH-D75 firmware rejects CAT write commands for several settings
545    // (BE, BL, DW return `?` for all write formats). These methods bypass
546    // CAT entirely and write directly to the verified MCP memory offsets.
547    //
548    // Each method enters MCP programming mode, reads the containing page,
549    // modifies the target byte, writes the page back, and exits. The USB
550    // connection does not survive the MCP transition — after calling any of
551    // these methods, drop the Radio and reconnect.
552    // -----------------------------------------------------------------------
553
554    /// Set key beep on/off via MCP memory write.
555    ///
556    /// The CAT `BE` command is a firmware stub on the TH-D75 — it always
557    /// returns `?` for writes. This method writes directly to the verified
558    /// MCP offset (`0x1071`) instead.
559    ///
560    /// # Connection lifetime
561    ///
562    /// This enters MCP programming mode. The USB connection drops after
563    /// exit. The `Radio` instance should be dropped and a fresh connection
564    /// established for subsequent CAT commands.
565    ///
566    /// # Errors
567    ///
568    /// Returns an error if entering programming mode, reading the page,
569    /// writing the page, or exiting programming mode fails.
570    pub async fn set_beep_via_mcp(&mut self, enabled: bool) -> Result<(), Error> {
571        const OFFSET: usize = 0x1071;
572        // 0x1071 / 256 = 0x10 = 16, fits in u16.
573        #[allow(clippy::cast_possible_truncation)]
574        const PAGE: u16 = (OFFSET / 256) as u16;
575        const BYTE_INDEX: usize = OFFSET % 256;
576
577        tracing::info!(enabled, offset = OFFSET, "setting key beep via MCP");
578        self.modify_memory_page(PAGE, |data| {
579            data[BYTE_INDEX] = u8::from(enabled);
580        })
581        .await
582    }
583
584    /// Set beep volume level via MCP memory write.
585    ///
586    /// The CAT `BE` command only supports on/off — volume level must be
587    /// set via MCP. Writes directly to verified MCP offset (`0x1072`).
588    /// Volume range is 0–7 (per Menu 915 in the Operating Tips §5.6.1).
589    ///
590    /// # Connection lifetime
591    ///
592    /// This enters MCP programming mode. The USB connection drops after
593    /// exit. The `Radio` instance should be dropped and a fresh connection
594    /// established for subsequent CAT commands.
595    ///
596    /// # Errors
597    ///
598    /// Returns an error if entering programming mode, reading the page,
599    /// writing the page, or exiting programming mode fails.
600    pub async fn set_beep_volume_via_mcp(&mut self, volume: u8) -> Result<(), Error> {
601        const OFFSET: usize = 0x1072;
602        #[allow(clippy::cast_possible_truncation)]
603        const PAGE: u16 = (OFFSET / 256) as u16;
604        const BYTE_INDEX: usize = OFFSET % 256;
605
606        if volume > 7 {
607            return Err(Error::Validation(
608                crate::error::ValidationError::SettingOutOfRange {
609                    name: "beep volume",
610                    value: volume,
611                    detail: "must be 0-7",
612                },
613            ));
614        }
615
616        tracing::info!(volume, offset = OFFSET, "setting beep volume via MCP");
617        self.modify_memory_page(PAGE, |data| {
618            data[BYTE_INDEX] = volume;
619        })
620        .await
621    }
622
623    /// Set VOX enabled on/off via MCP memory write.
624    ///
625    /// Writes directly to the verified MCP offset (`0x101B`). This
626    /// provides an alternative to CAT for modes where CAT writes are
627    /// rejected.
628    ///
629    /// # Connection lifetime
630    ///
631    /// This enters MCP programming mode. The USB connection drops after
632    /// exit. The `Radio` instance should be dropped and a fresh connection
633    /// established for subsequent CAT commands.
634    ///
635    /// # Errors
636    ///
637    /// Returns an error if entering programming mode, reading the page,
638    /// writing the page, or exiting programming mode fails.
639    pub async fn set_vox_via_mcp(&mut self, enabled: bool) -> Result<(), Error> {
640        const OFFSET: usize = 0x101B;
641        // 0x101B / 256 = 0x10 = 16, fits in u16.
642        #[allow(clippy::cast_possible_truncation)]
643        const PAGE: u16 = (OFFSET / 256) as u16;
644        const BYTE_INDEX: usize = OFFSET % 256;
645
646        tracing::info!(enabled, offset = OFFSET, "setting VOX enable via MCP");
647        self.modify_memory_page(PAGE, |data| {
648            data[BYTE_INDEX] = u8::from(enabled);
649        })
650        .await
651    }
652
653    /// Set lock on/off via MCP memory write.
654    ///
655    /// Writes directly to the verified MCP offset (`0x1060`). This
656    /// provides an alternative to CAT for modes where CAT writes are
657    /// rejected.
658    ///
659    /// # Connection lifetime
660    ///
661    /// This enters MCP programming mode. The USB connection drops after
662    /// exit. The `Radio` instance should be dropped and a fresh connection
663    /// established for subsequent CAT commands.
664    ///
665    /// # Errors
666    ///
667    /// Returns an error if entering programming mode, reading the page,
668    /// writing the page, or exiting programming mode fails.
669    pub async fn set_lock_via_mcp(&mut self, locked: bool) -> Result<(), Error> {
670        const OFFSET: usize = 0x1060;
671        // 0x1060 / 256 = 0x10 = 16, fits in u16.
672        #[allow(clippy::cast_possible_truncation)]
673        const PAGE: u16 = (OFFSET / 256) as u16;
674        const BYTE_INDEX: usize = OFFSET % 256;
675
676        tracing::info!(locked, offset = OFFSET, "setting lock via MCP");
677        self.modify_memory_page(PAGE, |data| {
678            data[BYTE_INDEX] = u8::from(locked);
679        })
680        .await
681    }
682
683    /// Set Bluetooth on/off via MCP memory write.
684    ///
685    /// Writes directly to the verified MCP offset (`0x1078`). This
686    /// provides an alternative to CAT for modes where CAT writes are
687    /// rejected.
688    ///
689    /// # Connection lifetime
690    ///
691    /// This enters MCP programming mode. The USB connection drops after
692    /// exit. The `Radio` instance should be dropped and a fresh connection
693    /// established for subsequent CAT commands.
694    ///
695    /// # Errors
696    ///
697    /// Returns an error if entering programming mode, reading the page,
698    /// writing the page, or exiting programming mode fails.
699    pub async fn set_bluetooth_via_mcp(&mut self, enabled: bool) -> Result<(), Error> {
700        const OFFSET: usize = 0x1078;
701        // 0x1078 / 256 = 0x10 = 16, fits in u16.
702        #[allow(clippy::cast_possible_truncation)]
703        const PAGE: u16 = (OFFSET / 256) as u16;
704        const BYTE_INDEX: usize = OFFSET % 256;
705
706        tracing::info!(enabled, offset = OFFSET, "setting Bluetooth via MCP");
707        self.modify_memory_page(PAGE, |data| {
708            data[BYTE_INDEX] = u8::from(enabled);
709        })
710        .await
711    }
712}