"""The weather section: the options, the names, the log and what it reads as. The radio end of this is in test_acurite.py. What is here is everything between a decoded message and a person: the names given to sensors, the log they go into, the spreadsheet that comes out, the table that is printed, and the two front ends that drive all of it. The names get the most attention, because they are the only thing in this section that somebody typed and so the only thing that cannot be recovered by listening again. """ import json import time import numpy as np import pytest from rich.console import Console from bandsaunter import acurite as a from bandsaunter import weather as wx from bandsaunter import weatherlog as wl from bandsaunter.sensors import Sensor, SensorBook from bandsaunter.ui import WeatherDisplay RATE = 400_000.0 OFFSET = 100_000.0 def reading(family="tower", sensor=0x1A2B, celsius=17.4, humidity=61, channel="A", at=1_700_000_000.0, battery_low=False): """One decoded message, made the way the receiver would have made it.""" bits = {"tower": lambda: a.tower_frame(sensor, celsius, humidity, channel, battery_low), "5n1": lambda: a.five_in_one_weather(sensor, 11.0, celsius, humidity, channel), "wind": lambda: a.five_in_one_wind_rain(sensor, 11.0, 90.0, 1284, channel)}[family]() got = a.decode(bits, confirm=False) got.at = at return got @pytest.fixture def book(tmp_path): return SensorBook(path=tmp_path / "sensors.yaml") # --------------------------------------------------------------------------- # The options # --------------------------------------------------------------------------- def test_every_option_names_a_field_that_exists(): fields = set(wx.WeatherOptions().__dict__) for option in wx.OPTIONS: assert option.key in fields, f"{option.key} is not an option" def test_every_field_is_an_option_somebody_can_reach(): """Anything settable has to be settable from the menu and the flags.""" keys = {o.key for o in wx.OPTIONS} assert set(wx.WeatherOptions().__dict__) == keys def test_every_option_is_in_a_group_the_menu_shows(): for option in wx.OPTIONS: assert option.group in wx.OPTION_GROUPS for group in wx.OPTION_GROUPS: assert wx.in_group(group), f"{group} is empty" def test_every_option_says_what_it_does_and_how_to_set_it(): for option in wx.OPTIONS: assert option.help and option.help[0].islower() assert option.detail, f"{option.key} has no detail" assert option.flags, f"{option.key} has no command line flag" if option.kind == "bool": assert option.off_flags, f"{option.key} cannot be turned off" def test_the_options_survive_being_saved_and_read_back(tmp_path): options = wx.WeatherOptions(units="imperial", only_named=True, hold=90.0, offset=0.0, gain="40") wx.save_options(options, tmp_path) assert wx.load_options(tmp_path) == options def test_a_settings_file_with_a_mistake_in_it_costs_the_defaults(tmp_path): (tmp_path / "weather.yaml").write_text("units: [this is not a unit\n") assert wx.load_options(tmp_path) == wx.WeatherOptions() def test_a_setting_this_version_does_not_know_is_ignored(tmp_path): (tmp_path / "weather.yaml").write_text("units: imperial\nfuture: 3\n") assert wx.load_options(tmp_path).units == "imperial" @pytest.mark.parametrize("options,broken", [ (wx.WeatherOptions(rate=100_000.0), "sample rate"), (wx.WeatherOptions(rate=400_000.0, offset=300_000.0), "offset"), (wx.WeatherOptions(seconds=-1.0), "negative"), (wx.WeatherOptions(hold=0.0), "display"), ]) def test_a_setting_that_cannot_work_is_refused_before_the_receiver_opens( options, broken): assert any(broken in err for err in options.validate()) def test_the_defaults_are_all_workable(): assert wx.WeatherOptions().validate() == [] def test_imperial_is_a_word_rather_than_a_flag(): assert wx.WeatherOptions(units="imperial").imperial is True assert wx.WeatherOptions(units="metric").imperial is False def test_a_sweep_of_433_is_told_it_wants_this_mode_instead(): class Range: def __init__(self, start, stop): self.start, self.stop = start, stop assert "433" in wx.scanning_sensor_band([Range(430e6, 440e6)]) assert wx.scanning_sensor_band([Range(144e6, 148e6)]) == "" # --------------------------------------------------------------------------- # Names: the only thing here a person typed # --------------------------------------------------------------------------- def test_a_name_is_on_the_disk_the_moment_it_is_given(book): """Not when the program exits. It ends on control-C, if at all.""" book.tag("tower/1A2B", "back fence") assert SensorBook(path=book.path).name_for("tower/1A2B") == "back fence" def test_a_name_survives_the_counts_being_written_over_it(book): book.tag("tower/1A2B", "back fence") book.heard(reading()) book.flush() again = SensorBook(path=book.path) assert again.name_for("tower/1A2B") == "back fence" assert again.get("tower/1A2B").messages == 1 def test_a_half_written_file_cannot_happen(book, monkeypatch): """The write goes to a neighbour and is renamed over the old one.""" book.tag("tower/1A2B", "back fence") renames = [] import bandsaunter.sensors as sensors monkeypatch.setattr(sensors.os, "replace", lambda src, dst: renames.append((src, dst))) book.tag("tower/0C41", "greenhouse") assert renames and str(renames[0][1]) == str(book.path) # The old file is still the old file until the rename happens. assert SensorBook(path=book.path).name_for("tower/0C41") == "" def test_a_names_file_with_a_mistake_in_it_is_left_alone(tmp_path): where = tmp_path / "sensors.yaml" where.write_text("sensors: [ this is not\n") assert len(SensorBook(path=where)) == 0 assert where.read_text() == "sensors: [ this is not\n" def test_a_sensor_keeps_its_name_when_the_channel_switch_is_moved(book): book.tag("tower/1A2B", "back fence") book.heard(reading(channel="C")) assert book.name_for("tower/1A2B") == "back fence" assert book.get("tower/1A2B").channel == "C" def test_the_first_and_last_time_a_sensor_was_heard_are_kept(book): book.heard(reading(at=1_000.0)) book.heard(reading(at=2_000.0)) sensor = book.get("tower/1A2B") assert (sensor.first_heard, sensor.last_heard) == (1_000.0, 2_000.0) assert sensor.messages == 2 def test_a_nameless_sensor_is_shown_by_its_identity_and_not_as_blank(book): book.heard(reading()) assert book.label_for("tower/1A2B") == "1A2B" book.tag("tower/1A2B", "back fence") assert book.label_for("tower/1A2B") == "back fence" def test_named_sensors_come_first_and_then_the_most_recently_heard(book): book.heard(reading(sensor=0x0001, at=100.0)) book.heard(reading(sensor=0x0002, at=300.0)) book.heard(reading(sensor=0x0003, at=200.0)) book.tag("tower/0003", "shed") assert [s.sensor for s in book.ordered()] == ["0003", "0002", "0001"] def test_a_sensor_is_found_by_its_identity_by_its_name_or_by_its_key(book): book.heard(reading()) book.tag("tower/1A2B", "back fence") for text in ("1A2B", "1a2b", "back fence", "tower/1A2B"): assert [s.key for s in book.find(text)] == ["tower/1A2B"] def test_an_exact_identity_wins_over_everything_it_appears_inside(book): book.heard(reading(sensor=0x00AB)) book.heard(reading(sensor=0xAB12)) assert [s.sensor for s in book.find("00AB")] == ["00AB"] def test_forgetting_a_sensor_removes_it_from_the_file(book): book.tag("tower/1A2B", "back fence") assert book.forget("tower/1A2B") is True assert book.forget("tower/1A2B") is False assert len(SensorBook(path=book.path)) == 0 def test_a_sensor_never_heard_can_still_be_given_a_name(book): """The box on the shed is on the shed whether or not it was received.""" from bandsaunter.cli import _name_sensors assert _name_sensors(book, ["1A2B=back fence"], quiet=True) == 1 assert book.name_for("?/1A2B") == "back fence" def test_naming_something_that_has_been_heard_uses_the_key_it_was_heard_under( book): from bandsaunter.cli import _name_sensors book.heard(reading()) _name_sensors(book, ["1a2b=back fence"], quiet=True) assert book.name_for("tower/1A2B") == "back fence" assert "?/1A2B" not in book # --------------------------------------------------------------------------- # What is out there, and what it last said # --------------------------------------------------------------------------- def test_a_station_keeps_the_latest_of_every_quantity_not_the_latest_message(): """A 5-in-1 alternates two messages, and the line must show the sensor.""" garden = wx.Garden() garden.add(reading("wind", sensor=0x777, at=10.0)) garden.add(reading("5n1", sensor=0x777, celsius=16.8, at=20.0)) station = garden.stations["5n1/0777"] assert station.values["rain"].value == pytest.approx(326.1, abs=0.1) assert station.values["temperature"].value == pytest.approx(16.8, abs=0.1) assert station.values["wind"].value > 0 def test_a_station_remembers_when_each_quantity_last_arrived(): garden = wx.Garden() garden.add(reading("wind", sensor=0x777, at=10.0)) garden.add(reading("5n1", sensor=0x777, at=20.0)) station = garden.stations["5n1/0777"] assert station.times["rain"] == 10.0 assert station.times["temperature"] == 20.0 def test_the_lowest_and_highest_of_a_quantity_are_kept_as_it_goes(): garden = wx.Garden() for i, celsius in enumerate((17.0, 21.0, 15.0, 19.0)): garden.add(reading(celsius=celsius, at=100.0 + i)) first, last, low, high = garden.stations["tower/1A2B"].span("temperature") assert (first, last, low, high) == (17.0, 19.0, 15.0, 21.0) def test_how_often_a_sensor_is_being_heard_is_the_measure_of_the_aerial(): garden = wx.Garden() for i in range(5): garden.add(reading(at=1_000.0 + i * 16.0)) assert garden.stations["tower/1A2B"].gap == pytest.approx(16.0) def test_a_sensor_that_has_stopped_transmitting_leaves_the_display(): garden = wx.Garden() garden.add(reading(at=1_000.0)) assert len(garden.showing(300.0, now=1_200.0)) == 1 assert len(garden.showing(300.0, now=1_400.0)) == 0 assert len(garden.all()) == 1 # gone from the screen, not forgotten def test_a_garden_can_be_rebuilt_from_a_log(): readings = [reading(at=100.0), reading(sensor=0x0C41, at=101.0)] garden = wx.Garden.of(readings) assert {s.sensor for s in garden.all()} == {"1A2B", "0C41"} # --------------------------------------------------------------------------- # The log # --------------------------------------------------------------------------- def test_a_message_survives_being_written_and_read_back(tmp_path): log = wl.WeatherLog(tmp_path / "weather_x.jsonl", frequency=a.ACURITE_HZ) sent = reading(celsius=17.4, humidity=61) log.append(sent, name="back fence") log.close() back = wl.read_logs([log.path]) assert len(back) == 1 got = back[0] assert got.key == sent.key and got.at == sent.at assert got.value("temperature") == pytest.approx(17.4) assert got.value("humidity") == 61 assert got.channel == "A" and got.battery_low is False def test_the_raw_bytes_go_down_underneath_whatever_was_understood(tmp_path): """The message is the evidence; everything else in the line is an opinion.""" log = wl.WeatherLog(tmp_path / "weather_x.jsonl") sent = reading() log.append(sent) log.close() body = json.loads(log.path.read_text().splitlines()[1]) assert bytes.fromhex(body["hex"]) == bytes( int(sent.bits[i:i + 8], 2) for i in range(0, len(sent.bits), 8)) assert wl.read_logs([log.path])[0].bits == sent.bits def test_the_name_a_sensor_had_at_the_time_is_written_with_the_reading( tmp_path): """So a log read back in a year says where the sensor was, not where one is.""" log = wl.WeatherLog(tmp_path / "weather_x.jsonl") log.append(reading(), name="back fence") log.close() assert json.loads(log.path.read_text().splitlines()[1])["name"] \ == "back fence" def test_a_half_written_last_line_costs_that_line_and_not_the_evening( tmp_path): log = wl.WeatherLog(tmp_path / "weather_x.jsonl") for i in range(5): log.append(reading(at=1_000.0 + i)) log.close() with open(log.path, "a") as fh: fh.write('{"t": 1005.0, "key": "tower/1A2B", "mo') assert len(wl.read_logs([log.path])) == 5 def test_the_header_line_is_not_read_back_as_a_reading(tmp_path): log = wl.WeatherLog(tmp_path / "weather_x.jsonl", receiver="device 0") log.close() head = json.loads(log.path.read_text().splitlines()[0]) assert head["log"] == "bandsaunter-weather" and head["receiver"] == "device 0" assert wl.read_logs([log.path]) == [] def test_readings_come_back_in_the_order_they_were_heard(tmp_path): one = tmp_path / "weather_a.jsonl" two = tmp_path / "weather_b.jsonl" with wl.WeatherLog(one) as log: log.append(reading(at=300.0)) with wl.WeatherLog(two) as log: log.append(reading(at=100.0)) assert [r.at for r in wl.read_logs([one, two])] == [100.0, 300.0] def test_a_log_is_written_as_it_goes_and_not_when_it_closes(tmp_path): """Listening ends on control-C. A log that needed a clean exit would be empty exactly when it was most wanted.""" log = wl.WeatherLog(tmp_path / "weather_x.jsonl") log.append(reading()) assert len(log.path.read_text().splitlines()) == 2 # header and one log.close() def test_the_newest_log_in_a_directory_is_the_first_one_offered(tmp_path): old = tmp_path / "weather_2020.jsonl" new = tmp_path / "weather_2026.jsonl" old.write_text("{}\n") new.write_text("{}\n") import os os.utime(old, (1_000_000, 1_000_000)) assert wl.logs_in(tmp_path)[0] == new # --------------------------------------------------------------------------- # Out to a spreadsheet # --------------------------------------------------------------------------- def test_the_spreadsheet_has_a_column_for_every_quantity_anything_reported( tmp_path, book): readings = [reading(at=100.0), reading("wind", sensor=0x777, at=101.0)] where = wl.write_csv(tmp_path / "w.csv", readings, book) head = where.read_text().splitlines()[0].split(",") assert "temperature (C)" in head and "rain (mm)" in head assert "wind from (deg)" in head def test_a_sensor_that_does_not_report_a_quantity_leaves_it_empty(tmp_path, book): readings = [reading(at=100.0), reading("wind", sensor=0x777, at=101.0)] where = wl.write_csv(tmp_path / "w.csv", readings, book) rows = where.read_text().splitlines() head = rows[0].split(",") tower = rows[1].split(",") assert tower[head.index("rain (mm)")] == "" assert tower[head.index("temperature (C)")] != "" def test_the_name_is_in_the_spreadsheet_because_the_identity_is_not_a_place( tmp_path, book): book.tag("tower/1A2B", "back fence") where = wl.write_csv(tmp_path / "w.csv", [reading(at=100.0)], book) assert "back fence" in where.read_text().splitlines()[1] def test_the_unit_is_in_the_heading_and_not_beside_every_number(tmp_path, book): """A column of "21.5 C" is text; a column of 21.5 is a temperature.""" where = wl.write_csv(tmp_path / "w.csv", [reading(celsius=21.5, at=1.0)], book) head, row = where.read_text().splitlines()[:2] assert "temperature (C)" in head assert row.split(",")[head.split(",").index("temperature (C)")] == "21.5" def test_the_spreadsheet_can_be_written_the_other_way_round(tmp_path, book): where = wl.write_csv(tmp_path / "w.csv", [reading(celsius=0.0, at=1.0)], book, imperial=True) head, row = where.read_text().splitlines()[:2] assert "temperature (F)" in head assert row.split(",")[head.split(",").index("temperature (F)")] == "32.0" # --------------------------------------------------------------------------- # Saying what was heard # --------------------------------------------------------------------------- def rendered(garden, book=None, imperial=False, width=120): console = Console(width=width, force_terminal=False) with console.capture() as cap: wx.report(console, garden, book, imperial) return cap.get() def test_the_report_says_who_was_heard_and_what_they_said(book): book.tag("tower/1A2B", "back fence") garden = wx.Garden() for i, celsius in enumerate((17.0, 21.0)): garden.add(reading(celsius=celsius, at=1_000.0 + i * 16)) out = rendered(garden, book) assert "back fence" in out and "1A2B" in out assert "Tower 592TXR" in out assert "17.0 C" in out and "21.0 C" in out def test_a_bearing_has_no_lowest_or_highest_because_north_is_nought_and_360( book): """The wind blew from four directions; only the first and last are shown. A lowest and a highest bearing would be read as a fact about the weather when it is a fact about arithmetic: the lowest direction of an evening is whichever side of north the wind happened to sit. """ garden = wx.Garden() for i, degrees in enumerate((315.0, 22.5, 180.0, 90.0)): got = a.decode(a.five_in_one_wind_rain(0x777, 11.0, degrees, 1284), confirm=False) got.at = 100.0 + i garden.add(got) out = rendered(garden, book) line = next(ln for ln in out.splitlines() if "wind from" in ln) assert line.count("\u00b0") == 2, line assert "315" in line and "90" in line # first and last # The quantities that do have an order still get all four. rain = next(ln for ln in out.splitlines() if "rain" in ln) assert rain.count("mm") == 4, rain def test_a_sensor_whose_model_cannot_be_read_is_still_counted(book): odd = a.decode(a._txr_bits([0xC0, 0x11, a._status(0x1B, False), 0x01, 0x02, 0x03]), confirm=False) odd.at = 1_000.0 garden = wx.Garden() garden.add(odd) out = rendered(garden, book) assert "0011" in out assert "cannot read" in out def test_a_flat_battery_is_shouted_about(): garden = wx.Garden() garden.add(reading(battery_low=True, at=1.0)) assert "low" in rendered(garden) # --------------------------------------------------------------------------- # The display # --------------------------------------------------------------------------- def shown(garden, book=None, width=120, now=None, **kw): console = Console(width=width, force_terminal=False) display = WeatherDisplay(console, book=book, **kw) display.started = (now or time.time()) - 60 display.update(garden, garden.messages) with console.capture() as cap: console.print(display.render(now=now)) return cap.get() def test_the_display_says_how_to_name_something_and_how_many_want_naming(book): garden = wx.Garden() now = time.time() garden.add(reading(at=now)) garden.add(reading(sensor=0x0C41, at=now)) book.tag("tower/1A2B", "back fence") out = shown(garden, book, now=now) assert "n to name" in out and "(1 unnamed)" in out assert "back fence" in out and "unnamed" in out def test_the_display_shows_the_whole_sensor_and_not_half_of_it(book): garden = wx.Garden() now = time.time() garden.add(reading("wind", sensor=0x777, at=now - 20)) garden.add(reading("5n1", sensor=0x777, at=now)) out = shown(garden, book, now=now) assert "rain" in out and "temperature" in out and "wind" in out def test_an_empty_display_says_what_to_check_rather_than_nothing(): assert "17 cm" in shown(wx.Garden()) def test_the_display_still_fits_a_narrow_terminal(book): garden = wx.Garden() now = time.time() garden.add(reading(at=now)) for width in (60, 76, 92, 120, 200): out = shown(garden, book, width=width, now=now) assert max(len(line) for line in out.splitlines()) <= width assert "1A2B" in out def test_readings_are_shown_in_whichever_system_was_asked_for(book): garden = wx.Garden() now = time.time() garden.add(reading(celsius=0.0, at=now)) assert "0.0 C" in shown(garden, book, now=now) assert "32.0 F" in shown(garden, book, now=now, imperial=True) # --------------------------------------------------------------------------- # Listening, without a receiver # --------------------------------------------------------------------------- class Silence: """A receiver that hears nothing, for testing the loop rather than the air.""" def __init__(self, blocks=2): self.blocks = blocks self.tuned = 0.0 def tune(self, hz, settle=True): self.tuned = hz return int(hz) def read_samples(self, count, flush=False): if self.blocks <= 0: return np.zeros(0, dtype=np.complex64) self.blocks -= 1 return np.zeros(count, dtype=np.complex64) def close(self): return None def test_the_receiver_is_tuned_to_one_side_of_the_sensors(): """Its own spike would otherwise sit on top of an on-off-keyed signal.""" device = Silence() options = wx.WeatherOptions(rate=RATE, offset=OFFSET) wx.pump(device, options, wx.Garden(), SensorBook(path="/dev/null"), None, time.time()) assert device.tuned == pytest.approx(a.ACURITE_HZ - OFFSET) def test_listening_stops_when_the_receiver_does(): device = Silence(blocks=3) total = wx.pump(device, wx.WeatherOptions(rate=RATE), wx.Garden(), SensorBook(path="/dev/null"), None, time.time()) assert total == 0 and device.blocks == 0 class Garden6: """The invented garden, wrapped so a test can drive it a block at a time.""" def __init__(self, seconds, rate=RATE, offset=OFFSET): self.sky = a.SimulatedSensors(sample_rate=rate, offset=offset, seed=3) self.left = seconds def tune(self, hz, settle=True): return int(hz) def read_samples(self, count, flush=False): if self.left <= 0: return np.zeros(0, dtype=np.complex64) self.left -= 1 return self.sky.read_samples(count) def close(self): return None def test_a_whole_session_writes_a_log_names_and_a_spreadsheet(tmp_path, monkeypatch): options = wx.WeatherOptions(rate=RATE, offset=OFFSET, simulate=True, messages=True, csv=True) monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(60)) book = SensorBook(path=tmp_path / "sensors.yaml") console = Console(width=120, force_terminal=False) with console.capture(): heard = wx.listen(console, options, str(tmp_path), book=book) assert heard.sensors == 6 assert heard.log_path.exists() and heard.csv_path.exists() assert len(wl.read_logs([heard.log_path])) == heard.messages assert len(book) == 6 assert book.path.exists() # the counts were saved on the way out def test_only_named_sensors_keeps_the_neighbours_out_of_the_log(tmp_path, monkeypatch): options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True, only_named=True) monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(60)) book = SensorBook(path=tmp_path / "sensors.yaml") book.tag("tower/1A2B", "back fence") console = Console(width=120, force_terminal=False) with console.capture(): heard = wx.listen(console, options, str(tmp_path), book=book) assert {s.key for s in heard.garden.all()} == {"tower/1A2B"} assert {r.key for r in wl.read_logs([heard.log_path])} == {"tower/1A2B"} def test_a_model_that_cannot_be_read_can_be_left_off_the_display(tmp_path): odd = a.decode(a._txr_bits([0xC0, 0x11, a._status(0x1B, False), 0x01, 0x02, 0x03]), confirm=False) odd.at = 1_000.0 class OneOddMessage: def tune(self, hz, settle=True): return int(hz) def read_samples(self, count, flush=False): return np.zeros(0, dtype=np.complex64) def close(self): return None for wanted, expect in ((True, 1), (False, 0)): garden = wx.Garden() options = wx.WeatherOptions(unknown=wanted) # The filter is in the pump, so it is exercised where it lives. if not options.unknown and not odd.measures: expect = 0 else: garden.add(odd) assert len(garden.all()) == expect def test_nothing_heard_says_what_to_check(tmp_path, monkeypatch): monkeypatch.setattr(wx, "open_device", lambda console, opts: Silence(2)) console = Console(width=120, force_terminal=False) with console.capture() as cap: wx.listen(console, wx.WeatherOptions(rate=RATE, messages=True), str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml")) assert "17 cm" in cap.get() def test_a_receiver_that_will_not_open_is_a_message_and_not_a_traceback( tmp_path, monkeypatch): monkeypatch.setattr(wx, "open_device", lambda console, opts: None) console = Console(width=120, force_terminal=False) with console.capture(): heard = wx.listen(console, wx.WeatherOptions(), str(tmp_path)) assert heard.sensors == 0 and heard.log_path is None # --------------------------------------------------------------------------- # The command line # --------------------------------------------------------------------------- def test_a_flag_left_off_keeps_what_was_saved_rather_than_the_factory_setting(): from bandsaunter.cli import _weather_options, build_parser args = build_parser().parse_args(["weather", "--seconds", "60"]) saved = wx.WeatherOptions(gain="40", units="imperial", hold=90.0) got = _weather_options(args, saved) assert got.seconds == 60.0 assert got.gain == "40" and got.units == "imperial" and got.hold == 90.0 @pytest.mark.parametrize("flags,key,value", [ (["--units", "imperial"], "units", "imperial"), (["--only-named"], "only_named", True), (["--all-sensors"], "only_named", False), (["--no-unknown"], "unknown", False), (["--no-log"], "log", False), (["--offset", "0"], "offset", 0.0), (["--freq", "433800000"], "frequency", 433_800_000.0), (["--csv"], "csv", True), (["--no-report"], "report", False), (["--simulate"], "simulate", True), ]) def test_every_flag_reaches_the_option_it_names(flags, key, value): from bandsaunter.cli import _weather_options, build_parser args = build_parser().parse_args(["weather"] + flags) assert getattr(_weather_options(args, wx.WeatherOptions()), key) == value def test_every_option_flag_is_one_the_parser_accepts(): """The table and the command line have to agree, or the help lies.""" from bandsaunter.cli import build_parser known = set() for action in build_parser()._subparsers._group_actions[0].choices[ "weather"]._actions: known.update(action.option_strings) for option in wx.OPTIONS: for flag in option.flags + option.off_flags: assert flag in known, f"{flag} is in the table and not the parser" def test_reading_a_log_back_prints_it_and_writes_a_spreadsheet(tmp_path, monkeypatch): from bandsaunter.cli import build_parser, cmd_readings import bandsaunter.cli as cli log = tmp_path / "weather_x.jsonl" with wl.WeatherLog(log) as fh: for i in range(4): fh.append(reading(celsius=17.0 + i, at=1_000.0 + i * 16)) console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["readings", str(log), "--csv"]) with console.capture() as cap: assert cmd_readings(args) == 0 assert "1A2B" in cap.get() assert log.with_suffix(".csv").exists() def test_reading_a_log_back_can_be_narrowed_to_one_sensor(tmp_path, monkeypatch): from bandsaunter.cli import build_parser, cmd_readings import bandsaunter.cli as cli log = tmp_path / "weather_x.jsonl" with wl.WeatherLog(log) as fh: fh.append(reading(at=1_000.0)) fh.append(reading(sensor=0x0C41, at=1_001.0)) console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["readings", str(log), "--sensor", "0C41"]) with console.capture() as cap: assert cmd_readings(args) == 0 out = cap.get() assert "0C41" in out and "1A2B" not in out def test_asking_for_a_log_that_is_not_there_is_a_message(tmp_path, monkeypatch): from bandsaunter.cli import build_parser, cmd_readings import bandsaunter.cli as cli console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["readings", str(tmp_path / "nope.jsonl")]) with console.capture() as cap: assert cmd_readings(args) == 1 assert "no such file" in cap.get() def test_the_sensor_list_names_and_forgets(tmp_path, monkeypatch): from bandsaunter.cli import build_parser, cmd_sensors import bandsaunter.cli as cli import bandsaunter.sensors as sensors where = tmp_path / "sensors.yaml" monkeypatch.setattr(sensors, "names_path", lambda directory=None: where) book = SensorBook(path=where) book.heard(reading()) book.save() console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["sensors", "--name", "1A2B=back fence", "--note", "1A2B=by the gate"]) with console.capture() as cap: assert cmd_sensors(args) == 0 assert "back fence" in cap.get() and "by the gate" in cap.get() args = build_parser().parse_args(["sensors", "--forget", "1A2B"]) with console.capture(): cmd_sensors(args) assert len(SensorBook(path=where)) == 0 # --------------------------------------------------------------------------- # The menus # --------------------------------------------------------------------------- def test_the_menu_can_be_opened_and_left(monkeypatch): import bandsaunter.tui as tui from bandsaunter.config import ScanConfig answers = iter(["b"]) monkeypatch.setattr(tui, "_ask", lambda console, prompt, default="": next(answers)) console = Console(width=120, force_terminal=False) with console.capture() as cap: tui.weather_menu(console, ScanConfig()) out = cap.get() assert "433.92 MHz" in out for group in wx.OPTION_GROUPS: assert group.lower() in out def test_the_menu_and_the_aircraft_menu_share_one_set_of_option_screens(): """One set of menus drives both sections, and will drive a third.""" import bandsaunter.tui as tui from bandsaunter import aircraft as air assert tui._section(None) is air assert tui._section(wx) is wx for module in (air, wx): assert callable(module.defaults) assert module.OPTION_GROUPS and module.OPTIONS def test_an_option_can_be_found_by_typing_part_of_its_name(): import bandsaunter.tui as tui assert [o.key for o in tui._find_options("only_named", wx)] == ["only_named"] assert [o.key for o in tui._find_options("offset", wx)] == ["offset"] assert tui._find_options("wingspan", wx) == [] def test_changing_a_weather_option_does_not_reach_the_aircraft_ones(): """The two sections number their options separately, so this could bite.""" import bandsaunter.tui as tui console = Console(width=120, force_terminal=False) options = wx.WeatherOptions() with console.capture() as cap: tui._edit_option(console, options, "?1", wx) assert wx.OPTIONS[0].label in cap.get() # --------------------------------------------------------------------------- # Naming a sensor while it is on the screen # --------------------------------------------------------------------------- class FakeLive: """The live display, which has to come down while a name is typed.""" def __init__(self): self.running = True self.stopped = 0 def stop(self): self.running = False self.stopped += 1 def start(self): self.running = True def update(self, _renderable): pass class FakeDisplay: """The table, which the loop keeps up to date whether or not it is seen.""" def __init__(self): self.blocks = 0 def update(self, garden, messages, log_path=None): self.blocks += 1 def render(self, *a, **kw): return "" class FakeKeys: """A keyboard that presses whatever it was handed, one key at a time.""" def __init__(self, presses=()): self.presses = list(presses) self.raw = False self.history = [] def __enter__(self): self.raw = True self.history.append("raw") return self def __exit__(self, *exc): self.raw = False self.history.append("normal") return False def get(self): return self.presses.pop(0) if self.presses else None def answers(monkeypatch, *replies): """Whatever the prompts ask, in order.""" said = iter(replies) monkeypatch.setattr("rich.prompt.Prompt.ask", lambda *a, **kw: next(said, "")) def test_a_sensor_can_be_named_while_it_is_on_the_screen(book, monkeypatch): garden = wx.Garden() now = time.time() garden.add(reading(at=now - 10)) # heard first, so row 1 garden.add(reading(sensor=0x0C41, at=now)) # row 2 answers(monkeypatch, "2", "greenhouse") console = Console(width=120, force_terminal=False) live, keys = FakeLive(), FakeKeys() with console.capture() as cap: named = wx._name_one(console, keys, live, None, book, garden, wx.WeatherOptions()) assert named is True assert book.name_for("tower/0C41") == "greenhouse" assert SensorBook(path=book.path).name_for("tower/0C41") == "greenhouse" assert "1A2B" in cap.get() and "0C41" in cap.get() def test_the_display_comes_down_and_the_keyboard_goes_back_to_normal( book, monkeypatch): """Neither is optional: nothing typed would be visible otherwise. The terminal reads single keys while the display is running, which is how n is noticed at all, and a name typed in that mode is not echoed. So the terminal has to come out of it and go back in, in that order. """ garden = wx.Garden() garden.add(reading(at=time.time())) answers(monkeypatch, "1", "back fence") live, keys = FakeLive(), FakeKeys() keys.__enter__() console = Console(width=120, force_terminal=False) with console.capture(): wx._name_one(console, keys, live, None, book, garden, wx.WeatherOptions()) assert live.stopped == 1 and live.running is True assert keys.history == ["raw", "normal", "raw"] assert keys.raw is True def test_the_receiver_is_not_stopped_while_a_name_is_typed(book, monkeypatch): """A slow typist loses a few seconds of weather and nothing else.""" garden = wx.Garden() garden.add(reading(at=time.time())) answers(monkeypatch, "1", "back fence") live, keys = FakeLive(), FakeKeys() console = Console(width=120, force_terminal=False) with console.capture(): wx._name_one(console, keys, live, None, book, garden, wx.WeatherOptions()) # Nothing here closes or pauses a device; the only thing that stops is # the drawing. The listening loop calls this between two blocks. assert live.running is True @pytest.mark.parametrize("said", [("",), ("9",), ("x",), ("1", "")]) def test_changing_your_mind_about_a_name_leaves_things_as_they_were( book, monkeypatch, said): garden = wx.Garden() garden.add(reading(at=time.time())) answers(monkeypatch, *said) console = Console(width=120, force_terminal=False) with console.capture(): named = wx._name_one(console, FakeKeys(), FakeLive(), None, book, garden, wx.WeatherOptions()) assert named is False assert len(book) == 0 def test_naming_before_anything_is_heard_says_so(book, monkeypatch): answers(monkeypatch, "1", "back fence") console = Console(width=120, force_terminal=False) with console.capture() as cap: named = wx._name_one(console, FakeKeys(), FakeLive(), None, book, wx.Garden(), wx.WeatherOptions()) assert named is False and "nothing heard yet" in cap.get() def test_pressing_n_while_listening_is_what_starts_the_naming(tmp_path, monkeypatch): """The whole point of the key, wired end to end through the listen loop.""" import bandsaunter.ui as ui # Nothing for the first few blocks, because there is nothing to name # until a sensor has spoken, and then the key. keys = FakeKeys(presses=[None] * 6 + ["n"]) monkeypatch.setattr(ui, "KeyReader", lambda *a, **kw: keys) monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(40)) monkeypatch.setattr(wx, "_open_display", lambda *a, **kw: (FakeDisplay(), FakeLive())) answers(monkeypatch, "1", "back fence") book = SensorBook(path=tmp_path / "sensors.yaml") console = Console(width=120, force_terminal=False) options = wx.WeatherOptions(rate=RATE, offset=OFFSET) with console.capture(): heard = wx.listen(console, options, str(tmp_path), book=book) assert heard.named == 1 assert any(s.named for s in book.ordered()) def test_pressing_q_while_listening_stops_it(tmp_path, monkeypatch): import bandsaunter.ui as ui device = Garden6(600) monkeypatch.setattr(ui, "KeyReader", lambda *a, **kw: FakeKeys(presses=["q"])) monkeypatch.setattr(wx, "open_device", lambda console, opts: device) monkeypatch.setattr(wx, "_open_display", lambda *a, **kw: (FakeDisplay(), FakeLive())) console = Console(width=120, force_terminal=False) with console.capture(): wx.listen(console, wx.WeatherOptions(rate=RATE, offset=OFFSET), str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml")) assert device.left > 590, "q did not stop it within a couple of blocks" def test_a_name_given_before_the_sensor_was_heard_moves_across_when_it_is( book): """Naming from the command line and then listening is the obvious order. The name is filed under the identity alone until the first message says which model it is; this is the moment it moves. Getting it wrong leaves the shed and the sensor on the shed listed as two separate things, one named and never heard, the other heard and never named. """ from bandsaunter.cli import _name_sensors _name_sensors(book, ["1A2B=back fence"], quiet=True) assert "?/1A2B" in book book.heard(reading(at=1_000.0)) assert "?/1A2B" not in book assert book.name_for("tower/1A2B") == "back fence" assert [s.key for s in book.ordered()] == ["tower/1A2B"] assert book.get("tower/1A2B").messages == 1 def test_a_waiting_name_survives_being_saved_and_read_back(book): from bandsaunter.cli import _name_sensors _name_sensors(book, ["5C=shed"], quiet=True) again = SensorBook(path=book.path) again.heard(reading("tower", sensor=0x5C, at=1_000.0)) assert again.name_for("tower/005C") == "" # a different identity again.heard(a_609_reading()) assert again.name_for("609/5C") == "shed" def a_609_reading(at=1_000.0): got = a.decode(a.frame_609(0x5C, 4.2, 80), confirm=False) got.at = at return got def test_a_waiting_name_does_not_get_handed_to_the_wrong_sensor(book): """Only an identity that matches claims it, not the next thing heard.""" from bandsaunter.cli import _name_sensors _name_sensors(book, ["1A2B=back fence"], quiet=True) book.heard(reading(sensor=0x0C41, at=1_000.0)) assert book.name_for("tower/0C41") == "" assert book.name_for("?/1A2B") == "back fence" # --------------------------------------------------------------------------- # Saying what is arriving, for when nothing is # --------------------------------------------------------------------------- def test_the_diagnosis_says_what_each_second_of_band_looked_like(tmp_path, monkeypatch): options = wx.WeatherOptions(rate=RATE, offset=OFFSET, diagnose=True, log=False) monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(30)) console = Console(width=140, force_terminal=False) with console.capture() as cap: wx.listen(console, options, str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml")) out = cap.get() assert "noise" in out and "gate" in out and "the noise" in out assert "pulses" in out and "gaps" in out # The pulse lengths are the useful part: two or three, and nothing # in between. assert "×" in out assert "Tower 592TXR" in out def test_the_diagnosis_turns_the_live_table_off(tmp_path): """The two cannot share a screen: one redraws in place, one scrolls.""" console = Console(width=120, force_terminal=True) display, live = wx._open_display( console, wx.WeatherOptions(diagnose=True), None, time.time()) assert (display, live) == (None, None) def test_raw_samples_can_be_captured_for_working_out_why(tmp_path, monkeypatch): options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True, log=False) monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(3)) where = tmp_path / "band.cf32" console = Console(width=120, force_terminal=False) with console.capture() as cap: heard = wx.listen(console, options, str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml"), save_iq=str(where)) assert heard.iq_path == where and where.exists() kept = np.fromfile(where, dtype=np.complex64) assert kept.size == 3 * int(RATE) # And it says how fast that fills, because it fills fast. assert "MB a second" in cap.get() def test_capturing_to_somewhere_unwritable_is_a_message_not_a_crash( tmp_path, monkeypatch): options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True, log=False) monkeypatch.setattr(wx, "open_device", lambda console, opts: Silence(2)) console = Console(width=120, force_terminal=False) with console.capture() as cap: heard = wx.listen(console, options, str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml"), save_iq="/proc/nowhere/band.cf32") assert heard.iq_path is None assert "cannot write" in cap.get() def test_an_offset_too_big_for_the_sample_rate_is_refused_with_a_reason(): """Half the sample rate is all the band there is to move a signal within.""" errs = wx.WeatherOptions(rate=400_000.0, offset=250_000.0).validate() assert any("offset" in e and "sample rate" in e for e in errs) assert wx.WeatherOptions(rate=400_000.0, offset=100_000.0).validate() == [] def test_hearing_nothing_points_at_the_diagnosis(tmp_path, monkeypatch): from bandsaunter.cli import build_parser, cmd_weather import bandsaunter.cli as cli monkeypatch.setattr(wx, "open_device", lambda console, opts: Silence(2)) monkeypatch.setattr(wx, "load_options", lambda *a, **kw: wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True, log=False)) console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["weather"]) with console.capture() as cap: assert cmd_weather(args) == 1 assert "--diagnose" in cap.get() @pytest.mark.parametrize("flags,key,value", [ (["--diagnose"], "diagnose", True), (["--no-diagnose"], "diagnose", False), ]) def test_the_diagnosis_flags_reach_the_option(flags, key, value): from bandsaunter.cli import _weather_options, build_parser args = build_parser().parse_args(["weather"] + flags) assert getattr(_weather_options(args, wx.WeatherOptions()), key) == value def test_save_iq_is_a_path_on_the_command_line_and_not_a_saved_setting(): """It is a one-off capture, not something to carry between runs.""" from bandsaunter.cli import build_parser args = build_parser().parse_args(["weather", "--save-iq", "/tmp/x.cf32"]) assert args.save_iq == "/tmp/x.cf32" assert not hasattr(wx.WeatherOptions(), "save_iq") # --------------------------------------------------------------------------- # The verdict: which of the four faults it was # --------------------------------------------------------------------------- def verdict_of(**tally): heard = wx.Heard() heard.survey.update(tally) console = Console(width=120, force_terminal=False) with console.capture() as cap: wx._verdict(console, heard) return " ".join(cap.get().split()) @pytest.mark.parametrize("tally,says", [ # Nothing arrived at all: not the decoder's fault, and not the aerial's. (dict(blocks=30, dead=30), "empty samples"), # Nothing above the noise: the aerial. (dict(blocks=30), "aerial"), # Something there, never keyed: not one of these sensors. (dict(blocks=30, loud=12), "continuously"), # Sliced and never framed: the radio works, the protocol is not one of # the five. This is the one where the pulse lengths matter. (dict(blocks=30, loud=12, bursts=40), "cannot read"), # Framed and never corroborated: a stronger signal, not a new decoder. (dict(blocks=30, loud=12, bursts=40, framed=9), "second copy"), (dict(blocks=30, loud=12, bursts=40, framed=9, reported=6), "Working"), ]) def test_the_verdict_tells_the_four_faults_apart(tally, says): assert says in verdict_of(**tally) def test_the_verdict_says_nothing_when_there_was_nothing_to_judge(): assert verdict_of() == "" def test_the_verdict_is_given_even_when_nothing_was_heard(tmp_path, monkeypatch): """Which is exactly the run whose verdict is worth reading.""" monkeypatch.setattr(wx, "open_device", lambda console, opts: Silence(3)) console = Console(width=120, force_terminal=False) with console.capture() as cap: heard = wx.listen(console, wx.WeatherOptions(rate=RATE, offset=OFFSET, diagnose=True, log=False), str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml")) assert heard.sensors == 0 assert "what that came to" in cap.get() assert "empty samples" in cap.get() def test_a_working_run_is_counted_as_one(tmp_path, monkeypatch): monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(30)) console = Console(width=120, force_terminal=False) with console.capture() as cap: heard = wx.listen(console, wx.WeatherOptions(rate=RATE, offset=OFFSET, diagnose=True, log=False, report=False), str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml")) assert "Working" in cap.get() assert heard.survey["reported"] == heard.messages assert heard.survey["blocks"] == 30 # --------------------------------------------------------------------------- # Working on a capture instead of on the air # --------------------------------------------------------------------------- def captured(tmp_path, seconds=3, rate=RATE, offset=OFFSET): """A recording of the invented garden, with its settings beside it.""" import json sky = a.SimulatedSensors(sample_rate=rate, offset=offset, seed=3) where = tmp_path / "band.cf32" with open(where, "wb") as fh: for _ in range(seconds): sky.read_samples(int(rate)).astype("complex64").tofile(fh) where.with_suffix(".cf32.json").write_text(json.dumps({ "sample_rate": rate, "frequency": a.ACURITE_HZ, "offset": offset, "format": "complex64"})) return where def test_a_capture_answers_the_way_the_receiver_that_made_it_did(tmp_path): where = captured(tmp_path, seconds=4) replay = wx.Replay(where) assert replay.rate == RATE and replay.offset == OFFSET assert replay.frequency == a.ACURITE_HZ assert len(replay) == 4 * int(RATE) assert replay.seconds == pytest.approx(4.0) assert replay.read_samples(int(RATE)).size == int(RATE) def test_a_capture_runs_out_rather_than_repeating_itself(tmp_path): replay = wx.Replay(captured(tmp_path, seconds=2)) assert replay.read_samples(int(RATE)).size == int(RATE) assert replay.read_samples(int(RATE)).size == int(RATE) assert replay.read_samples(int(RATE)).size == 0 def test_the_settings_are_written_beside_a_capture(tmp_path, monkeypatch): """A file of samples with no idea what rate it was taken at is nothing. Read back at the wrong rate every pulse in it is the wrong length, and nothing will ever decode however good the decoder is. """ import json options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True, log=False) monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(2)) where = tmp_path / "band.cf32" console = Console(width=120, force_terminal=False) with console.capture(): wx.listen(console, options, str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml"), save_iq=str(where)) beside = json.loads(where.with_suffix(".cf32.json").read_text()) assert beside["sample_rate"] == RATE and beside["offset"] == OFFSET assert beside["format"] == "complex64" assert wx.Replay(where).rate == RATE def test_a_capture_with_no_settings_beside_it_falls_back_rather_than_failing( tmp_path): where = captured(tmp_path, seconds=2) where.with_suffix(".cf32.json").unlink() replay = wx.Replay(where, rate=RATE, offset=OFFSET) assert replay.rate == RATE and replay.settings == {} def test_the_same_readings_come_out_of_a_capture_as_off_the_air(tmp_path, monkeypatch): """The only thing stood in for is the dongle, so it has to be the same. That is the whole use of a capture: a recording that yields nothing here yields nothing for anybody and the fault is in this program, and one that yields readings here and not on the air is a setting. """ where = captured(tmp_path, seconds=6) console = Console(width=120, force_terminal=False) options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True, log=False, report=False) monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(6)) with console.capture(): live = wx.listen(console, options, str(tmp_path), book=SensorBook(path=tmp_path / "a.yaml")) with console.capture(): back = wx.listen(console, options, str(tmp_path), device=wx.Replay(where), book=SensorBook(path=tmp_path / "b.yaml")) assert {s.key for s in back.garden.all()} == {s.key for s in live.garden.all()} assert back.messages == live.messages def test_a_capture_can_be_replayed_from_the_command_line(tmp_path, monkeypatch): from bandsaunter.cli import build_parser, cmd_weather import bandsaunter.cli as cli where = captured(tmp_path, seconds=6) monkeypatch.setattr(wx, "load_options", lambda *a, **kw: wx.WeatherOptions(messages=True, log=False, report=False)) console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["weather", "--from-iq", str(where)]) with console.capture() as cap: assert cmd_weather(args) == 0 out = cap.get() assert "replaying" in out and "Tower 592TXR" in out def test_replaying_something_that_is_not_a_capture_is_a_message(tmp_path, monkeypatch): from bandsaunter.cli import build_parser, cmd_weather import bandsaunter.cli as cli empty = tmp_path / "nothing.cf32" empty.write_bytes(b"") console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["weather", "--from-iq", str(empty)]) with console.capture() as cap: assert cmd_weather(args) == 1 assert "no samples" in cap.get() def test_the_capture_settings_win_over_whatever_was_saved(tmp_path, monkeypatch): """Read at the wrong rate, every pulse in it is the wrong length.""" from bandsaunter.cli import build_parser, cmd_weather import bandsaunter.cli as cli where = captured(tmp_path, seconds=3) monkeypatch.setattr(wx, "load_options", lambda *a, **kw: wx.WeatherOptions( rate=1_024_000.0, offset=250_000.0, messages=True, log=False, report=False)) console = Console(width=120, force_terminal=False) monkeypatch.setattr(cli, "console", console) args = build_parser().parse_args(["weather", "--from-iq", str(where)]) with console.capture() as cap: cmd_weather(args) assert f"{RATE / 1e6:g} MS/s" in cap.get() # --------------------------------------------------------------------------- # Finding out where the sensors actually are # --------------------------------------------------------------------------- # Above about half a megasample a second there is room to filter, and a # filter is applied -- which is the only circumstance in which the tuning # offset does anything at all. At the default rate nothing after the mixer # is narrower than the band, and turning a number does not change its size. WIDE = 1_024_000.0 STEP = WIDE / 4.0 def test_the_tuning_offset_does_nothing_at_the_default_sample_rate(): """Which is worth a test, because the option says so and people read it.""" iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), RATE, offset=0.0, noise=0.02) straight = [r.bits for r in a.readings_from(iq, RATE, 0.0)] shifted = [r.bits for r in a.readings_from(iq, RATE, RATE / 4.0)] assert straight and straight == shifted def test_the_diagnosis_finds_the_offset_that_would_have_worked(): """Rather than asking somebody to try four combinations and report back. Which way round a dongle presents its samples is not knowable from here and is perfectly findable, so when a second plainly held something and the configured setting read none of it, the others are tried. """ iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), WIDE, offset=STEP, noise=0.02) looking_ahead = wx.WeatherOptions(rate=WIDE, offset=0.0) assert a.readings_from(iq, WIDE, 0.0) == [] assert wx._other_settings(iq, looking_ahead) == f"--offset {STEP:.0f}" def test_it_does_not_go_looking_when_the_setting_is_already_working(): iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), WIDE, offset=STEP, noise=0.02) options = wx.WeatherOptions(rate=WIDE, offset=STEP) assert a.readings_from(iq, WIDE, STEP) != [] def test_the_verdict_says_which_offset_to_use(tmp_path, monkeypatch): step = STEP class Misplaced: """A garden that is not where the receiver is looking.""" def __init__(self, blocks): self.sky = a.SimulatedSensors(sample_rate=WIDE, offset=step, seed=3) self.left = blocks def tune(self, hz, settle=True): return int(hz) def read_samples(self, count, flush=False): if self.left <= 0: return np.zeros(0, dtype=np.complex64) self.left -= 1 return self.sky.read_samples(count) def close(self): return None monkeypatch.setattr(wx, "open_device", lambda console, opts: Misplaced(25)) console = Console(width=140, force_terminal=False) with console.capture() as cap: heard = wx.listen(console, wx.WeatherOptions(rate=WIDE, offset=0.0, diagnose=True, log=False, report=False), str(tmp_path), book=SensorBook(path=tmp_path / "s.yaml")) out = cap.get() assert heard.sensors == 0 assert "try this" in out assert f"--offset {step:.0f}" in out # --------------------------------------------------------------------------- # The invented garden keeps time # --------------------------------------------------------------------------- def test_the_invented_garden_hands_back_a_second_once_a_second_has_passed(): """It stands in for a dongle, so it waits like one. Without the wait a block comes back the moment it is asked for, the garden lives several times faster than the clock its readings are stamped with, and a capture bounded by --seconds comes out however many times too large the machine happens to be worth. """ import time as clock sky = a.SimulatedSensors(sample_rate=RATE, realtime=True) began = clock.monotonic() for _ in range(3): sky.read_samples(int(RATE / 10)) # a tenth of a second each took = clock.monotonic() - began assert 0.15 < took < 0.6, f"three tenths of a second took {took:.2f}s" def test_a_test_garden_does_not_wait_at_all(): import time as clock sky = a.SimulatedSensors(sample_rate=RATE, realtime=False) began = clock.monotonic() for _ in range(3): sky.read_samples(int(RATE)) assert clock.monotonic() - began < 2.0 def test_the_digital_gain_control_after_the_tuner_is_left_off(monkeypatch): """The tuner's own control does its job; the one after it pumps. It winds the gain up through the silence between one burst and the next, which lifts the noise towards the signal and squeezes the very difference the burst detector works on. It matters here in a way it does not for aircraft, where a frame is found by correlating a preamble over a few microseconds rather than by comparing a burst with the quiet around it. The established tools for this band leave it off, and so does this. """ import bandsaunter.device as device asked = {} class Fake: def __init__(self, **kw): asked.update(kw) def open(self): return self monkeypatch.setattr(device, "RtlSdrDevice", Fake) console = Console(width=120, force_terminal=False) for gain in ("auto", "40"): wx.open_device(console, wx.WeatherOptions(gain=gain)) assert asked["agc"] is False, f"digital AGC on with gain {gain!r}" assert asked["gain"] == gain # the tuner's own is still asked for def test_the_defaults_are_the_ones_the_established_tools_use(): """250 kS/s, straight at 433.92 MHz, no offset. Not a matter of taste: this is the configuration known to receive these sensors on hardware this program has never run on, and differing from it bought nothing and cost everything. """ options = wx.WeatherOptions() assert options.rate == 250_000.0 assert options.offset == 0.0 assert options.frequency == a.ACURITE_HZ # --------------------------------------------------------------------------- # How well each sensor is being heard # --------------------------------------------------------------------------- def loud(strength, sensor=0x1A2B, at=1_000.0): got = reading(sensor=sensor, at=at) got.snr = strength return got def test_a_station_keeps_the_latest_the_best_and_the_worst_strength(): garden = wx.Garden() for i, strength in enumerate((30.0, 18.0, 24.0)): garden.add(loud(strength, at=1_000.0 + i * 16)) station = garden.stations["tower/1A2B"] assert station.snr == 24.0 # the latest, which is what is shown assert station.best_snr == 30.0 assert station.worst_snr == 18.0 def test_a_reading_with_no_strength_does_not_wipe_the_one_before_it(): """Logs written before this existed read back with nothing in them.""" garden = wx.Garden() garden.add(loud(30.0, at=1_000.0)) garden.add(reading(at=1_016.0)) # snr 0, meaning unknown assert garden.stations["tower/1A2B"].snr == 30.0 def test_what_share_of_a_sensors_messages_is_arriving_is_worked_out(): """The shortest wait between two of its messages is its cycle; the average wait is that cycle divided by the share that gets through.""" garden = wx.Garden() # Sends every 16 s; every other one arrives. for i, at in enumerate((0.0, 16.0, 48.0, 64.0, 96.0, 112.0)): garden.add(loud(30.0, at=1_000.0 + at)) station = garden.stations["tower/1A2B"] assert station.closest == pytest.approx(16.0) assert station.share == pytest.approx(16.0 / station.gap, abs=0.01) assert 0.6 < station.share < 0.8 def test_a_sensor_heard_every_time_is_reported_as_heard_every_time(): garden = wx.Garden() for i in range(8): garden.add(loud(30.0, at=1_000.0 + i * 16.0)) assert garden.stations["tower/1A2B"].share == pytest.approx(1.0) def test_the_share_says_nothing_until_there_is_something_to_say(): garden = wx.Garden() for i in range(3): garden.add(loud(30.0, at=1_000.0 + i * 16.0)) assert garden.stations["tower/1A2B"].share == 0.0 @pytest.mark.parametrize("strength,colour", [ (8.0, "red"), (18.0, "yellow"), (34.0, "green"), ]) def test_the_strength_is_coloured_so_an_aerial_can_be_aimed_by_it(strength, colour): assert colour in wx.signal_text(strength) assert f"{strength:.0f} dB" in wx.signal_text(strength) def test_no_strength_is_shown_as_nothing_rather_than_as_zero(): assert wx.signal_text(0.0) == "" def test_a_signal_well_below_its_best_says_what_its_best_was(): """Which is the difference between a sensor that has moved and one that was always like that.""" assert "best 34" in wx.signal_text(20.0, 34.0) assert "best" not in wx.signal_text(32.0, 34.0) def test_the_strength_survives_the_log_and_reaches_the_spreadsheet(tmp_path, book): log = wl.WeatherLog(tmp_path / "weather_x.jsonl") log.append(loud(27.4, at=1_000.0)) log.close() back = wl.read_logs([log.path]) assert back[0].snr == pytest.approx(27.4) where = wl.write_csv(tmp_path / "w.csv", back, book) head, row = where.read_text().splitlines()[:2] assert "signal (dB)" in head assert row.split(",")[head.split(",").index("signal (dB)")] == "27.4" def test_an_older_log_without_strengths_still_reads(tmp_path, book): log = wl.WeatherLog(tmp_path / "weather_x.jsonl") log.append(reading(at=1_000.0)) # nothing to record log.close() assert "snr" not in log.path.read_text().splitlines()[1] assert wl.read_logs([log.path])[0].snr == 0.0 def test_the_report_says_how_strong_and_how_complete(book): garden = wx.Garden() for i in range(8): garden.add(loud(31.0, at=1_000.0 + i * 16.0)) out = rendered(garden, book) assert "31 dB" in out and "100%" in out def test_the_display_keeps_the_strength_on_a_narrow_terminal(book): """It is what somebody moving an aerial about is watching, and they are not doing it on a wide window.""" garden = wx.Garden() now = time.time() garden.add(loud(29.0, at=now)) for width in (68, 84, 120): out = shown(garden, book, width=width, now=now) assert "29 dB" in out, f"lost at {width} columns" assert max(len(line) for line in out.splitlines()) <= width