From a9bc6967f769df5ff1f62e8d9e3b8d747bac7c22 Mon Sep 17 00:00:00 2001 From: ww8l Date: Tue, 13 Oct 2026 10:48:34 -0600 Subject: [PATCH] Add Fusion VW as a channel mode (#141) YSF stays Fusion DN; the new YSF-VW mode is Fusion's full-rate voice mode. - Mode list and zone order gain YSF-VW; export sends it only to Fusion radios (an unknown mode would have fallen through to analog). - FTM-300 writes VW as byte 11 = 0x60. Measured on the one VW channel in Tim's backup and confirmed on the radio: an app-written card showed VW for the 0x60 memory and DN for the 0x40 one. The gate no longer masks VW. - FT5D writes VW as digmode 3 (CHIRP's ft1d map; not radio-checked). - CSV import maps VW / YSF-VW to YSF-VW instead of folding it into DN. - A RepeaterBook refresh keeps an operator's YSF-VW while RepeaterBook still lists the machine as Fusion; RepeaterBook cannot tell DN from VW. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/csv_map.rs | 16 ++++- src-tauri/src/commands/export.rs | 20 +++++- src-tauri/src/commands/import.rs | 65 ++++++++++++++++++- src-tauri/src/radios/yaesu_ft5d/sd_image.rs | 25 ++++++- src-tauri/src/radios/yaesu_ftm300/gate.rs | 12 ++-- src-tauri/src/radios/yaesu_ftm300/sd_image.rs | 18 ++++- src/lib/constants.ts | 3 +- 7 files changed, 141 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/commands/csv_map.rs b/src-tauri/src/commands/csv_map.rs index e100948..f851d32 100644 --- a/src-tauri/src/commands/csv_map.rs +++ b/src-tauri/src/commands/csv_map.rs @@ -889,7 +889,7 @@ pub(crate) fn parse_mapped_csv( // both DMR and YSF, and inventing a second capability from a stray // column would put a channel on the radio in the wrong mode. dstar_capable: mode == "DSTAR", - ysf_capable: mode == "YSF", + ysf_capable: mode == "YSF" || mode == "YSF-VW", nxdn_capable: mode == "NXDN", p25_capable: mode == "P25", p25_nac: (mode == "P25").then(|| cell(&rec, "p25_nac")).flatten(), @@ -1122,7 +1122,8 @@ fn parse_timeslot(v: &str) -> Option { /// /// The aliases are the spellings other tools use: CHIRP writes `NFM` for narrow /// FM (a bandwidth, not a different mode here), `DV` for D-STAR and `DN` for -/// Fusion's digital-narrow. +/// Fusion's digital-narrow; `VW` is Fusion's full-rate voice mode, as Yaesu +/// and RT Systems name it. fn norm_mode(v: &str) -> Option<&'static str> { Some(match normalize(v).as_str() { "fm" | "nfm" | "fmn" | "wfm" | "analog" | "analogue" => "FM", @@ -1133,6 +1134,7 @@ fn norm_mode(v: &str) -> Option<&'static str> { "dmr" | "dmrtier2" => "DMR", "dstar" | "dv" | "dstardv" => "DSTAR", "ysf" | "fusion" | "systemfusion" | "c4fm" | "dn" => "YSF", + "ysfvw" | "c4fmvw" | "fusionvw" | "vw" => "YSF-VW", "nxdn" => "NXDN", "p25" | "p25phase1" | "p25phase2" => "P25", "m17" => "M17", @@ -1854,4 +1856,14 @@ mod tests { } } } + + /// #141: Fusion's two voice modes stay distinct, including this app's own + /// `YSF-VW` coming back in through a library CSV. + #[test] + fn fusion_vw_is_not_folded_into_dn() { + assert_eq!(norm_mode("DN"), Some("YSF")); + assert_eq!(norm_mode("YSF"), Some("YSF")); + assert_eq!(norm_mode("VW"), Some("YSF-VW")); + assert_eq!(norm_mode("YSF-VW"), Some("YSF-VW")); + } } diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index 4d69abb..c3aa988 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -410,7 +410,8 @@ pub(crate) fn channel_fit(channel: &Channel, model: &RadioModel) -> ChannelFit { "FM" | "NFM" | "AM" | "USB" | "LSB" | "CW" => model.analog_capable, "DMR" => model.dmr_capable, "DSTAR" => model.dstar_capable, - "YSF" => model.ysf_capable, + // YSF is Fusion digital-narrow (DN); YSF-VW is its full-rate voice mode. + "YSF" | "YSF-VW" => model.ysf_capable, "NXDN" => model.nxdn_capable, "P25" => model.p25_capable, "M17" => model.m17_capable, @@ -2691,6 +2692,23 @@ mod tests { assert!(exclusion_reason(&dmr, &model).unwrap().contains("Mode DMR")); } + /// Issue #141: VW is a Fusion mode, so it goes where YSF goes — onto a + /// Fusion radio, and never onto an analog-only one as plain FM (an unknown + /// mode falls through to `analog_capable`). + #[test] + fn fusion_vw_follows_ysf() { + let vw = Channel { + mode: Some("YSF-VW".into()), + ..fm_at(146.940) + }; + assert_eq!(channel_fit(&vw, &wideband_rx_model()), ChannelFit::Included); + let analog = RadioModel { + ysf_capable: false, + ..wideband_rx_model() + }; + assert!(exclusion_reason(&vw, &analog).unwrap().contains("Mode YSF-VW")); + } + /// Reference data that will not parse must never quietly empty a codeplug: /// a broken `tx_bands` falls back to the contiguous span it replaced. #[test] diff --git a/src-tauri/src/commands/import.rs b/src-tauri/src/commands/import.rs index e4d97a7..25d5bb1 100644 --- a/src-tauri/src/commands/import.rs +++ b/src-tauri/src/commands/import.rs @@ -157,6 +157,7 @@ pub(crate) fn build_preview(parsed: &[ParsedChannel]) -> ImportPreview { #[derive(sqlx::FromRow)] struct ExistingChannel { id: i64, + mode: Option, ctcss_uplink: Option, ctcss_downlink: Option, operational_status: Option, @@ -234,7 +235,7 @@ pub(crate) async fn insert_parsed_tx( // custom names preserved) rather than skipped, so re-importing a fresh // export corrects stale data — e.g. a mode wrongly flagged YSF. let existing: Option = sqlx::query_as( - "SELECT id, ctcss_uplink, ctcss_downlink, operational_status, notes, \ + "SELECT id, mode, ctcss_uplink, ctcss_downlink, operational_status, notes, \ ctcss_uplink_overridden, ctcss_downlink_overridden, \ operational_status_overridden, notes_overridden, \ tone_mode, cross_mode, dcs_code, dcs_rx_code, \ @@ -344,7 +345,9 @@ pub(crate) async fn insert_parsed_tx( /// Merge policy: /// * RB-authoritative technical facts (mode + every digital-mode flag, /// tx_freq/offset/duplex/band, city/county/country) are refreshed from RB — -/// this is what corrects a stale/mis-flagged mode. +/// this is what corrects a stale/mis-flagged mode. The one exception is +/// Fusion's VW: RepeaterBook can only say "Fusion", so an operator's +/// YSF-VW survives a refresh that still says YSF (#141). /// * The four user-overridable fields (uplink/downlink tone, operational /// status, notes) keep the user's value when overridden; otherwise they /// adopt the fresh RB value. Their `rb_*` snapshots always advance and the @@ -402,6 +405,14 @@ async fn merge_existing( // against the snapshot is the same test the tracked tone fields use. A row // predating migration 0019 has NULL snapshots and so reads as the // operator's, which preserves whatever it holds. + // RepeaterBook's Fusion flag cannot tell DN from VW; an operator who chose + // VW keeps it while the machine is still listed as Fusion. + let mode = if p.mode == "YSF" && ex.mode.as_deref() == Some("YSF-VW") { + "YSF-VW" + } else { + p.mode.as_str() + }; + let dcs_curated = ex.dcs_code != ex.rb_dcs_code || ex.dcs_rx_code != ex.rb_dcs_rx_code; // An export whose tone columns can express DCS describes the machine's @@ -478,7 +489,7 @@ async fn merge_existing( .bind(p.offset) .bind(&p.duplex) .bind(&p.band) - .bind(&p.mode) + .bind(mode) .bind(&tone_mode) .bind(&cross_mode) .bind(ctcss_uplink) @@ -2863,6 +2874,54 @@ mod tests { assert_eq!(count.0, 1); } + /// #141: RepeaterBook says only "Fusion", so a refresh must not turn an + /// operator's VW back into DN — but a machine that drops Fusion still + /// leaves it. + #[tokio::test] + async fn reimport_keeps_fusion_vw_while_rb_still_says_fusion() { + let dir = std::env::temp_dir().join(format!("cpm_vw_{}", std::process::id())); + let db_path = dir.join("test.sqlite3"); + let _ = std::fs::remove_file(&db_path); + let pool = crate::db::init_pool(&db_path).await.expect("init_pool"); + + let rb_id = "N2SKY|448.4000|CO|FORT COLLINS"; + sqlx::query( + "INSERT INTO channels (rb_name, callsign, rx_freq, tx_freq, mode, ysf_capable, \ + source, repeaterbook_id, state, city) \ + VALUES ('N2SKY Fort Collins', 'N2SKY', 448.4, 443.4, 'YSF-VW', 1, \ + 'repeaterbook', ?1, 'CO', 'Fort Collins')", + ) + .bind(rb_id) + .execute(&pool) + .await + .expect("seed"); + + let reimport = |ysf: &str| { + let json = format!( + r#"{{"records":[{{"freq_mhz":"448.400","input_freq":"443.400", + "callsign":"N2SKY","state":"CO","city":"Fort Collins","ysf":"{ysf}"}}]}}"# + ); + let jpath = dir.join(format!("vw_{ysf}.json")); + std::fs::write(&jpath, json).expect("write json"); + parse_repeaterbook_json(jpath.to_str().unwrap()).expect("parse") + }; + let mode = || async { + sqlx::query_scalar::<_, String>("SELECT mode FROM channels WHERE repeaterbook_id = ?1") + .bind(rb_id) + .fetch_one(&pool) + .await + .expect("row") + }; + + let still_fusion = reimport("Yes"); + assert_eq!(still_fusion[0].mode, "YSF"); + insert_parsed(&pool, &still_fusion).await.expect("merge"); + assert_eq!(mode().await, "YSF-VW"); + + insert_parsed(&pool, &reimport("No")).await.expect("merge"); + assert_eq!(mode().await, "FM"); + } + #[tokio::test] async fn same_call_freq_state_different_city_import_as_two_rows() { // N2SKY 448.400 exists in both Fort Collins and Buena Vista, CO. They diff --git a/src-tauri/src/radios/yaesu_ft5d/sd_image.rs b/src-tauri/src/radios/yaesu_ft5d/sd_image.rs index 5230228..407bfd3 100644 --- a/src-tauri/src/radios/yaesu_ft5d/sd_image.rs +++ b/src-tauri/src/radios/yaesu_ft5d/sd_image.rs @@ -274,9 +274,13 @@ fn encode_record(ec: &ExpandedChannel, name: &str) -> [u8; MEM_STRIDE] { _ => 3, // High, 5 W }; // 0 Analog / 1 AMS / 2 DN / 3 VW. A YSF channel is programmed as DN - // (the C4FM mode the FT5D uses for repeaters); analog stays 0. - let digmode = if c.mode.as_deref().map(|m| m.eq_ignore_ascii_case("YSF")) == Some(true) { - 2u8 + // (the C4FM mode the FT5D uses for repeaters), YSF-VW as VW; analog stays + // 0. VW = 3 is CHIRP's ft1d map: none of the backups holds a VW channel. + let is = |m: &str| c.mode.as_deref().map(|v| v.eq_ignore_ascii_case(m)) == Some(true); + let digmode: u8 = if is("YSF") { + 2 + } else if is("YSF-VW") { + 3 } else { 0 }; @@ -682,6 +686,21 @@ mod tests { assert_eq!(img[FLAG_BASE], 0b0000_0011, "valid + used, sub-VFO allowed"); } + /// DN and VW pin the digital mode, so both turn AMS auto-detect off; + /// analog keeps it on. (#141) + #[test] + fn encodes_the_fusion_modes() { + let digmode_and_automode = |mode: &str| { + let mut c = chan(1, "C4FM", 438.55); + c.mode = Some(mode.into()); + let r = encode_record(&expanded(c), "C4FM"); + ((r[5] >> 4) & 3, r[0x1F] & 0x08) + }; + assert_eq!(digmode_and_automode("FM"), (0, 0x08)); + assert_eq!(digmode_and_automode("YSF"), (2, 0)); + assert_eq!(digmode_and_automode("YSF-VW"), (3, 0)); + } + /// Issue #32: channels the FT5D can only listen on now reach the encoder, /// and they are ordinary memories — the radio polices its own TX bands, and /// a radio modified to transmit out of band needs the real TX frequency to diff --git a/src-tauri/src/radios/yaesu_ftm300/gate.rs b/src-tauri/src/radios/yaesu_ftm300/gate.rs index 10bf2e7..cf7bb51 100644 --- a/src-tauri/src/radios/yaesu_ftm300/gate.rs +++ b/src-tauri/src/radios/yaesu_ftm300/gate.rs @@ -13,9 +13,9 @@ //! ``` //! //! Three per-channel bits have no column in the channel database, so they are -//! masked and COUNTED rather than compared: skip, M-GRP and clock shift. VW -//! mode is the fourth (the database's YSF becomes DN). Everything else must -//! match byte for byte, names and the name-length table included. +//! masked and COUNTED rather than compared: skip, M-GRP and clock shift. +//! Everything else must match byte for byte, names and the name-length table +//! included. use super::sd_image::{encode_record, patch_backup, MEM_SLOTS}; use crate::commands::export::ExpandedChannel; @@ -26,7 +26,8 @@ fn row_to_channel(cols: &[&str], id: i64) -> Channel { let mode = match cols[5] { "AM" => "AM", "FM Narrow" => "NFM", - "DN" | "VW" => "YSF", + "DN" => "YSF", + "VW" => "YSF-VW", _ => "FM", }; let tone_mode = match cols[8] { @@ -88,7 +89,6 @@ fn reencodes_a_real_radios_memories() { let mut m = r.to_vec(); m[0] &= !0x30; // skip, M-GRP m[10] &= !0x80; // clock shift - m[11] &= !0x20; // VW m }; if mask(&ours) == mask(theirs) { @@ -97,7 +97,7 @@ fn reencodes_a_real_radios_memories() { wrong.push(format!("slot {slot}: ours {ours:02x?}\n radio {theirs:02x?}")); } } - eprintln!("{exact} exact, {masked_only} differ only in skip/M-GRP/clock-shift/VW, {} wrong", wrong.len()); + eprintln!("{exact} exact, {masked_only} differ only in skip/M-GRP/clock-shift, {} wrong", wrong.len()); assert!(wrong.is_empty(), "{}", wrong.join("\n")); // Names and the name-length table: every name the radio holds, run through diff --git a/src-tauri/src/radios/yaesu_ftm300/sd_image.rs b/src-tauri/src/radios/yaesu_ftm300/sd_image.rs index c0d8773..d509a17 100644 --- a/src-tauri/src/radios/yaesu_ftm300/sd_image.rs +++ b/src-tauri/src/radios/yaesu_ftm300/sd_image.rs @@ -107,7 +107,10 @@ const TMODE_TONE: u8 = 1; const TMODE_TSQL: u8 = 2; const TMODE_DCS: u8 = 4; // Byte 11: DN (0x40), VW (0x20 with 0x40), user CTCSS in Hz/100 (low 5 bits). +// VW is 0x60 on the one VW channel in Tim's own backup (slot 2), and the +// radio showed VW for a 0x60 memory the app wrote (#141); DN is 0x40. const DIGITAL_DN: u8 = 0x40; +const DIGITAL_VW: u8 = 0x60; const USER_CTCSS_1500: u8 = 15; /// Largest repeater shift the radio offers (Set Mode RPT SHIFT FREQ, 0.00-99.95 @@ -330,8 +333,15 @@ pub(crate) fn encode_record(ec: &ExpandedChannel) -> [u8; MEM_STRIDE] { r[9] = (power_code(c.power.as_deref()) << 6) | tone; r[10] = dcs; // clock shift (0x80) stays off // A YSF channel is programmed DN-fixed, as the operator's own C4FM - // repeater channels are; AMS (byte 1, 0x40) stays off. - r[11] = USER_CTCSS_1500 | if is("YSF") { DIGITAL_DN } else { 0 }; + // repeater channels are, and YSF-VW VW-fixed; AMS (byte 1, 0x40) stays off. + let digital = if is("YSF") { + DIGITAL_DN + } else if is("YSF-VW") { + DIGITAL_VW + } else { + 0 + }; + r[11] = USER_CTCSS_1500 | digital; r[12..14].copy_from_slice(&shift_units.to_be_bytes()); // 14/15: RX/TX DG-ID 00. r @@ -556,6 +566,10 @@ mod tests { c.power = Some("Med".into()); let r = encode_record(&ec(c)); assert_eq!((r[1], r[9] >> 6, r[11]), (0x03, 1, 0x4F)); + let mut c = chan(147.0, Some(147.6)); + c.mode = Some("YSF-VW".into()); + let r = encode_record(&ec(c)); + assert_eq!((r[1], r[11]), (0x03, 0x6F)); } #[test] diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7705583..befe664 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -11,6 +11,7 @@ export const MODES = [ "DMR", "DSTAR", "YSF", + "YSF-VW", "NXDN", "P25", "M17", @@ -19,7 +20,7 @@ export const MODES = [ // the digital modes in the order most operators want them, then everything else // in MODES order. NFM rides with FM — on the air they're the same thing, so a // mixed analog batch stays together at the top. -const ZONE_MODE_ORDER = ["FM", "NFM", "DMR", "YSF", "DSTAR"]; +const ZONE_MODE_ORDER = ["FM", "NFM", "DMR", "YSF", "YSF-VW", "DSTAR"]; export const OP_STATUSES = ["On Air", "Off Air", "Unknown"]; export const DUPLEX_OPTIONS = ["+", "-", "none", "split"];