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 @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions codeplug/csv_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions codeplug/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
220 changes: 220 additions & 0 deletions tests/test_web_generate.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading