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}