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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ cable, or its microSD card — or exported as CSV for tools that expect it.
| **TIDRADIO TD-H3** | Analog FM/NFM/AM | VHF / UHF / 1.25 m | Direct USB — read, write, settings | 200 channels, tri-band |
| **AnyTone AT-D890UV** | DMR + Analog | VHF / UHF | Direct USB — channels, zones, scan lists, settings, call-sign DB | 4000 channels; full DMR: zones, talkgroups, 308k-entry caller-ID database |
| **Yaesu FT5D** | C4FM (System Fusion) + Analog | VHF / UHF TX, wideband RX | microSD — patches the radio's own backup file | 900 channels in 24 banks; channels, banks, and menu settings |
| **Yaesu FTM-300DR** | C4FM (System Fusion) + Analog | 2 m / 70 cm TX, 108–999 MHz RX | microSD — patches the radio's own backup file | 999 memories; memories and 147 menu settings, including the APRS and SmartBeaconing setup |
| **Icom ID-52** | D-STAR + Analog | VHF / UHF TX, 108–174 / 225–479 MHz RX | microSD — patches the radio's own `.icf` file | 1000 memories in 100 groups; memories and menu settings restore in one operation |
| **Icom ID-5100** | D-STAR + Analog | 2 m / 70 cm TX, 118–174 / 375–550 MHz RX | Data cable — read, write, settings | 1000 memories in 26 named banks, programmed by Icom's clone protocol over the cable; 292 menu settings including My Call Sign, TX messages and the D-PRS (APRS) position, object, item and weather setup, written in the same upload as the memories |
| **Kenwood TH-D72** | APRS + Analog | 2 m / 70 cm TX, 118–174 / 320–524 MHz RX | Direct USB — read, write, settings | 1000 memories; 113 menu settings over the radio's own `MU` command |
Expand Down Expand Up @@ -68,7 +69,6 @@ settings together, then verify on the actual radio before shipping.
| **Icom IC-7610** | HF / 6 m SDR base | [#46](https://github.com/ww8l/codeplug-magic/issues/46) |
| **Quansheng UV-K5** | Analog handheld | [#44](https://github.com/ww8l/codeplug-magic/issues/44) |
| **Yaesu FT-891** | HF / 6 m mobile | [#53](https://github.com/ww8l/codeplug-magic/issues/53) |
| **Yaesu FTM-300** | C4FM + Analog mobile | [#48](https://github.com/ww8l/codeplug-magic/issues/48) |
| **Yaesu FTM-400** | C4FM + Analog mobile | [#52](https://github.com/ww8l/codeplug-magic/issues/52) |

Adding a radio takes hardware to verify against, so the order these land in follows what's on the
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/migrations/0020_codeplug_memory_group.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- #48: the channels a codeplug puts in the radio's one user memory group (the
-- Yaesu FTM-300's M-GRP), chosen channel by channel on the codeplug page. Kept
-- per codeplug, like the per-channel scan-list assignment (0009), because the
-- same channel can be in M-GRP in one codeplug and not in another.
CREATE TABLE codeplug_memory_group (
codeplug_id INTEGER NOT NULL REFERENCES codeplugs(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
PRIMARY KEY (codeplug_id, channel_id)
);
53 changes: 53 additions & 0 deletions src-tauri/src/commands/codeplugs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,56 @@ pub async fn clear_channel_scan_list(
.estr()?;
Ok(())
}

// ---- Memory group (the FTM-300's M-GRP, #48) ----
// One user group a channel is either in or out of, chosen per codeplug.

/// The codeplug's memory-group channels that are still IN the codeplug. A row
/// for a channel whose list was later removed stays stored (re-adding the list
/// brings it back) but is not reported here, so the page's count, the checklist
/// and the export all agree on one set.
pub(crate) async fn codeplug_memory_group(
pool: &sqlx::SqlitePool,
codeplug_id: i64,
) -> Result<Vec<i64>, String> {
sqlx::query_scalar(
"SELECT mg.channel_id FROM codeplug_memory_group mg
WHERE mg.codeplug_id = ?1
AND mg.channel_id IN (
SELECT cle.channel_id FROM codeplug_channel_lists ccl
JOIN channel_list_entries cle ON cle.channel_list_id = ccl.channel_list_id
WHERE ccl.codeplug_id = ?1)",
)
.bind(codeplug_id)
.fetch_all(pool)
.await
.estr()
}

#[tauri::command]
pub async fn get_codeplug_memory_group(
state: State<'_, AppState>,
codeplug_id: i64,
) -> Result<Vec<i64>, String> {
codeplug_memory_group(&state.pool, codeplug_id).await
}

/// Put a batch of channels in (or take them out of) the codeplug's memory group.
#[tauri::command]
pub async fn set_codeplug_memory_group(
state: State<'_, AppState>,
codeplug_id: i64,
channel_ids: Vec<i64>,
member: bool,
) -> Result<(), String> {
let mut tx = state.pool.begin().await.estr()?;
for channel_id in channel_ids {
let sql = if member {
"INSERT OR IGNORE INTO codeplug_memory_group (codeplug_id, channel_id) VALUES (?1, ?2)"
} else {
"DELETE FROM codeplug_memory_group WHERE codeplug_id = ?1 AND channel_id = ?2"
};
sqlx::query(sql).bind(codeplug_id).bind(channel_id).execute(&mut *tx).await.estr()?;
}
tx.commit().await.estr()
}
15 changes: 15 additions & 0 deletions src-tauri/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,20 @@ pub async fn generate_codeplug(
(values, _) => values,
};

// The codeplug's memory group (the FTM-300's M-GRP), only for the formats
// that write one. Read before the exporter is borrowed for the export: the
// registry hands out a non-Send reference that cannot cross an await.
let wants_memory_group = model
.export_format
.as_deref()
.and_then(crate::radios::registry::exporter_for_format)
.is_some_and(|e| e.has_memory_group());
let memory_group: Vec<i64> = if wants_memory_group {
crate::commands::codeplugs::codeplug_memory_group(&state.pool, codeplug_id).await?
} else {
Vec::new()
};

// Format-keyed registry lookup (Chunk 3.8): a model picks its exporter by
// `export_format`, never by name and never by driver — an export-only model
// has no driver but still names a format. Unclaimed formats (and NULL) get
Expand All @@ -1101,6 +1115,7 @@ pub async fn generate_codeplug(
groups: &groups,
model: &model,
profile_settings: profile_settings.as_deref(),
memory_group: &memory_group,
};
exporter.export(&target, &req)?;
target
Expand Down
46 changes: 46 additions & 0 deletions src-tauri/src/commands/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ pub async fn find_memory_cards(format: String) -> Vec<MemoryCard> {
"yaesu_ft5d_sd" => ft5d_cards(),
"icom_id52_icf" => id52_cards(),
"kenwood_thd75_sd" => thd75_cards(),
"yaesu_ftm300_sd" => ftm300_cards(),
_ => Vec::new(),
})
.await
Expand Down Expand Up @@ -548,6 +549,27 @@ fn ft5d_cards() -> Vec<MemoryCard> {
out
}

/// The FTM-300, like the FT5D, writes its ALL backup to one fixed path — the
/// path READ FROM SD → ALL loads back — so there is nothing to navigate to.
fn ftm300_cards() -> Vec<MemoryCard> {
let mut out = Vec::new();
for root in mounted_roots() {
let path = root.join("FTM300D").join("BACKUP").join("CLNFTM300D.dat");
let Ok(image) = std::fs::read(&path) else {
continue;
};
if crate::radios::yaesu_ftm300::sd_image::validate_backup(&image).is_err() {
continue;
}
out.push(MemoryCard {
has_original: path.with_extension("dat.orig").exists(),
volume: volume_name(&root),
path: path.to_string_lossy().into_owned(),
});
}
out
}

/// The ID-52 keeps its settings files in one fixed folder but names them
/// itself, so what gets offered is the **folder**, not a file: the write creates
/// a new `SetYYYYMMDD_NN.icf` in it rather than overwriting anything the
Expand Down Expand Up @@ -658,6 +680,30 @@ pub async fn read_ft5d_settings_from_backup(path: String) -> Result<RadioSetting
.estr()?
}

/// The FTM-300's version of [`read_ft5d_settings_from_backup`], reading the
/// `CLNFTM300D.dat` the radio writes with SD CARD → BACKUP → WRITE TO SD → ALL.
#[tauri::command]
pub async fn read_ftm300_settings_from_backup(path: String) -> Result<RadioSettingsRead, String> {
tauri::async_runtime::spawn_blocking(move || {
let image = std::fs::read(&path).map_err(|e| {
format!(
"Could not read {path}: {e}. Pick FTM300D/BACKUP/CLNFTM300D.dat from the \
radio's microSD card."
)
})?;
crate::radios::yaesu_ftm300::sd_image::validate_backup(&image)?;
let settings = crate::radios::yaesu_ftm300::settings::decode_settings(&image);
let count = settings.as_object().map(|o| o.len()).unwrap_or(0);
Ok(RadioSettingsRead {
settings,
count,
backup_path: path,
})
})
.await
.estr()?
}

/// The ID-52's version of [`read_ft5d_settings_from_backup`], reading the `.icf`
/// the radio writes with `SET > SD Card > Save Setting`.
///
Expand Down
13 changes: 7 additions & 6 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ mod tests {
// Models are reintroduced one at a time (migration 0005 trimmed the
// original set): currently the Baofeng UV-5R, TIDRADIO TD-H3, AnyTone
// AT-D890UV, Yaesu FT5D, Icom ID-52, Kenwood TH-D75, Kenwood TH-D72
// the Binteradio BT-9000, Kenwood TM-D710, Icom ID-5100 and TYT MD-380. (0015
// the Binteradio BT-9000, Kenwood TM-D710, Icom ID-5100, TYT MD-380 and Yaesu
// FTM-300DR. (0015
// removed the Vero VR-N76 placeholder.) None of the last five has a
// migration of its own — seeding INSERTs new (manufacturer, model)
// rows, so a new model reaches existing databases on the next startup
Expand All @@ -70,9 +71,9 @@ mod tests {
.await
.unwrap();
assert_eq!(
count.0, 11,
count.0, 12,
"expected the UV-5R, TD-H3, AT-D890UV, FT5D, ID-52, TH-D75, TH-D72, BT-9000, \
TM-D710, ID-5100 and MD-380 seeded models"
TM-D710, ID-5100, MD-380 and FTM-300DR seeded models"
);

let models: Vec<(String,)> =
Expand All @@ -84,8 +85,8 @@ mod tests {
assert_eq!(
names,
vec![
"AT-D890UV", "BT-9000", "FT5D", "ID-5100", "ID-52", "MD-380", "TD-H3", "TH-D72",
"TH-D75", "TM-D710", "UV-5R"
"AT-D890UV", "BT-9000", "FT5D", "FTM-300DR", "ID-5100", "ID-52", "MD-380", "TD-H3",
"TH-D72", "TH-D75", "TM-D710", "UV-5R"
]
);

Expand All @@ -95,7 +96,7 @@ mod tests {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count2.0, 11, "seeding should be idempotent");
assert_eq!(count2.0, 12, "seeding should be idempotent");

// A new database starts with NO talkgroups. The BrandMeister list used
// to be compiled in and seeded here; it is downloaded on request now,
Expand Down
Loading
Loading