A consumer weather station is two things. The display on the kitchen wall is one of them; the other is a plastic box on a fence post that says what it can see every sixteen seconds, in the clear, to anyone who happens to be listening. This reads the box. A section of its own, like the aircraft one, and for the same reason: it does not fit through the scanner. A sensor message is a burst of a carrier switched on and off, a fifth of a second long, and the scan path is a squelch and a recorder -- it would record the bursts as clicks in a WAV file and decode nothing. `bandsaunter weather` listens, `bandsaunter readings` reads a log back, `bandsaunter sensors` says what is out there. Item 6 in the main menu is the same thing without a command line. Five families: the Tower 592TXR, the 5-in-1, the 6045M lightning detector, the 609TXC and the 606TX. Temperature, humidity, wind speed and direction, rainfall, strike counts, how far off the storm is, and battery state from all of them. Every one is implemented from its published description and checked against frames built from the same description, which proves the framing, the parity, the checksums and the arithmetic and is not the same as having held one of each. The naming is the point. A sensor broadcasts an identity, and that identity is a number that came out of a hat in a factory; it tells one sensor from another and is no use at all for telling which is which. So press n while listening: the display comes down, the sensors are listed, you name one, and it goes back up, with the receiver running throughout. That is the moment it is possible -- the sensor is on the screen saying 3.1 degrees, and the person watching is the one who knows that the cold one is the shed. An hour later it is a list of hexadecimal again. Names are written the instant they are given rather than at exit, to a neighbouring file renamed over the old one, and one given before a sensor has ever been heard waits under its identity and moves across when the first message says which model it is. Four things keep the neighbours' doorbells off the display. The checks the message carries; a second copy, for the two models that carry only one byte of check between them; a plausibility range, because a checksum can be satisfied by a message the hardware could not send; and where in the burst the message sits. That last one is the one that is easy to miss: a seven-byte message read out of the front of a real eight-byte one is made of that message's own payload bytes, whose parity is already correct, so the parity bits contribute nothing and one byte of sum is all that is left -- and corroboration cannot help, the three copies being identical. What gives that window away every time is that it ends a whole byte before the burst does. The Atlas is nine bytes like the lightning detector and lays its payload out differently, so every decoder insists on a message type it knows. Anything else that frames correctly is reported with its identity and no weather, because wrong weather under somebody's sensor name is a worse answer than none. ism.py now delegates to this rather than keeping a second implementation of the tower sensor, which fixes the channel letters -- A is 3, B is 2, C is 0, and there is no D -- and the battery bit, which is set while the battery is good. The two thinly-checked models are not reported from a scan at all: a scan hears one burst, and they need two. The option menus are now handed the module that owns the options rather than importing the aircraft one, so one set of screens drives both sections and will drive a third. 169 new tests, checked against nineteen deliberately broken builds; two of the tests were too weak to notice their own mutation and were rewritten. Full suite 2252 passed. Built as 2026-09-07_01. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
1075 lines
40 KiB
Python
1075 lines
40 KiB
Python
"""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"
|