Read the weather sensors on 433 MHz, and let them be given names

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
This commit is contained in:
The Dust Council 2026-09-07 13:40:33 -07:00
parent f01de4117f
commit 65cc03b78d
18 changed files with 6006 additions and 123 deletions

View file

@ -24,7 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
from .ranges import RangeError, ScanRange, parse_frequency
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
"aircraft_menu", "first_run_setup", "TUIAbort"]
"aircraft_menu", "weather_menu", "first_run_setup", "TUIAbort"]
_BACK = ("", "b", "back", "q", "quit", "x")
@ -646,13 +646,36 @@ A picture takes minutes rather than seconds, so 'Max record time' has to be
long enough or what arrives is the top of one. A partial picture is kept and
labelled partial.
Utility meters on 900 MHz and AcuRite weather sensors on 433 MHz are named
rather than reported as hexadecimal, and neither is believed without its own
checksum.
Utility meters on 900 MHz are named rather than reported as hexadecimal, and
are not believed without their own checksum.
Aircraft are a separate command: `bandsaunter adsb` parks the receiver on
1090 MHz. ADS-B is a megabit a second and will not go through a channel
twelve and a half kilohertz wide, which is what a scan is made of."""),
Two things are separate commands, because neither fits through a scan.
`bandsaunter adsb` parks the receiver on 1090 MHz: ADS-B is a megabit a
second and will not go through a channel twelve and a half kilohertz wide.
`bandsaunter weather` parks it on 433.92 MHz for the weather sensors, whose
messages are bursts of a carrier switched on and off, which a scan records as
clicks."""),
"13": ("Weather sensors on 433 MHz", """
The plastic box on a fence post that came with a consumer weather station
broadcasts what it can see every sixteen seconds, in the clear, on 433.92
MHz. `bandsaunter weather` reads it, and reads five families of them:
Tower 592TXR temperature, humidity
5-in-1 06014RM wind speed, wind direction, rainfall, temperature, humidity
Lightning 6045M temperature, humidity, strike count, how far off the storm is
609TXC temperature, humidity
606TX temperature
Battery state comes from all of them. Nothing is reported that has not
satisfied its own checksum and, on the older two models, arrived twice.
The identity in the message is a number that came out of a hat in a factory,
so press n while listening to name whichever sensor is on the screen -- the
shed, the greenhouse -- and it keeps the name from then on. Names live in
sensors.yaml beside the settings and can be edited by hand.
`bandsaunter readings --csv` turns a log into a spreadsheet: a column per
quantity, a row per reading, the name in the second column."""),
"11": ("Keys during a scan", """
q stop the scan
p pause and resume
@ -687,11 +710,27 @@ _AIRCRAFT_INTRO = (
)
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
def _section(section=None):
"""The module that owns a set of options: aircraft unless told otherwise.
Every helper below works off ``OPTIONS``, ``OPTION_GROUPS``, ``in_group``,
``defaults`` and ``format_option``, which both sections provide and which
say nothing about aircraft or weather. That is what lets one set of
menus drive both, and what will let it drive a third.
"""
if section is not None:
return section
from . import aircraft as air
return air
def _options_table(console: Console, options, group: str,
section=None) -> list[st.Setting]:
air = _section(section)
items = air.in_group(group)
default = air.AircraftOptions()
default = air.defaults()
t = Table(box=None, header_style="bold", pad_edge=False,
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
@ -782,15 +821,15 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
_pick_option(console, options)
def _option_groups(console: Console, options) -> None:
def _option_groups(console: Console, options, section=None) -> None:
"""The groups, and how many of each has been changed from the default.
A list of six lines rather than a table of thirty-three: the options
are all still there, and this is the way in to them.
"""
from . import aircraft as air
air = _section(section)
default = air.AircraftOptions()
default = air.defaults()
t = Table(box=None, header_style="bold", pad_edge=False,
title="[bold]options[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
@ -810,7 +849,7 @@ def _option_groups(console: Console, options) -> None:
console.print(t)
def _find_options(text: str) -> list:
def _find_options(text: str, section=None) -> list:
"""Every option this could mean, nearest match first.
An exact name wins outright. Typing "seconds" should reach the setting
@ -818,7 +857,7 @@ def _find_options(text: str) -> list:
to mention the word -- so a name that matches exactly is the answer, and
the wider search is only what happens when nothing does.
"""
from . import aircraft as air
air = _section(section)
wanted = text.strip().lower()
if not wanted:
@ -832,11 +871,12 @@ def _find_options(text: str) -> list:
or wanted in o.help.lower()]
def _option_list(console: Console, options, items, title: str) -> None:
def _option_list(console: Console, options, items, title: str,
section=None) -> None:
"""One table of whichever options were asked for."""
from . import aircraft as air
air = _section(section)
default = air.AircraftOptions()
default = air.defaults()
t = Table(box=None, header_style="bold", pad_edge=False,
title=f"[bold]{title}[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
@ -852,24 +892,25 @@ def _option_list(console: Console, options, items, title: str) -> None:
console.print(t)
def _pick_option(console: Console, options) -> None:
def _pick_option(console: Console, options, section=None) -> None:
"""Ask which of the options just listed to change, and change it."""
console.print("[grey62]Enter an option number to change it, "
"[cyan]?N[/cyan] for what it does, or blank to go back."
"[/grey62]")
answer = _ask(console, " option").strip().lower()
if answer and answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer)
_edit_option(console, options, answer, section)
def _option_group_menu(console: Console, options, group: str) -> None:
def _option_group_menu(console: Console, options, group: str,
section=None) -> None:
"""One group of options, on a screen of its own."""
from . import aircraft as air
air = _section(section)
while True:
_rule(console, group.lower())
items = air.in_group(group)
_option_list(console, options, items, group.lower())
_option_list(console, options, items, group.lower(), air)
console.print("\n[grey62]Enter an option number to change it, "
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
"to go back.[/grey62]")
@ -877,15 +918,16 @@ def _option_group_menu(console: Console, options, group: str) -> None:
if not answer or answer in _BACK:
return
if answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer)
_edit_option(console, options, answer, air)
else:
console.print(" [yellow]enter a number from the list, "
"or b[/yellow]")
def _edit_option(console: Console, options, answer: str) -> None:
def _edit_option(console: Console, options, answer: str,
section=None) -> None:
"""Change one option, or explain it when asked with a question mark."""
from . import aircraft as air
air = _section(section)
want_help = answer.startswith("?")
index = int(answer.lstrip("?").strip())
@ -894,15 +936,16 @@ def _edit_option(console: Console, options, answer: str) -> None:
return
option = air.OPTIONS[index - 1]
if want_help:
option_help(console, option, options)
option_help(console, option, options, air)
else:
edit_setting(console, option, options,
default=air.AircraftOptions(), show_help=option_help)
edit_setting(console, option, options, default=air.defaults(),
show_help=lambda c, o, v: option_help(c, o, v, air))
def option_help(console: Console, option: st.Setting, options) -> None:
"""The same help panel the settings menu shows, for an aircraft option."""
from . import aircraft as air
def option_help(console: Console, option: st.Setting, options,
section=None) -> None:
"""The same help panel the settings menu shows, for a section option."""
air = _section(section)
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
"", option.help.capitalize() + "."]
@ -910,7 +953,7 @@ def option_help(console: Console, option: st.Setting, options) -> None:
body += ["", option.detail]
if option.guidance and option.guidance != option.detail:
body += ["", f"[grey62]{option.guidance}[/grey62]"]
default = air.AircraftOptions()
default = air.defaults()
body += ["", f"[grey62]now:[/grey62] "
f"{air.format_option(option, getattr(options, option.key))}"
f" [grey62]default:[/grey62] "
@ -927,6 +970,216 @@ def option_help(console: Console, option: st.Setting, options) -> None:
border_style="blue", padding=(0, 1)))
# ---------------------------------------------------------------------------
# Weather sensors
# ---------------------------------------------------------------------------
_WEATHER_INTRO = (
"Every consumer weather station has a plastic box on a fence post which "
"says what it can see, in the clear, on 433.92 MHz, every sixteen "
"seconds \u2014 to the display in the kitchen and to anyone else "
"listening. This reads the box.\n\n"
"Temperature and humidity from all of them; wind speed, wind direction "
"and rainfall from a 5-in-1; lightning strikes and how far off the storm "
"is from a 6045M. Battery state from every one.\n\n"
"What a sensor cannot tell you is which sensor it is: the identity in "
"the message came out of a hat in a factory. So [bold]press n while "
"listening[/bold] to give whichever is on the screen a name \u2014 the "
"shed, the greenhouse, the back fence \u2014 and it keeps it from then "
"on, in the display, in the log and in the spreadsheet."
)
def weather_menu(console: Console, cfg: ScanConfig) -> None:
"""Listen to the weather sensors, and name them, without a command line."""
from . import weather as wx
from .sensors import SensorBook
options = wx.load_options()
while True:
_rule(console, "weather sensors (433 MHz)")
console.print(Panel(Text.from_markup(_WEATHER_INTRO),
border_style="blue", padding=(0, 1)))
_option_groups(console, options, wx)
logs = wx.logs_in(cfg.output_dir)
book = SensorBook()
kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
named = sum(1 for s in book.ordered() if s.named)
known = (f"{len(book)} heard, {named} named" if len(book)
else "none heard yet")
console.print(
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
f" [grey62]{wx.describe(options)}[/grey62]\n"
f" [cyan]n[/cyan] Name the sensors [grey62]{known}"
f"[/grey62]\n"
f" [cyan]r[/cyan] Read a log back [grey62]{kept} in "
f"{cfg.output_dir}[/grey62]\n"
f" [cyan]N[/cyan] open group N "
f"[grey62]or type part of an option's name to find it[/grey62]\n"
f" [cyan]s[/cyan] Save these as default "
f"[grey62]kept in {wx.options_path()}[/grey62]\n"
f" [cyan]d[/cyan] Reset them\n"
f" [cyan]b[/cyan] Back\n")
answer = _ask(console, " choice", "l").strip().lower()
if answer in _BACK:
return
if answer in ("l", "listen", "p"):
_weather_listen(console, cfg, options)
elif answer in ("n", "name", "names"):
_sensor_names(console, book)
elif answer in ("r", "read", "readings", "m"):
_weather_readings(console, cfg, options, logs)
elif answer == "s":
try:
where = wx.save_options(options)
console.print(f" [green]saved to {where}[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
elif answer == "d":
if _confirm(" reset every weather option"):
options = wx.WeatherOptions()
console.print(" [green]reset[/green]")
elif answer.isdigit() and 1 <= int(answer) <= len(wx.OPTION_GROUPS):
_option_group_menu(console, options,
wx.OPTION_GROUPS[int(answer) - 1], wx)
elif answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer, wx)
elif answer:
found = _find_options(answer, wx)
if not found:
console.print(f" [yellow]nothing matches {answer!r} \u2014 "
f"enter a group number, or l, n, r, s, d or b"
f"[/yellow]")
elif len(found) == 1:
_edit_option(console, options,
str(wx.OPTIONS.index(found[0]) + 1), wx)
else:
_option_list(console, options, found, f"matching {answer!r}",
wx)
_pick_option(console, options, wx)
def _weather_listen(console: Console, cfg: ScanConfig, options) -> None:
"""Run a listening session from the menu and come back afterwards."""
from . import weather as wx
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
console.print("[grey62]control-C stops listening and comes back here. "
"Press [cyan]n[/cyan] while it runs to name a sensor."
"[/grey62]")
try:
wx.listen(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
def _sensor_names(console: Console, book) -> None:
"""The list of everything ever heard, and what it is called.
Everything ever heard rather than everything heard lately, because a
sensor with a flat battery is exactly the one somebody wants to look up.
"""
while True:
_rule(console, "sensor names")
sensors = book.ordered()
if not sensors:
console.print(" [yellow]nothing heard yet. Listen first, or "
"turn the invented garden on.[/yellow]")
return
t = Table(box=None, header_style="bold", pad_edge=False)
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("name", width=18)
t.add_column("id", style="grey62", width=6)
t.add_column("model", style="grey62", width=18)
t.add_column("msgs", style="grey62", justify="right", width=7)
t.add_column("last heard", style="grey62")
t.add_column("note", style="grey62", overflow="fold")
for i, sensor in enumerate(sensors, 1):
t.add_row(str(i),
Text(sensor.name, style="bold") if sensor.named
else Text("unnamed", style="yellow"),
sensor.sensor, sensor.model, f"{sensor.messages:,}",
_when(sensor.last_heard) if sensor.last_heard else "",
sensor.note)
console.print(t)
console.print(f"\n[grey62]Enter a number to name it, "
f"[cyan]-N[/cyan] to forget it, or [cyan]b[/cyan] to go "
f"back. Kept in {book.path}.[/grey62]")
answer = _ask(console, " sensor", "b").strip().lower()
if not answer or answer in _BACK:
return
forget = answer.startswith("-")
index = answer.lstrip("-").strip()
if not index.isdigit() or not 1 <= int(index) <= len(sensors):
console.print(" [yellow]enter a number from the list[/yellow]")
continue
sensor = sensors[int(index) - 1]
if forget:
if _confirm(f" forget {sensor.label()}"):
book.forget(sensor.key)
console.print(" [green]forgotten[/green]")
continue
name = _ask(console, f" a name for {sensor.sensor}",
sensor.name).strip()
note = _ask(console, " a note (optional)", sensor.note).strip()
try:
book.tag(sensor.key, name, note)
console.print(f" [green]saved[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
def _weather_readings(console: Console, cfg: ScanConfig, options,
logs) -> None:
"""Pick a log and read it back, newest first."""
from . import weather as wx
from .sensors import SensorBook
from .weatherlog import read_logs, write_csv
if not logs:
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
"listen first, or turn the invented garden on[/yellow]")
return
t = Table(box=None, header_style="bold")
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("log")
t.add_column("when", style="grey62")
t.add_column("size", style="grey62", justify="right")
for i, path in enumerate(logs[:12], 1):
stat = path.stat()
t.add_row(str(i), path.name, _when(stat.st_mtime),
f"{stat.st_size / 1e6:.2f} MB")
console.print(t)
answer = _ask(console, " which log", "1").strip()
if answer in _BACK or not answer.isdigit():
return
index = int(answer)
if not 1 <= index <= min(12, len(logs)):
console.print(" [yellow]no such number[/yellow]")
return
path = logs[index - 1]
readings = read_logs([path])
if not readings:
console.print(f" [yellow]{path.name} holds no readings[/yellow]")
return
book = SensorBook()
wx.report(console, wx.Garden.of(readings), book, options.imperial)
if _confirm(" write it as a spreadsheet too"):
try:
where = write_csv(path.with_suffix(".csv"), readings, book,
options.imperial)
console.print(f" [green]wrote {where}[/green]")
except OSError as exc:
console.print(f" [red]could not write it: {exc}[/red]")
def _listen(console: Console, cfg: ScanConfig, options) -> None:
"""Run a listening session from the menu and come back afterwards."""
from . import aircraft as air
@ -1122,6 +1375,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
f" [cyan]4[/cyan] Saved settings and profiles\n"
f" [cyan]5[/cyan] Aircraft (ADS-B) "
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
f" [cyan]6[/cyan] Weather sensors "
f"[grey62]listen on 433 MHz, name what is out there[/grey62]\n"
f" [cyan]h[/cyan] Help\n"
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
f" [cyan]q[/cyan] Quit\n")
@ -1137,6 +1392,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
cfg = profiles_menu(console, cfg)
elif choice == "5":
aircraft_menu(console, cfg)
elif choice == "6":
weather_menu(console, cfg)
elif choice in ("h", "?", "help"):
help_screen(console)
elif choice in ("s", "start", "go"):