thd75_tui/radio_task.rs
1use std::time::Duration;
2
3use kenwood_thd75::Radio;
4use kenwood_thd75::transport::EitherTransport;
5use kenwood_thd75::transport::SerialTransport;
6use kenwood_thd75::types::{Band, SMeterReading};
7use tokio::sync::mpsc;
8
9use crate::app::{BandState, Message, RadioState};
10
11/// Poll interval for reading radio state.
12/// ~10 commands per cycle (FQ, SQ, MD, PC, RA, FS per band + globals).
13/// SM and BY are NOT polled — they use AI push notifications instead.
14const POLL_INTERVAL: Duration = Duration::from_millis(500);
15
16/// Reconnect poll interval after disconnect.
17const RECONNECT_INTERVAL: Duration = Duration::from_secs(1);
18
19/// Open a transport on the calling thread (must be main for BT).
20///
21/// This is synchronous — call from main before starting tokio.
22/// On macOS, Bluetooth RFCOMM callbacks require the main thread's
23/// `CFRunLoop`, so transport discovery must happen before the tokio
24/// runtime is spawned on a dedicated thread.
25///
26/// # Arguments
27/// - `port`: explicit serial port path (e.g. `/dev/cu.usbmodem*`), or
28/// `None` for auto-detection of USB CDC or Bluetooth SPP.
29/// - `baud`: baud rate (115200 for USB CDC, 9600 for BT SPP).
30///
31/// # Returns
32/// `(port_path, transport)` on success, or an error string.
33pub(crate) fn discover_and_open_transport(
34 port: Option<&str>,
35 baud: u32,
36) -> Result<(String, EitherTransport), String> {
37 discover_and_open(port, baud)
38}
39
40/// Spawn the radio communication task with a pre-opened transport.
41///
42/// Performs initial handshake (identify, enable AI mode, read firmware
43/// version and radio type), then spawns a tokio task that:
44/// 1. Polls band state (FQ, SQ, MD, PC, RA, FS) and global state on a timer
45/// 2. Processes AI-pushed BY notifications as a gate for SM reads
46/// 3. Handles user commands (tune, set squelch, MCP write, etc.)
47///
48/// S-meter and busy state are event-driven via AI mode, not polled.
49/// This avoids spurious firmware spikes on Band B that occur with
50/// direct SM/BY polling.
51///
52/// # Arguments
53/// - `mcp_speed`: `"fast"` for `McpSpeed::Fast` (risky), anything else for Safe.
54/// - `tx`: channel for sending state updates and errors to the TUI.
55/// - `bt_req_tx` / `bt_resp_rx`: channels for requesting BT reconnect from
56/// the main thread (`IOBluetooth` RFCOMM must be opened on main).
57/// - `cmd_rx`: channel for receiving user commands from the TUI.
58#[allow(clippy::similar_names)]
59pub(crate) async fn spawn_with_transport(
60 path: String,
61 transport: EitherTransport,
62 mcp_speed: String,
63 tx: mpsc::UnboundedSender<Message>,
64 mut cmd_rx: mpsc::UnboundedReceiver<crate::event::RadioCommand>,
65 bt_req_tx: std::sync::mpsc::Sender<(Option<String>, u32)>,
66 bt_resp_rx: std::sync::mpsc::Receiver<Result<(String, EitherTransport), String>>,
67) -> Result<String, String> {
68 let baud = SerialTransport::DEFAULT_BAUD;
69 let mut radio = Radio::connect(transport)
70 .await
71 .map_err(|e| format!("Connect failed: {e}"))?;
72
73 if mcp_speed == "fast" {
74 radio.set_mcp_speed(kenwood_thd75::McpSpeed::Fast);
75 }
76
77 // Verify identity and read static info
78 let _info = radio
79 .identify()
80 .await
81 .map_err(|e| format!("Identify failed: {e}"))?;
82
83 // Enable AI (Auto Information) mode — radio pushes BY/FQ/MD notifications
84 // instead of requiring polling. This is critical for reliable S-meter:
85 // AI-pushed BY notifications go through the radio's internal squelch
86 // debouncing, while polled BY reads raw hardware state with spurious spikes.
87 radio
88 .set_auto_info(true)
89 .await
90 .map_err(|e| format!("AI mode failed: {e}"))?;
91
92 // Subscribe to unsolicited notifications from AI mode
93 let mut notifications = radio.subscribe();
94
95 let firmware_version = radio.get_firmware_version().await.unwrap_or_default();
96 let radio_type = radio
97 .get_radio_type()
98 .await
99 .map(|(region, variant)| format!("{region} v{variant}"))
100 .unwrap_or_default();
101
102 let _ = tx.send(Message::RadioUpdate(RadioState {
103 firmware_version,
104 radio_type,
105 ..RadioState::default()
106 }));
107
108 let path_clone = path.clone();
109
110 let _task = tokio::spawn(async move {
111 // AI notification state — these fields are updated by push notifications
112 // from the radio (AI mode) rather than polling. This reduces USB traffic,
113 // provides instant updates, and avoids firmware quirks (e.g., spurious
114 // SM/BY spikes on Band B when polled directly).
115 let mut s_meter_a = SMeterReading::new(0).unwrap();
116 let mut s_meter_b = SMeterReading::new(0).unwrap();
117 let mut busy_a = false;
118 let mut busy_b = false;
119
120 // Wrap in Option so we can take() for APRS mode (which consumes
121 // the Radio by value via enter_kiss) and put it back after.
122 let mut radio_opt: Option<Radio<EitherTransport>> = Some(radio);
123
124 // Why break out of the inner loop: Reconnect (transport lost) or
125 // APRS mode entry (radio consumed). `aprs_pending` stores an APRS
126 // config when the inner loop broke because of an EnterAprs command.
127 let mut aprs_pending: Option<Box<kenwood_thd75::AprsClientConfig>> = None;
128 let mut dstar_pending: Option<kenwood_thd75::DStarGatewayConfig> = None;
129
130 // Main loop: poll + handle commands + process AI notifications
131 'outer: loop {
132 // --- Handle pending APRS mode entry ---
133 // This runs outside the select! borrow scope so we can take()
134 // the radio from the Option without conflicting borrows.
135 if let Some(config) = aprs_pending.take()
136 && let Some(taken_radio) = radio_opt.take()
137 {
138 match enter_aprs_session(taken_radio, *config, &tx, &mut cmd_rx).await {
139 Ok(new_radio) => {
140 let mut r = new_radio;
141 if let Err(e) = r.set_auto_info(true).await {
142 tracing::warn!("AI mode after APRS exit: {e}");
143 }
144 notifications = r.subscribe();
145 s_meter_a = SMeterReading::new(0).unwrap();
146 s_meter_b = SMeterReading::new(0).unwrap();
147 busy_a = false;
148 busy_b = false;
149 radio_opt = Some(r);
150 let _ = tx.send(Message::AprsStopped);
151 continue 'outer;
152 }
153 Err(EnterAprsError::KissExitFailed(msg)) => {
154 let _ = tx.send(Message::AprsError(msg));
155 let _ = tx.send(Message::AprsStopped);
156 let _ = tx.send(Message::Disconnected);
157 // radio_opt is None — fall through to reconnect.
158 }
159 }
160 }
161
162 // --- Handle pending D-STAR gateway mode entry ---
163 if let Some(config) = dstar_pending.take()
164 && let Some(taken_radio) = radio_opt.take()
165 {
166 match enter_dstar_session(taken_radio, config, &tx, &mut cmd_rx).await {
167 Ok(new_radio) => {
168 let mut r = new_radio;
169 if let Err(e) = r.set_auto_info(true).await {
170 tracing::warn!("AI mode after D-STAR exit: {e}");
171 }
172 notifications = r.subscribe();
173 s_meter_a = SMeterReading::new(0).unwrap();
174 s_meter_b = SMeterReading::new(0).unwrap();
175 busy_a = false;
176 busy_b = false;
177 radio_opt = Some(r);
178 let _ = tx.send(Message::DStarStopped);
179 continue 'outer;
180 }
181 Err(EnterDStarError::MmdvmExitFailed(msg)) => {
182 let _ = tx.send(Message::DStarError(msg));
183 let _ = tx.send(Message::DStarStopped);
184 let _ = tx.send(Message::Disconnected);
185 // radio_opt is None — fall through to reconnect.
186 }
187 }
188 }
189
190 // --- CAT polling inner loop ---
191 // radio_opt must be Some here unless we're reconnecting.
192 if let Some(ref mut radio) = radio_opt {
193 loop {
194 tokio::select! {
195 () = tokio::time::sleep(POLL_INTERVAL) => {
196 match poll_once(radio, s_meter_a, s_meter_b, busy_a, busy_b).await {
197 Ok(state) => {
198 if tx.send(Message::RadioUpdate(state)).is_err() {
199 return;
200 }
201 }
202 Err(PollError::Transport(e)) => {
203 let _ = tx.send(Message::RadioError(e));
204 break; // Go to reconnect
205 }
206 Err(PollError::Protocol(e)) => {
207 // Parse errors are non-fatal — skip this poll cycle
208 let _ = tx.send(Message::RadioError(e));
209 }
210 }
211 }
212 Ok(notification) = notifications.recv() => {
213 // Process AI-pushed notifications. The radio sends these
214 // automatically when state changes (AI 1 mode). This is
215 // faster than polling and avoids firmware quirks.
216 use kenwood_thd75::protocol::Response;
217 // Other AI notifications (FQ, MD, SQ, VM, etc.) are
218 // handled implicitly — the next poll cycle will read
219 // the updated values. AI mode ensures we don't miss
220 // rapid changes between poll cycles.
221 if let Response::Busy { band, busy } = notification {
222 // BY gate: squelch open → poll SM; closed → zero meter
223 if busy {
224 match radio.get_smeter(band).await {
225 Ok(level) => match band {
226 Band::A => { s_meter_a = level; busy_a = true; }
227 Band::B => { s_meter_b = level; busy_b = true; }
228 _ => {}
229 },
230 Err(e) => {
231 tracing::warn!(?band, "SM read failed on BY: {e}");
232 // Still mark busy even though SM read failed
233 match band {
234 Band::A => busy_a = true,
235 Band::B => busy_b = true,
236 _ => {}
237 }
238 }
239 }
240 } else {
241 let zero = SMeterReading::new(0).unwrap();
242 match band {
243 Band::A => { s_meter_a = zero; busy_a = false; }
244 Band::B => { s_meter_b = zero; busy_b = false; }
245 _ => {}
246 }
247 }
248 }
249 }
250 Some(cmd) = cmd_rx.recv() => {
251 match cmd {
252 crate::event::RadioCommand::ReadMemory => {
253 let tx2 = tx.clone();
254 let result = radio.read_memory_image_with_progress(move |page, total| {
255 let _ = tx2.send(Message::McpProgress { page, total });
256 }).await;
257 match result {
258 Ok(data) => {
259 let _ = tx.send(Message::McpReadComplete(data));
260 }
261 Err(e) => {
262 let _ = tx.send(Message::McpError(format!("{e}")));
263 }
264 }
265 // The TH-D75's USB stack always resets when exiting MCP
266 // programming mode. The connection is guaranteed to drop.
267 let _ = tx.send(Message::Disconnected);
268 break; // Go to reconnect
269 }
270 crate::event::RadioCommand::WriteMemory(data) => {
271 let tx2 = tx.clone();
272 let result = radio.write_memory_image_with_progress(&data, move |page, total| {
273 let _ = tx2.send(Message::McpProgress { page, total });
274 }).await;
275 match result {
276 Ok(()) => {
277 let _ = tx.send(Message::McpWriteComplete);
278 }
279 Err(e) => {
280 let _ = tx.send(Message::McpError(format!("{e}")));
281 }
282 }
283 // The TH-D75's USB stack always resets when exiting MCP
284 // programming mode. The connection is guaranteed to drop.
285 let _ = tx.send(Message::Disconnected);
286 break; // Go to reconnect
287 }
288 crate::event::RadioCommand::TuneChannel { band, channel } => {
289 if let Err(e) = radio.tune_channel(band, channel).await {
290 let _ = tx.send(Message::RadioError(format!("Tune failed: {e}")));
291 }
292 // Don't break — stay in poll loop, radio is still connected
293 }
294 crate::event::RadioCommand::FreqUp(band) => {
295 if let Err(e) = radio.frequency_up(band).await {
296 let _ = tx.send(Message::RadioError(format!("Freq up: {e}")));
297 }
298 }
299 crate::event::RadioCommand::FreqDown(band) => {
300 // DW exists as a blind step-down, but we use the manual
301 // read-subtract-tune path for precision: DW doesn't confirm
302 // the resulting frequency, and we need the exact value for
303 // the TUI display update.
304 match freq_down(radio, band).await {
305 Ok(()) => {}
306 Err(e) => {
307 let _ = tx.send(Message::RadioError(format!("Freq down: {e}")));
308 }
309 }
310 }
311 crate::event::RadioCommand::TuneFreq { band, freq } => {
312 let f = kenwood_thd75::types::Frequency::new(freq);
313 if let Err(e) = radio.tune_frequency(band, f).await {
314 let _ = tx.send(Message::RadioError(format!("Tune freq: {e}")));
315 }
316 }
317 crate::event::RadioCommand::SetSquelch { band, level } => {
318 if let Err(e) = radio.set_squelch(band, level).await {
319 let _ = tx.send(Message::RadioError(format!("Set squelch: {e}")));
320 }
321 }
322 crate::event::RadioCommand::SetAttenuator { band, enabled } => {
323 if let Err(e) = radio.set_attenuator(band, enabled).await {
324 let _ = tx.send(Message::RadioError(format!("Set atten: {e}")));
325 }
326 }
327 crate::event::RadioCommand::SetMode { band, mode } => {
328 if let Err(e) = radio.set_mode(band, mode).await {
329 let _ = tx.send(Message::RadioError(format!("Set mode: {e} (may require VFO mode)")));
330 }
331 }
332 crate::event::RadioCommand::SetLock(on) => {
333 if let Err(e) = radio.set_lock(on).await {
334 let _ = tx.send(Message::RadioError(format!("Set lock: {e}")));
335 }
336 }
337 crate::event::RadioCommand::SetDualBand(on) => {
338 if let Err(e) = radio.set_dual_band(on).await {
339 let _ = tx.send(Message::RadioError(format!("Set dual band: {e}")));
340 }
341 }
342 crate::event::RadioCommand::SetBluetooth(on) => {
343 if let Err(e) = radio.set_bluetooth(on).await {
344 let _ = tx.send(Message::RadioError(format!("Set bluetooth: {e}")));
345 }
346 }
347 crate::event::RadioCommand::SetVox(on) => {
348 if let Err(e) = radio.set_vox(on).await {
349 let _ = tx.send(Message::RadioError(format!("Set VOX: {e}")));
350 }
351 }
352 crate::event::RadioCommand::SetVoxGain(level) => {
353 if let Err(e) = radio.set_vox_gain(level).await {
354 let _ = tx.send(Message::RadioError(format!("Set VOX gain: {e}")));
355 }
356 }
357 crate::event::RadioCommand::SetVoxDelay(delay) => {
358 if let Err(e) = radio.set_vox_delay(delay).await {
359 let _ = tx.send(Message::RadioError(format!("Set VOX delay: {e}")));
360 }
361 }
362 crate::event::RadioCommand::SetPower { band, level } => {
363 if let Err(e) = radio.set_power_level(band, level).await {
364 let _ = tx.send(Message::RadioError(format!("Set power: {e}")));
365 }
366 }
367 crate::event::RadioCommand::SetStepSize { band, step } => {
368 if let Err(e) = radio.set_step_size(band, step).await {
369 let _ = tx.send(Message::RadioError(format!("Set step: {e}")));
370 }
371 }
372 crate::event::RadioCommand::SetScanResumeCat(method) => {
373 if let Err(e) = radio.set_scan_resume(method).await {
374 let _ = tx.send(Message::RadioError(format!("Scan resume: {e}")));
375 }
376 }
377 crate::event::RadioCommand::SetTncBaud(rate) => {
378 if let Err(e) = radio.set_tnc_baud(rate).await {
379 let _ = tx.send(Message::RadioError(format!("TNC baud: {e}")));
380 }
381 }
382 crate::event::RadioCommand::SetBeaconType(mode) => {
383 if let Err(e) = radio.set_beacon_type(mode).await {
384 let _ = tx.send(Message::RadioError(format!("Beacon type: {e}")));
385 }
386 }
387 crate::event::RadioCommand::SetGpsConfig(enabled, pc_output) => {
388 if let Err(e) = radio.set_gps_config(enabled, pc_output).await {
389 let _ = tx.send(Message::RadioError(format!("GPS config: {e}")));
390 }
391 }
392 crate::event::RadioCommand::SetFmRadio(enabled) => {
393 if let Err(e) = radio.set_fm_radio(enabled).await {
394 let _ = tx.send(Message::RadioError(format!("FM radio: {e}")));
395 }
396 }
397 crate::event::RadioCommand::SetCallsignSlot(slot) => {
398 if let Err(e) = radio.set_active_callsign_slot(slot).await {
399 let _ = tx.send(Message::RadioError(format!("Callsign slot: {e}")));
400 }
401 }
402 crate::event::RadioCommand::SetDstarSlot(slot) => {
403 if let Err(e) = radio.set_dstar_slot(slot).await {
404 let _ = tx.send(Message::RadioError(format!("D-STAR slot: {e}")));
405 }
406 }
407 crate::event::RadioCommand::McpWriteByte { offset, value } => {
408 // Single-page MCP write for settings the D75 rejects via CAT.
409 // Enters MCP mode, modifies one byte, exits. USB/BT drops.
410 let page = offset / 256;
411 let byte_idx = (offset % 256) as usize;
412 let _ = tx.send(Message::RadioError(format!("Writing MCP 0x{offset:04X}...")));
413 match radio.modify_memory_page(page, |data| {
414 data[byte_idx] = value;
415 }).await {
416 Ok(()) => {
417 // Update the in-memory MCP cache so the TUI
418 // stays in sync without requiring a full re-read.
419 let _ = tx.send(Message::McpByteWritten { offset, value });
420 let _ = tx.send(Message::RadioError(format!("MCP 0x{offset:04X} = {value} — reconnecting...")));
421 }
422 Err(e) => {
423 let _ = tx.send(Message::McpError(format!("MCP write 0x{offset:04X}: {e}")));
424 }
425 }
426 // USB/BT drops after MCP exit
427 let _ = tx.send(Message::Disconnected);
428 break; // Go to reconnect loop
429 }
430 crate::event::RadioCommand::SetUrcall { callsign, suffix } => {
431 if let Err(e) = radio.set_urcall(&callsign, &suffix).await {
432 let _ = tx.send(Message::RadioError(format!("Set URCALL: {e}")));
433 }
434 }
435 crate::event::RadioCommand::ConnectReflector { name, module } => {
436 if let Err(e) = radio.connect_reflector(&name, module).await {
437 let _ = tx.send(Message::RadioError(format!("Connect reflector: {e}")));
438 }
439 }
440 crate::event::RadioCommand::DisconnectReflector => {
441 if let Err(e) = radio.disconnect_reflector().await {
442 let _ = tx.send(Message::RadioError(format!("Disconnect reflector: {e}")));
443 }
444 }
445 crate::event::RadioCommand::SetCQ => {
446 if let Err(e) = radio.set_cq().await {
447 let _ = tx.send(Message::RadioError(format!("Set CQ: {e}")));
448 }
449 }
450 crate::event::RadioCommand::EnterDStar { config } => {
451 dstar_pending = Some(config);
452 continue 'outer;
453 }
454 crate::event::RadioCommand::ExitDStar => {
455 let _ = tx.send(Message::RadioError(
456 "Not in D-STAR gateway mode".into()
457 ));
458 }
459 crate::event::RadioCommand::EnterAprs { config } => {
460 // Store the config and break out of the inner loop.
461 // The APRS session runs at the top of 'outer where
462 // we can take() the radio from the Option.
463 aprs_pending = Some(config);
464 continue 'outer;
465 }
466 crate::event::RadioCommand::ExitAprs
467 | crate::event::RadioCommand::SendAprsMessage { .. }
468 | crate::event::RadioCommand::BeaconPosition { .. } => {
469 // These are only valid in APRS mode; ignore in CAT mode.
470 let _ = tx.send(Message::RadioError(
471 "Not in APRS mode".into()
472 ));
473 }
474 }
475 }
476 }
477 }
478 } // if radio_opt.is_some()
479
480 // Reconnect loop.
481 //
482 // After MCP programming mode, the D75's USB stack resets and the
483 // device may re-enumerate with a different path (e.g., the
484 // usbmodem suffix changes on macOS). We first try the original
485 // path, then auto-discover USB. If both fail (e.g., BT connection),
486 // we request the main thread to open a new BT transport (IOBluetooth
487 // RFCOMM must be opened on the main thread for CFRunLoop callbacks).
488 // Close the old transport BEFORE opening a new connection. Critical
489 // for Bluetooth: do_rfcomm_open() calls [device closeConnection] on
490 // the shared IOBluetoothDevice, which would corrupt the old
491 // RfcommContext's channel pointer if it's still alive.
492 if let Some(ref mut r) = radio_opt
493 && let Err(e) = r.close_transport().await
494 {
495 tracing::warn!("failed to close transport before reconnect: {e}");
496 }
497 drop(radio_opt.take()); // Ensure old radio is dropped before reconnect.
498 tokio::time::sleep(Duration::from_secs(3)).await;
499 let mut attempts = 0u32;
500 loop {
501 attempts += 1;
502 let _ = tx.send(Message::RadioError(format!(
503 "Reconnect attempt {attempts}..."
504 )));
505
506 // On macOS, native IOBluetooth RFCOMM must be opened on the
507 // main thread (CFRunLoop). On Linux/Windows, BT SPP serial
508 // ports can be opened directly from any thread.
509 #[cfg(target_os = "macos")]
510 let skip_direct = SerialTransport::is_bluetooth_port(&path_clone);
511 #[cfg(not(target_os = "macos"))]
512 let skip_direct = false;
513
514 let connect_result = if skip_direct {
515 Err("BT requires main thread".to_string())
516 } else {
517 discover_and_open(Some(&path_clone), baud)
518 .or_else(|_| discover_and_open(None, baud))
519 }
520 .or_else(|_| {
521 bt_req_tx
522 .send((Some(path_clone.clone()), baud))
523 .map_err(|e| e.to_string())?;
524 bt_resp_rx
525 .recv_timeout(Duration::from_secs(10))
526 .map_err(|e| e.to_string())?
527 });
528 if let Ok((_p, transport)) = connect_result {
529 match Radio::connect(transport).await {
530 Ok(mut new_radio) => match new_radio.identify().await {
531 Ok(_) => {
532 if let Err(e) = new_radio.set_auto_info(true).await {
533 tracing::error!("AI mode failed after reconnect: {e}");
534 let _ = tx.send(Message::RadioError(format!(
535 "AI mode failed: {e} — S-meter may not update"
536 )));
537 }
538 notifications = new_radio.subscribe();
539 s_meter_a = SMeterReading::new(0).unwrap();
540 s_meter_b = SMeterReading::new(0).unwrap();
541 busy_a = false;
542 busy_b = false;
543 let _ = tx.send(Message::Reconnected);
544 radio_opt = Some(new_radio);
545 continue 'outer;
546 }
547 Err(e) => {
548 let _ = tx.send(Message::RadioError(format!(
549 "Radio found but not responding: {e}"
550 )));
551 }
552 },
553 Err(e) => {
554 let _ = tx.send(Message::RadioError(format!(
555 "Transport opened but handshake failed: {e}"
556 )));
557 }
558 }
559 }
560 // Exponential backoff: 1s, 2s, 3s, ... up to 10s
561 let delay = RECONNECT_INTERVAL * attempts.min(10);
562 tokio::time::sleep(delay).await;
563 }
564 }
565 });
566
567 Ok(path)
568}
569
570/// Distinguishes transport errors (connection lost) from protocol errors
571/// (parse failures). Transport errors break out of the poll loop to the
572/// reconnect path. Protocol errors are non-fatal — the current poll cycle
573/// is skipped but the connection stays alive.
574enum PollError {
575 Transport(String),
576 Protocol(String),
577}
578
579fn classify_error(context: &str, e: &kenwood_thd75::Error) -> PollError {
580 use kenwood_thd75::Error;
581 match e {
582 Error::Transport(_) | Error::Timeout(_) => PollError::Transport(format!("{context}: {e}")),
583 _ => PollError::Protocol(format!("{context}: {e}")),
584 }
585}
586
587/// Read a global setting, propagating transport errors to trigger reconnect.
588/// Protocol/parse errors default to the provided fallback (non-fatal).
589macro_rules! global_read {
590 ($radio:expr, $cmd:expr, $call:expr, $default:expr) => {
591 match $call.await {
592 Ok(v) => v,
593 Err(e) => match classify_error($cmd, &e) {
594 PollError::Transport(msg) => return Err(PollError::Transport(msg)),
595 PollError::Protocol(msg) => {
596 tracing::debug!(cmd = $cmd, error = %msg, "protocol error, using default");
597 $default
598 },
599 },
600 }
601 };
602}
603
604#[allow(clippy::cognitive_complexity)]
605async fn poll_once(
606 radio: &mut Radio<EitherTransport>,
607 s_meter_a: SMeterReading,
608 s_meter_b: SMeterReading,
609 busy_a: bool,
610 busy_b: bool,
611) -> Result<RadioState, PollError> {
612 let mut band_a = poll_band(radio, Band::A).await?;
613 let mut band_b = poll_band(radio, Band::B).await?;
614
615 // S-meter and busy are driven by AI-pushed BY notifications (not polling).
616 // This avoids spurious firmware spikes on Band B.
617 band_a.s_meter = s_meter_a;
618 band_a.busy = busy_a;
619 band_b.s_meter = s_meter_b;
620 band_b.busy = busy_b;
621
622 // Global state reads. Transport errors trigger reconnect (a USB unplug
623 // mid-poll should not show fake defaults for a full cycle). Protocol
624 // parse errors are non-fatal and default to safe values.
625 let battery_level = global_read!(
626 radio,
627 "BL",
628 radio.get_battery_level(),
629 kenwood_thd75::types::BatteryLevel::Empty
630 );
631 // BE (beep) is a firmware stub on D75 — always returns N.
632 // Beep state is read from MCP image instead. Skip polling.
633 let beep = false;
634 let lock = global_read!(radio, "LC", radio.get_lock(), false);
635 let dual_band = global_read!(radio, "DL", radio.get_dual_band(), false);
636 let bluetooth = global_read!(radio, "BT", radio.get_bluetooth(), false);
637 let vox = global_read!(radio, "VX", radio.get_vox(), false);
638 let vox_gain = global_read!(
639 radio,
640 "VG",
641 radio.get_vox_gain(),
642 kenwood_thd75::types::VoxGain::new(0).unwrap()
643 );
644 let vox_delay = global_read!(
645 radio,
646 "VD",
647 radio.get_vox_delay(),
648 kenwood_thd75::types::VoxDelay::new(0).unwrap()
649 );
650 let af_gain = global_read!(
651 radio,
652 "AG",
653 radio.get_af_gain(),
654 kenwood_thd75::types::AfGainLevel::new(0)
655 );
656 let gps = radio.get_gps_config().await.unwrap_or((false, false));
657 let gps_sentences = radio.get_gps_sentences().await.ok();
658 let gps_mode = radio.get_gps_mode().await.ok();
659 let beacon_type = global_read!(
660 radio,
661 "BN",
662 radio.get_beacon_type(),
663 kenwood_thd75::types::BeaconMode::Off
664 );
665 // FS read (no band parameter) — returns N in some modes
666 let fine_step = radio.get_fine_step().await.ok();
667 // SH read per mode — returns N in some modes
668 let filter_width_ssb = radio
669 .get_filter_width(kenwood_thd75::types::FilterMode::Ssb)
670 .await
671 .ok();
672 let filter_width_cw = radio
673 .get_filter_width(kenwood_thd75::types::FilterMode::Cw)
674 .await
675 .ok();
676 let filter_width_am = radio
677 .get_filter_width(kenwood_thd75::types::FilterMode::Am)
678 .await
679 .ok();
680 // D-STAR reads — non-fatal, default to empty/None on error
681 let dstar_urcall = radio.get_urcall().await.unwrap_or_default();
682 let dstar_rpt1 = radio.get_rpt1().await.unwrap_or_default();
683 let dstar_rpt2 = radio.get_rpt2().await.unwrap_or_default();
684 let dstar_gw = radio.get_gateway().await.ok();
685 let dstar_slot = radio.get_dstar_slot().await.ok();
686 let dstar_callsign_slot = radio.get_active_callsign_slot().await.ok();
687 Ok(RadioState {
688 band_a,
689 band_b,
690 battery_level,
691 beep,
692 lock,
693 dual_band,
694 bluetooth,
695 vox,
696 vox_gain,
697 vox_delay,
698 af_gain,
699 firmware_version: String::new(),
700 radio_type: String::new(),
701 gps_enabled: gps.0,
702 gps_pc_output: gps.1,
703 gps_sentences,
704 gps_mode,
705 beacon_type,
706 fine_step,
707 filter_width_ssb,
708 filter_width_cw,
709 filter_width_am,
710 scan_resume_cat: None, // Write-only on D75 — not readable
711 // D-STAR state — non-fatal reads (protocol errors use defaults)
712 dstar_urcall: dstar_urcall.0,
713 dstar_urcall_suffix: dstar_urcall.1,
714 dstar_rpt1: dstar_rpt1.0,
715 dstar_rpt1_suffix: dstar_rpt1.1,
716 dstar_rpt2: dstar_rpt2.0,
717 dstar_rpt2_suffix: dstar_rpt2.1,
718 dstar_gateway_mode: dstar_gw,
719 dstar_slot,
720 dstar_callsign_slot,
721 })
722}
723
724/// Poll per-band state: frequency, squelch setting, mode, power, attenuator, step size.
725///
726/// **SM and BY are intentionally omitted.** The D75 firmware returns spurious
727/// SM=5 / BY=1 spikes when Band B is polled directly. Instead, S-meter and
728/// busy state are driven by AI-pushed BY notifications in the main loop,
729/// which go through the radio's internal squelch debouncing and match the
730/// radio's own display behavior. The `s_meter` and `busy` fields are set to
731/// zero here and overwritten by `poll_once` with the AI-driven values.
732async fn poll_band(radio: &mut Radio<EitherTransport>, band: Band) -> Result<BandState, PollError> {
733 let channel = radio
734 .get_frequency(band)
735 .await
736 .map_err(|e| classify_error(&format!("FQ {band:?}"), &e))?;
737
738 // SM and BY are NOT polled — they are driven by AI-pushed BY notifications.
739 // Polling SM/BY causes spurious readings on Band B due to firmware behavior.
740 // The AI push path goes through the radio's internal squelch debouncing.
741
742 let squelch = radio
743 .get_squelch(band)
744 .await
745 .map_err(|e| classify_error(&format!("SQ {band:?}"), &e))?;
746
747 let mode = radio
748 .get_mode(band)
749 .await
750 .map_err(|e| classify_error(&format!("MD {band:?}"), &e))?;
751
752 let power_level = radio
753 .get_power_level(band)
754 .await
755 .map_err(|e| classify_error(&format!("PC {band:?}"), &e))?;
756
757 let attenuator = radio.get_attenuator(band).await.unwrap_or(false);
758 // SF returns N (not available) in some modes — gracefully default
759 let step_size = radio.get_step_size(band).await.ok().map(|(_, s)| s);
760
761 Ok(BandState {
762 frequency: channel.rx_frequency,
763 mode,
764 s_meter: SMeterReading::new(0).unwrap(), // Set by AI notification handler
765 squelch,
766 power_level,
767 busy: false, // Set by AI notification handler
768 attenuator,
769 step_size,
770 })
771}
772
773/// Step frequency down by reading current freq + step size, subtracting, and tuning.
774/// DW (frequency down) exists but does not echo the resulting frequency, so we
775/// compute it manually for accurate TUI display.
776async fn freq_down(
777 radio: &mut Radio<EitherTransport>,
778 band: Band,
779) -> Result<(), kenwood_thd75::Error> {
780 let ch = radio.get_frequency(band).await?;
781 // SF may return N (not available) in some modes — default to 5 kHz
782 let step = radio
783 .get_step_size(band)
784 .await
785 .map(|(_, s)| s)
786 .unwrap_or(kenwood_thd75::types::StepSize::Hz5000);
787 let new_hz = ch.rx_frequency.as_hz().saturating_sub(step.as_hz());
788 radio
789 .tune_frequency(band, kenwood_thd75::types::Frequency::new(new_hz))
790 .await
791}
792
793/// Error type for the APRS session helper — the radio is lost, reconnect required.
794enum EnterAprsError {
795 /// KISS exit failed or the session ended with a transport error.
796 KissExitFailed(String),
797}
798
799/// Enter APRS mode, run the event loop, and return the radio on clean exit.
800///
801/// Takes ownership of the `Radio` (consumed by `AprsClient::start`), runs the
802/// APRS event loop, and returns the `Radio` after `AprsClient::stop`.
803async fn enter_aprs_session(
804 radio: Radio<EitherTransport>,
805 config: kenwood_thd75::AprsClientConfig,
806 tx: &mpsc::UnboundedSender<Message>,
807 cmd_rx: &mut mpsc::UnboundedReceiver<crate::event::RadioCommand>,
808) -> Result<Radio<EitherTransport>, EnterAprsError> {
809 let mut client = match kenwood_thd75::AprsClient::start(radio, config).await {
810 Ok(c) => c,
811 Err((_radio, e)) => {
812 return Err(EnterAprsError::KissExitFailed(format!(
813 "KISS entry failed: {e}"
814 )));
815 }
816 };
817
818 let _ = tx.send(Message::AprsStarted);
819
820 let exit_result = run_aprs_loop(&mut client, tx, cmd_rx).await;
821
822 match client.stop().await {
823 Ok(new_radio) => {
824 if let Err(msg) = exit_result {
825 let _ = tx.send(Message::AprsError(msg));
826 }
827 Ok(new_radio)
828 }
829 Err(e) => Err(EnterAprsError::KissExitFailed(format!(
830 "KISS exit failed: {e}"
831 ))),
832 }
833}
834
835/// Run the APRS event loop until `ExitAprs` is received or a transport error occurs.
836///
837/// Returns `Ok(())` on clean exit, `Err(msg)` on transport failure.
838async fn run_aprs_loop(
839 client: &mut kenwood_thd75::AprsClient<EitherTransport>,
840 tx: &mpsc::UnboundedSender<Message>,
841 cmd_rx: &mut mpsc::UnboundedReceiver<crate::event::RadioCommand>,
842) -> Result<(), String> {
843 loop {
844 tokio::select! {
845 event_result = client.next_event() => {
846 match event_result {
847 Ok(Some(event)) => {
848 if tx.send(Message::AprsEvent(event)).is_err() {
849 return Ok(()); // TUI closed
850 }
851 }
852 Ok(None) | Err(kenwood_thd75::Error::Timeout(_)) => {
853 // Timeout — no activity, loop again.
854 }
855 Err(e) => {
856 return Err(format!("APRS transport error: {e}"));
857 }
858 }
859 }
860 Some(cmd) = cmd_rx.recv() => {
861 match cmd {
862 crate::event::RadioCommand::ExitAprs => {
863 return Ok(());
864 }
865 crate::event::RadioCommand::SendAprsMessage { addressee, text } => {
866 match client.send_message(&addressee, &text).await {
867 Ok(message_id) => {
868 let _ = tx.send(Message::AprsMessageSent {
869 addressee,
870 text,
871 message_id,
872 });
873 }
874 Err(e) => {
875 let _ = tx.send(Message::AprsError(
876 format!("Send message failed: {e}")
877 ));
878 }
879 }
880 }
881 crate::event::RadioCommand::BeaconPosition { lat, lon, comment } => {
882 if let Err(e) = client.beacon_position(lat, lon, &comment).await {
883 let _ = tx.send(Message::AprsError(
884 format!("Beacon failed: {e}")
885 ));
886 }
887 }
888 _ => {
889 // CAT commands are not valid in APRS mode.
890 let _ = tx.send(Message::RadioError(
891 "CAT commands unavailable in APRS mode".into()
892 ));
893 }
894 }
895 }
896 }
897 }
898}
899
900/// Error type for the D-STAR gateway session — the radio is lost, reconnect required.
901enum EnterDStarError {
902 /// MMDVM exit failed or the session ended with a transport error.
903 MmdvmExitFailed(String),
904}
905
906/// Enter D-STAR gateway mode, run the event loop, and return the radio on clean exit.
907///
908/// Takes ownership of the `Radio` (consumed by `DStarGateway::start`), runs the
909/// D-STAR event loop, and returns the `Radio` after `DStarGateway::stop`.
910async fn enter_dstar_session(
911 radio: Radio<EitherTransport>,
912 config: kenwood_thd75::DStarGatewayConfig,
913 tx: &mpsc::UnboundedSender<Message>,
914 cmd_rx: &mut mpsc::UnboundedReceiver<crate::event::RadioCommand>,
915) -> Result<Radio<EitherTransport>, EnterDStarError> {
916 let mut gateway = match kenwood_thd75::DStarGateway::start(radio, config).await {
917 Ok(g) => g,
918 Err((_radio, e)) => {
919 return Err(EnterDStarError::MmdvmExitFailed(format!(
920 "D-STAR gateway start failed: {e}"
921 )));
922 }
923 };
924
925 let _ = tx.send(Message::DStarStarted);
926
927 let exit_result = run_dstar_loop(&mut gateway, tx, cmd_rx).await;
928
929 match gateway.stop().await {
930 Ok(new_radio) => {
931 if let Err(msg) = exit_result {
932 let _ = tx.send(Message::DStarError(msg));
933 }
934 Ok(new_radio)
935 }
936 Err(e) => Err(EnterDStarError::MmdvmExitFailed(format!(
937 "D-STAR gateway stop failed: {e}"
938 ))),
939 }
940}
941
942/// Run the D-STAR gateway event loop until `ExitDStar` is received or a transport error occurs.
943async fn run_dstar_loop(
944 gateway: &mut kenwood_thd75::DStarGateway<EitherTransport>,
945 tx: &mpsc::UnboundedSender<Message>,
946 cmd_rx: &mut mpsc::UnboundedReceiver<crate::event::RadioCommand>,
947) -> Result<(), String> {
948 loop {
949 tokio::select! {
950 event_result = gateway.next_event() => {
951 match event_result {
952 Ok(Some(event)) => {
953 if tx.send(Message::DStarEvent(event)).is_err() {
954 return Ok(()); // TUI closed
955 }
956 }
957 Ok(None) | Err(kenwood_thd75::Error::Timeout(_)) => {
958 // Timeout — no activity, loop again.
959 }
960 Err(e) => {
961 return Err(format!("D-STAR transport error: {e}"));
962 }
963 }
964 }
965 Some(cmd) = cmd_rx.recv() => {
966 match cmd {
967 crate::event::RadioCommand::ExitDStar => {
968 return Ok(());
969 }
970 _ => {
971 // CAT commands are not valid in D-STAR gateway mode.
972 let _ = tx.send(Message::RadioError(
973 "CAT commands unavailable in D-STAR gateway mode".into()
974 ));
975 }
976 }
977 }
978 }
979 }
980}
981
982fn discover_and_open(port: Option<&str>, baud: u32) -> Result<(String, EitherTransport), String> {
983 // Explicit port
984 if let Some(path) = port
985 && path != "auto"
986 {
987 if SerialTransport::is_bluetooth_port(path) {
988 // Use native IOBluetooth RFCOMM for BT (bypasses broken serial driver)
989 #[cfg(target_os = "macos")]
990 {
991 let bt = kenwood_thd75::BluetoothTransport::open(None)
992 .map_err(|e| format!("BT connect failed: {e}"))?;
993 return Ok((path.to_string(), EitherTransport::Bluetooth(bt)));
994 }
995 #[cfg(not(target_os = "macos"))]
996 {
997 let transport = SerialTransport::open(path, baud)
998 .map_err(|e| format!("Failed to open {path}: {e}"))?;
999 return Ok((path.to_string(), EitherTransport::Serial(transport)));
1000 }
1001 }
1002 let transport =
1003 SerialTransport::open(path, baud).map_err(|e| format!("Failed to open {path}: {e}"))?;
1004 return Ok((path.to_string(), EitherTransport::Serial(transport)));
1005 }
1006
1007 // Auto-discover: try USB first
1008 let usb_ports =
1009 SerialTransport::discover_usb().map_err(|e| format!("USB discovery failed: {e}"))?;
1010
1011 if let Some(info) = usb_ports.first() {
1012 let path = info.port_name.clone();
1013 let transport = SerialTransport::open(&path, baud)
1014 .map_err(|e| format!("Failed to open {path}: {e}"))?;
1015 return Ok((path, EitherTransport::Serial(transport)));
1016 }
1017
1018 // No USB — try Bluetooth.
1019 // macOS: use native IOBluetooth RFCOMM (the serial driver drops bytes).
1020 // Linux/Windows: discover BT SPP serial ports (rfcomm*, COM with "bluetooth").
1021 #[cfg(target_os = "macos")]
1022 {
1023 if let Ok(bt) = kenwood_thd75::BluetoothTransport::open(None) {
1024 return Ok(("bluetooth:TH-D75".into(), EitherTransport::Bluetooth(bt)));
1025 }
1026 }
1027
1028 let bt_ports =
1029 SerialTransport::discover_bluetooth().map_err(|e| format!("BT discovery failed: {e}"))?;
1030 if let Some(info) = bt_ports.first() {
1031 let path = info.port_name.clone();
1032 let transport = SerialTransport::open(&path, baud)
1033 .map_err(|e| format!("Failed to open BT port {path}: {e}"))?;
1034 return Ok((path, EitherTransport::Serial(transport)));
1035 }
1036
1037 Err("No TH-D75 found on USB or Bluetooth".to_string())
1038}