"""The map of who was heard. KML is a text format, and every one of these reads back what was written with an XML parser rather than by matching strings, so a file that would not open in Google Earth fails here first. Nothing touches the network: the licence data is built by hand. """ import time import xml.etree.ElementTree as ET import pytest from bandsaunter.callsign import Callsign, CallsignBook, grid_to_latlon from bandsaunter.kml import Contact, Hearing, KmlLog KML_NS = "{http://www.opengis.net/kml/2.2}" @pytest.fixture(autouse=True) def no_network(monkeypatch): def refuse(self, url, call): raise AssertionError(f"a test tried to look up {call} for real") monkeypatch.setattr(CallsignBook, "_request", refuse) def _when(text: str) -> float: return time.mktime(time.strptime(text, "%Y-%m-%d %H:%M:%S")) def _entry(call="KU0W", **kw) -> Callsign: body = dict(name="Rod R Gowdy", location="Tucson, AZ", country="United States", grid="DM42lj", oper_class="EXTRA", latitude=32.3345, longitude=-111.0421, status="found") body.update(kw) return Callsign(call=call, **body) def _placemarks(path): root = ET.parse(path).getroot() return root.iter(KML_NS + "Placemark") def _fields(placemark) -> dict: out = {} for data in placemark.iter(KML_NS + "Data"): value = data.find(KML_NS + "value") out[data.get("name")] = value.text if value is not None else "" return out # --------------------------------------------------------------------------- # One station, one pin # --------------------------------------------------------------------------- def test_a_callsign_becomes_a_placemark_where_it_is_licensed(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44"), recording="0146.880000MHz-nfm.wav") written = log.save() assert written is not None and written.exists() marks = list(_placemarks(written)) assert len(marks) == 1 fields = _fields(marks[0]) assert fields["callsign"] == "KU0W" assert fields["licensee"] == "Rod R Gowdy" assert fields["location"] == "Tucson, AZ" assert fields["grid"] == "DM42lj" point = marks[0].find(f"{KML_NS}Point/{KML_NS}coordinates") lon, lat, _ = point.text.split(",") # KML puts longitude first, which is the easy thing to get backwards. assert float(lon) == pytest.approx(-111.0421, abs=1e-4) assert float(lat) == pytest.approx(32.3345, abs=1e-4) def test_the_name_on_the_pin_is_the_callsign_and_the_licensee(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44")) mark = next(_placemarks(log.save())) assert mark.find(KML_NS + "name").text == "KU0W — Rod R Gowdy" def test_the_balloon_names_the_frequency_and_the_time(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44")) body = next(_placemarks(log.save())).find(KML_NS + "description").text assert "146.88 MHz" in body assert "2026-08-22 13:01:44" in body assert "2 m Amateur" in body or "2 m" in body assert "Rod R Gowdy" in body def test_hearing_a_station_again_adds_a_line_not_a_second_pin(tmp_path): log = KmlLog(tmp_path / "map.kml") for offset in (0, 300, 900): log.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44") + offset) written = log.save() assert len(list(_placemarks(written))) == 1 heard = _fields(next(_placemarks(written)))["heard"].splitlines() assert len(heard) == 3 def test_the_very_same_transmission_is_not_recorded_twice(tmp_path): """A re-run over the same recordings must not inflate the count.""" log = KmlLog(tmp_path / "map.kml") at = _when("2026-08-22 13:01:44") assert log.add(_entry(), 146.88e6, at) is True assert log.add(_entry(), 146.88e6, at) is False heard = _fields(next(_placemarks(log.save())))["heard"].splitlines() assert len(heard) == 1 def test_a_station_heard_on_two_bands_keeps_both(tmp_path): log = KmlLog(tmp_path / "map.kml") at = _when("2026-08-22 13:01:44") log.add(_entry(), 146.88e6, at) log.add(_entry(), 7.242e6, at + 60) heard = _fields(next(_placemarks(log.save())))["heard"] assert "146.88 MHz" in heard and "7.242 MHz" in heard def test_the_time_span_covers_first_to_last(tmp_path): log = KmlLog(tmp_path / "map.kml") at = _when("2026-08-22 13:01:44") log.add(_entry(), 146.88e6, at) log.add(_entry(), 146.88e6, at + 3600) mark = next(_placemarks(log.save())) span = mark.find(KML_NS + "TimeSpan") assert span is not None begin = span.find(KML_NS + "begin").text end = span.find(KML_NS + "end").text assert begin < end and begin.endswith("Z") # --------------------------------------------------------------------------- # Continuing an existing map # --------------------------------------------------------------------------- def test_a_later_run_adds_to_the_map_rather_than_replacing_it(tmp_path): path = tmp_path / "map.kml" first = KmlLog(path) first.add(_entry(), 146.88e6, _when("2026-08-22 13:01:44")) first.save() second = KmlLog(path) assert "KU0W" in second.contacts, "the existing map was not read back" second.add(_entry("W1AW", name="Arrl Hq Operators Club", location="Newington, CT", grid="FN31pr", latitude=41.7147, longitude=-72.7270), 3.965e6, _when("2026-08-23 20:00:00")) written = second.save() calls = {_fields(m)["callsign"] for m in _placemarks(written)} assert calls == {"KU0W", "W1AW"} def test_what_was_heard_before_survives_the_round_trip(tmp_path): path = tmp_path / "map.kml" first = KmlLog(path) at = _when("2026-08-22 13:01:44") first.add(_entry(), 146.88e6, at, recording="one.wav") first.save() second = KmlLog(path) heard = second.contacts["KU0W"].hearings assert len(heard) == 1 assert heard[0].frequency == pytest.approx(146.88e6) assert heard[0].at == pytest.approx(at) assert heard[0].recording == "one.wav" # and the same transmission read back is still not added twice assert second.add(_entry(), 146.88e6, at) is False def test_a_position_learned_later_updates_the_pin(tmp_path): path = tmp_path / "map.kml" first = KmlLog(path) first.add(Callsign(call="KU0W", status="offline"), 146.88e6, 1.0) first.save() second = KmlLog(path) second.add(_entry(), 146.88e6, 2.0) mark = next(_placemarks(second.save())) assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is not None def test_a_failed_lookup_does_not_blank_out_what_is_known(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(_entry(), 146.88e6, 1.0) log.add(Callsign(call="KU0W", status="offline"), 146.88e6, 2.0) fields = _fields(next(_placemarks(log.save()))) assert fields["licensee"] == "Rod R Gowdy" # --------------------------------------------------------------------------- # Stations with nowhere to put them # --------------------------------------------------------------------------- def test_a_callsign_with_no_licence_is_kept_but_not_placed(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(Callsign(call="2E0XYZ", country="United Kingdom", status="unlisted"), 145.5e6, 1.0) written = log.save() mark = next(_placemarks(written)) assert _fields(mark)["callsign"] == "2E0XYZ" assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is None root = ET.parse(written).getroot() folder = root.find(f".//{KML_NS}Folder/{KML_NS}name") assert folder is not None and "no location" in folder.text def test_a_grid_square_places_a_station_the_licence_did_not(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(Callsign(call="W1AW", grid="FN31pr", status="found"), 3.965e6, 1.0) mark = next(_placemarks(log.save())) point = mark.find(f"{KML_NS}Point/{KML_NS}coordinates") lon, lat, _ = point.text.split(",") assert float(lat) == pytest.approx(41.73, abs=0.05) assert float(lon) == pytest.approx(-72.71, abs=0.05) # and it says so, because a grid square is kilometres wide assert "grid square" in mark.find(KML_NS + "description").text def test_a_record_with_no_coordinates_is_not_placed_off_africa(tmp_path): """Zeroed coordinates are missing data, not a position in the Atlantic.""" entry = Callsign(call="K7XYZ", status="found", latitude=0.0, longitude=0.0) log = KmlLog(tmp_path / "map.kml") log.add(entry, 146.0e6, 1.0) mark = next(_placemarks(log.save())) assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is None @pytest.mark.parametrize("grid,lat,lon", [ ("DM42lj", 32.40, -111.04), ("FN31pr", 41.73, -72.71), ("EM79", 39.5, -85.0), ]) def test_grid_squares_convert_to_their_centre(grid, lat, lon): got = grid_to_latlon(grid) assert got == pytest.approx((lat, lon), abs=0.06) @pytest.mark.parametrize("bad", ["", "nonsense", "ZZ99", "DM4", "DM42lj9"]) def test_what_is_not_a_grid_square_converts_to_nothing(bad): assert grid_to_latlon(bad) is None # --------------------------------------------------------------------------- # Not breaking the file # --------------------------------------------------------------------------- def test_a_name_with_xml_in_it_does_not_break_the_document(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(_entry(name="Smith & Sons Club"), 146.88e6, 1.0) fields = _fields(next(_placemarks(log.save()))) assert fields["licensee"] == "Smith & Sons Club" def test_an_unreadable_file_is_never_overwritten(tmp_path): path = tmp_path / "map.kml" path.write_text("this is not KML, and something else wrote it") log = KmlLog(path) log.add(_entry(), 146.88e6, 1.0) assert log.save() is None assert path.read_text().startswith("this is not KML") def test_nothing_heard_writes_no_file(tmp_path): path = tmp_path / "map.kml" assert KmlLog(path).save() is None assert not path.exists() def test_the_write_leaves_no_temporary_behind(tmp_path): log = KmlLog(tmp_path / "map.kml") log.add(_entry(), 146.88e6, 1.0) log.save() assert [p.name for p in tmp_path.iterdir()] == ["map.kml"] def test_a_callsign_with_no_call_is_refused(tmp_path): log = KmlLog(tmp_path / "map.kml") assert log.add(Callsign(call=""), 146.0e6, 1.0) is False assert len(log) == 0 # --------------------------------------------------------------------------- # The pieces # --------------------------------------------------------------------------- def test_a_hearing_reads_back_what_it_wrote(): original = Hearing(146.88e6, _when("2026-08-22 13:01:44"), "2 m Amateur", "capture.wav") again = Hearing.parse(original.describe()) assert again.frequency == pytest.approx(original.frequency) assert again.at == pytest.approx(original.at) assert again.band == original.band assert again.recording == original.recording def test_a_mangled_line_loses_only_itself(): """Someone editing the map by hand must not destroy the rest of it.""" assert Hearing.parse("") is None assert Hearing.parse("who knows what this is") is not None def test_a_contact_orders_its_hearings_by_time(): c = Contact(call="KU0W") c.add(Hearing(146.88e6, 300.0)) c.add(Hearing(146.88e6, 100.0)) assert [h.at for h in c.hearings] == [100.0, 300.0] assert c.first_heard == 100.0 and c.last_heard == 300.0 # --------------------------------------------------------------------------- # A scan that hears somebody # --------------------------------------------------------------------------- CALLOOK = { "status": "VALID", "type": "PERSON", "current": {"callsign": "W1AW", "operClass": "CLUB"}, "name": "ARRL HQ OPERATORS CLUB", "address": {"line1": "225 MAIN ST", "line2": "NEWINGTON, CT 06111"}, "location": {"latitude": "41.714775", "longitude": "-72.727260", "gridsquare": "FN31pr"}, "otherInfo": {"expiryDate": "02/29/2032"}, } @pytest.fixture def fake_lookup(monkeypatch): """The licence database, answering from a fixture instead of the network.""" asked = [] def respond(self, url, call): asked.append(call) return dict(CALLOOK, current={"callsign": call, "operClass": "CLUB"}) monkeypatch.setattr(CallsignBook, "_request", respond) return asked @pytest.fixture def heard_speech(monkeypatch): """A recogniser that hears one station identify itself.""" from bandsaunter import transcribe as tr monkeypatch.setitem( tr._DISPATCH, "fake", lambda audio, rate, model, lang: tr.Transcript( text="Net control, this is W1AW, standing by.", engine="fake")) monkeypatch.setattr(tr, "ENGINES", ("fake",) + tr.ENGINES) monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake") monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake") def _scan(tmp_path, **over): from bandsaunter.config import ScanConfig from bandsaunter.ranges import parse_range_list from bandsaunter.scanner import Scanner, ScannerCallbacks from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"), output_dir=str(tmp_path), record_seconds=4.0, hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05, max_cycles=1, revisit_seconds=0.2, transcribe=True, transcribe_engine="fake") on_status = over.pop("_on_status", None) for key, value in over.items(): setattr(cfg, key, value) scanner = Scanner(cfg, device=SimulatedDevice( transmitters=[V(146_520_000, "nfm", 0.4, 12_500, "v")]).open(), callbacks=ScannerCallbacks(on_status=on_status) if on_status else None) scanner.prepare() scanner.run() return scanner def test_a_scan_maps_the_stations_it_hears(tmp_path, heard_speech, fake_lookup): scanner = _scan(tmp_path) written = tmp_path / "callsigns.kml" assert written.exists(), "the scan heard a callsign but wrote no map" assert fake_lookup == ["W1AW"] mark = next(_placemarks(written)) fields = _fields(mark) assert fields["callsign"] == "W1AW" assert fields["licensee"] == "ARRL HQ Operators Club" assert fields["location"] == "Newington, CT" # The frequency is the one the detector settled on, which is # near the transmitter rather than exactly on it. assert "146.5" in fields["heard"] assert "2 m FM Simplex" in fields["heard"] assert ".wav" in fields["heard"] assert mark.find(f"{KML_NS}Point/{KML_NS}coordinates") is not None assert scanner.heard.get("W1AW") def test_a_scan_says_on_the_display_who_it_heard(tmp_path, heard_speech, fake_lookup): said = [] _scan(tmp_path, _on_status=said.append) spoken = [m for m in said if "W1AW" in m] assert spoken, f"the scan never announced the callsign: {said}" assert "ARRL HQ Operators Club" in spoken[0] def test_the_same_station_is_only_announced_once(tmp_path, heard_speech, fake_lookup): """A repeater net would otherwise fill the status line with one name.""" said = [] _scan(tmp_path, _on_status=said.append, max_cycles=3, revisit_seconds=0.0) assert len([m for m in said if m.startswith("heard W1AW")]) == 1 def test_the_map_is_off_when_no_file_is_named(tmp_path, heard_speech, fake_lookup): _scan(tmp_path, kml_file="") assert not list(tmp_path.glob("*.kml")) def test_lookups_can_be_turned_off_entirely(tmp_path, heard_speech): """Nothing may reach the network with the lookup switched off.""" def refuse(self, url, call): raise AssertionError("looked a callsign up with lookups off") import bandsaunter.callsign as cs original, cs.CallsignBook._request = cs.CallsignBook._request, refuse try: _scan(tmp_path, callsign_lookup=False) finally: cs.CallsignBook._request = original # The station is still on the map, named by its prefix rather than a # licence: that it was heard is worth recording either way. written = tmp_path / "callsigns.kml" assert written.exists() assert _fields(next(_placemarks(written)))["callsign"] == "W1AW" def test_a_second_scan_adds_to_the_same_map(tmp_path, heard_speech, fake_lookup): _scan(tmp_path) first = len(_fields(next(_placemarks(tmp_path / "callsigns.kml") ))["heard"].splitlines()) _scan(tmp_path) marks = list(_placemarks(tmp_path / "callsigns.kml")) assert len(marks) == 1, "a second scan started a second pin" assert len(_fields(marks[0])["heard"].splitlines()) > first