Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src-tauri/src/commands/csv_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -1122,7 +1122,8 @@ fn parse_timeslot(v: &str) -> Option<i64> {
///
/// 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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"));
}
}
20 changes: 19 additions & 1 deletion src-tauri/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
65 changes: 62 additions & 3 deletions src-tauri/src/commands/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ pub(crate) fn build_preview(parsed: &[ParsedChannel]) -> ImportPreview {
#[derive(sqlx::FromRow)]
struct ExistingChannel {
id: i64,
mode: Option<String>,
ctcss_uplink: Option<f64>,
ctcss_downlink: Option<f64>,
operational_status: Option<String>,
Expand Down Expand Up @@ -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<ExistingChannel> = 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, \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions src-tauri/src/radios/yaesu_ft5d/sd_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src-tauri/src/radios/yaesu_ftm300/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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] {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src-tauri/src/radios/yaesu_ftm300/sd_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const MODES = [
"DMR",
"DSTAR",
"YSF",
"YSF-VW",
"NXDN",
"P25",
"M17",
Expand All @@ -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"];
Expand Down
Loading