From 93d6aef96fd6c0333574aedfc55edfdfd50e3019 Mon Sep 17 00:00:00 2001 From: Jim Dawdy Date: Fri, 11 Sep 2026 23:44:37 -0500 Subject: [PATCH] Clarify 'Disc' disconnect channels, validate DMR ID binding, and allow hotspot-only generation - csv_export & README: clarify that TG 4000 channels labeled 'Disc' are Disconnect channels (not Discovery) - models.py: validate that radio_name is populated and matches channel dmr_id - web/app.py: allow codeplug generation when repeaters is empty if hotspot talkgroups or analog repeaters are selected - tests: add comprehensive test suite for web generate endpoint, DMR ID referential integrity, and edge cases --- README.md | 2 +- codeplug/csv_export.py | 5 +- codeplug/models.py | 7 ++ tests/test_web_generate.py | 220 +++++++++++++++++++++++++++++++++++++ web/app.py | 4 +- 5 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 tests/test_web_generate.py diff --git a/README.md b/README.md index 470ddca..e4baaad 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ zones again. - **Bundled repeater database** — 16,000+ analog repeaters from all 50 states, no API keys required - **State-only search** — pick states, get all repeaters, choose what you want - **Per-category hotspot zones** — Wide Area, US States, Countries, Language, Activity, Emcomm, Link — each capped at 64 channels with overflow paging -- **Mandatory disconnect** — every hotspot zone ends with TG 4000 disconnect; users can't accidentally omit it +- **Mandatory disconnect** — every hotspot zone ends with TG 4000 disconnect ('Disc', not Discovery); users can't accidentally omit it - **Per-state/band analog zones** — separate "IL 2m Analog", "IL 70cm Analog", "IN 2m Analog" zones rather than one giant Analog zone - **BrandMeister verification** (optional, with key) — cross-references RadioID data against BM device registry - **Official TG names from CSV** — bundled BM talkgroup catalog (1,700+ entries) diff --git a/codeplug/csv_export.py b/codeplug/csv_export.py index f3b4f35..9e328f0 100644 --- a/codeplug/csv_export.py +++ b/codeplug/csv_export.py @@ -339,8 +339,9 @@ def write_readme(codeplug: Codeplug) -> str: ----- - Parrot (TG 9990 / TG 310997) channels are set to Private Call type. Parrot will NOT respond if called as a Group Call. -- TG 4000 (Disconnect) disconnects the repeater from its current - reflector or talkgroup. Useful after a linked QSO. +- TG 4000 (Disconnect, labeled "Disc" in channel names, NOT Discovery) + disconnects the repeater or hotspot from its current reflector or talkgroup. + Useful after a linked QSO. - Verify all frequencies, offsets, and color codes against current repeater listings before transmitting. Repeater data comes from RadioID.net and may not reflect recent changes. diff --git a/codeplug/models.py b/codeplug/models.py index c851048..d176d1f 100644 --- a/codeplug/models.py +++ b/codeplug/models.py @@ -120,4 +120,11 @@ def validate(self) -> list[str]: for g in self.rx_groups: if len(g.contacts) > self.MAX_CONTACTS_PER_GROUP: warnings.append(f"RX group '{g.name}' has {len(g.contacts)} contacts, max is {self.MAX_CONTACTS_PER_GROUP}") + if not self.radio_name: + warnings.append("Codeplug has no radio_name set; channels will fail to bind to Radio ID in CPS") + for ch in self.channels: + if not ch.dmr_id: + warnings.append(f"Channel '{ch.name}' has empty DMR ID") + elif self.radio_name and ch.dmr_id != self.radio_name: + warnings.append(f"Channel '{ch.name}' DMR ID '{ch.dmr_id}' does not match radio_name '{self.radio_name}'") return warnings diff --git a/tests/test_web_generate.py b/tests/test_web_generate.py new file mode 100644 index 0000000..a1216a5 --- /dev/null +++ b/tests/test_web_generate.py @@ -0,0 +1,220 @@ +""" +Tests for web API generate endpoint and DMR ID referential integrity. + +Verifies that all channels generated via web API (repeater channels, +hotspot catalog channels, hotspot disconnect channels, manual hotspot channels, +and analog channels) have their DMR ID column correctly set to match the Radio Name +in dmr_id.csv. Also tests hotspot-only and analog-only generation. +""" + +import csv +import io +import sys +import zipfile +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from codeplug.models import Channel, Codeplug, Repeater, Talkgroup, Zone +from web.app import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +def test_web_generate_dmr_id_referential_integrity(client): + """ + Every channel in channels.csv — whether repeater, hotspot catalog, + hotspot disconnect, manual hotspot, or analog — must have its DMR ID + column set to the user's callsign, matching the Radio Name in dmr_id.csv. + """ + mock_rep = [ + Repeater( + callsign="W9XYZ", city="Chicago", state="Illinois", + country="United States", rx_freq=442.0625, offset=5.0, + color_code=1, network="BrandMeister", status="on-air", + talkgroups=[Talkgroup(3122, 1, "Illinois")], + ) + ] + + req_data = { + "dmr_id": 3179879, + "callsign": "KQ9I", + "city": "Chicago", + "state": "Illinois", + "locations": [{"city": "Chicago", "state": "Illinois"}], + "networks": ["BrandMeister"], + "selected_repeaters": ["W9XYZ:442.06250"], + "hotspot_tg_ids": [91, 93], + "manual_hotspot_tgs": [{"tg_id": 12345, "name": "Custom TG"}], + "selected_analog": [{ + "callsign": "W9TET", + "name": "W9TET Analog", + "city": "Chicago", + "state": "Illinois", + "rx_freq": 146.760, + "tx_freq": 146.160, + "ctcss_encode": "107.2", + }], + "hotspot_freq": 433.550, + "power": "High", + "country": "United States", + "initials": "JD", + } + + with patch("codeplug.radioid.search_repeaters", return_value=mock_rep): + resp = client.post("/api/generate", json=req_data) + + assert resp.status_code == 200, resp.text + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + + # dmr_id.csv must exist and have the user's Radio ID and Callsign + assert "dmr_id.csv" in zf.namelist() + dmr_rows = list(csv.DictReader(io.StringIO(zf.read("dmr_id.csv").decode("utf-8")))) + assert len(dmr_rows) == 1 + assert dmr_rows[0]["Radio ID"] == "3179879" + assert dmr_rows[0]["Radio Name"] == "KQ9I" + valid_radio_names = {r["Radio Name"] for r in dmr_rows} + + # channels.csv check + channels_csv = zf.read("channels.csv").decode("utf-8") + channel_rows = list(csv.DictReader(io.StringIO(channels_csv))) + assert len(channel_rows) > 0 + + channel_names = {c["Channel Name"] for c in channel_rows} + # Check that hotspot and manual channels are present + assert any("HS" in name for name in channel_names) + assert "Custom TG" in channel_names + assert "W9TET Analog" in channel_names + + # Check DMR ID on every channel + for ch in channel_rows: + dmr_id = ch["DMR ID"] + assert dmr_id, f"Channel '{ch['Channel Name']}' has empty DMR ID" + assert dmr_id in valid_radio_names, ( + f"Channel '{ch['Channel Name']}' has DMR ID '{dmr_id}', not in dmr_id.csv" + ) + + +def test_web_generate_hotspot_only(client): + """Users with only a hotspot (no repeaters selected) should get a valid codeplug.""" + req_data = { + "dmr_id": 3179879, + "callsign": "KQ9I", + "city": "Chicago", + "state": "Illinois", + "locations": [{"city": "Chicago", "state": "Illinois"}], + "networks": ["BrandMeister"], + "selected_repeaters": [], + "hotspot_tg_ids": [91, 93], + "manual_hotspot_tgs": [{"tg_id": 9999, "name": "Local HS"}], + "selected_analog": [], + "hotspot_freq": 433.550, + "power": "Low", + "country": "United States", + "initials": "JD", + } + + resp = client.post("/api/generate", json=req_data) + assert resp.status_code == 200, resp.text + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + + channel_rows = list(csv.DictReader(io.StringIO(zf.read("channels.csv").decode("utf-8")))) + assert len(channel_rows) > 0 + for ch in channel_rows: + assert ch["DMR ID"] == "KQ9I" + + +def test_web_generate_analog_only(client): + """Users selecting only analog repeaters should get a valid codeplug.""" + req_data = { + "dmr_id": 3179879, + "callsign": "KQ9I", + "city": "Chicago", + "state": "Illinois", + "locations": [{"city": "Chicago", "state": "Illinois"}], + "networks": ["BrandMeister"], + "selected_repeaters": [], + "hotspot_tg_ids": [], + "manual_hotspot_tgs": [], + "selected_analog": [{ + "callsign": "W9TET", + "name": "W9TET Analog", + "city": "Chicago", + "state": "Illinois", + "rx_freq": 146.760, + "tx_freq": 146.160, + "ctcss_encode": "107.2", + }], + "hotspot_freq": 433.550, + "power": "High", + "country": "United States", + "initials": "JD", + } + + resp = client.post("/api/generate", json=req_data) + assert resp.status_code == 200, resp.text + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + + channel_rows = list(csv.DictReader(io.StringIO(zf.read("channels.csv").decode("utf-8")))) + assert len(channel_rows) == 1 + assert channel_rows[0]["Channel Name"] == "W9TET Analog" + assert channel_rows[0]["DMR ID"] == "KQ9I" + + +def test_web_generate_no_selection_raises_400(client): + """If nothing at all is selected, /api/generate returns 400.""" + req_data = { + "dmr_id": 3179879, + "callsign": "KQ9I", + "city": "Chicago", + "state": "Illinois", + "locations": [{"city": "Chicago", "state": "Illinois"}], + "networks": ["BrandMeister"], + "selected_repeaters": [], + "hotspot_tg_ids": [], + "manual_hotspot_tgs": [], + "selected_analog": [], + "hotspot_freq": 433.550, + "power": "High", + "country": "United States", + "initials": "JD", + } + + resp = client.post("/api/generate", json=req_data) + assert resp.status_code == 400 + assert "No repeaters or talkgroups selected" in resp.json()["detail"] + + +def test_codeplug_validate_catches_dmr_id_issues(): + """Codeplug.validate() should flag missing radio_name and mismatched dmr_id.""" + ch_ok = Channel( + name="Ch1", channel_type="Digital", rx_freq=442.0, tx_freq=447.0, + color_code=1, timeslot=1, tx_contact="TG1", rx_group="None", + dmr_id="KQ9I", + ) + ch_mismatch = Channel( + name="Ch2", channel_type="Digital", rx_freq=442.0, tx_freq=447.0, + color_code=1, timeslot=1, tx_contact="TG1", rx_group="None", + dmr_id="OTHER", + ) + + cp_no_radio = Codeplug( + contacts=[], rx_groups=[], channels=[ch_ok], zones=[], + radio_id=0, radio_name="", + ) + warnings = cp_no_radio.validate() + assert any("no radio_name set" in w for w in warnings) + + cp_mismatch = Codeplug( + contacts=[], rx_groups=[], channels=[ch_ok, ch_mismatch], zones=[], + radio_id=3179879, radio_name="KQ9I", + ) + warnings = cp_mismatch.validate() + assert any("does not match radio_name" in w for w in warnings) diff --git a/web/app.py b/web/app.py index 5b5119d..b2125ab 100644 --- a/web/app.py +++ b/web/app.py @@ -358,8 +358,8 @@ async def generate(req: GenerateRequest): if f"{r.callsign.upper()}:{r.rx_freq:.5f}" in selected_set ] - if not repeaters: - raise HTTPException(status_code=400, detail="No repeaters selected") + if not repeaters and not req.selected_analog and not req.hotspot_tg_ids and not req.manual_hotspot_tgs: + raise HTTPException(status_code=400, detail="No repeaters or talkgroups selected") # BM data — use CSV catalog as primary TG name source; API for device verification bm_talkgroups: dict[int, str] = dict(_BM_TG_NAMES) # CSV names