bandsaunter/bandsaunter/weatherlog.py
The Dust Council 65cc03b78d 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
2026-09-07 13:40:33 -07:00

251 lines
9.7 KiB
Python

"""Writing down the weather, and reading it back.
One line of JSON per message, written the moment it arrives. Flushed after
every one, for the reason every log in this program is: a listening session
ends when the operator gets bored and presses control-C, and a log that only
reached the disk on a clean shutdown would be empty exactly when it was most
wanted.
Each line holds the message in hexadecimal alongside whatever was made of
it, because the message is the evidence and the rest of the line is an
opinion about it. A future version of this program that reads a model this
one cannot will be able to go back through old logs and read them properly,
which is only possible if the bytes were kept.
There is also a way out to CSV, because weather is the one thing this
program records that people genuinely want to plot: a column per quantity, a
row per reading, the sensor's name in the second column, and nothing that
needs a program to open.
"""
from __future__ import annotations
import csv
import json
import time
from datetime import datetime
from pathlib import Path
from .acurite import Measure, Reading
__all__ = ["WeatherLog", "read_logs", "write_csv", "LOG_VERSION", "logs_in"]
LOG_VERSION = 1
class WeatherLog:
"""A JSON Lines record of every message heard, written as it arrives."""
def __init__(self, path, receiver: str = "", frequency: float = 0.0,
sample_rate: float = 0.0, started: float = 0.0):
self.path = Path(path)
self.messages = 0
self.started = started or time.time()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._file = self.path.open("a", encoding="utf8")
self._write({"log": "bandsaunter-weather", "version": LOG_VERSION,
"started": round(self.started, 3),
"started_local": datetime.fromtimestamp(
self.started).strftime("%Y-%m-%d %H:%M:%S"),
"frequency": frequency, "sample_rate": sample_rate,
"receiver": receiver})
def _write(self, body: dict) -> None:
self._file.write(json.dumps(body, separators=(",", ":"),
ensure_ascii=False) + "\n")
self._file.flush()
def append(self, reading: Reading, name: str = "") -> None:
"""Record one message: what arrived, and what was made of it."""
body: dict = {"t": round(reading.at or time.time(), 3),
"key": reading.key, "family": reading.family,
"id": reading.sensor, "model": reading.model,
"msg": reading.message, "copies": reading.copies,
"hex": _hex(reading.bits)}
if reading.channel:
body["ch"] = reading.channel
if name:
# The name as it stood when the message arrived. Kept so that a
# log read back years later says where the sensor was, rather
# than where a sensor with the same identity is now.
body["name"] = name
if reading.battery_low is not None:
body["battery_low"] = bool(reading.battery_low)
if reading.measures:
body["m"] = {m.name: ([m.value, m.unit] if m.raw is None
else [m.value, m.unit, m.raw])
for m in reading.measures}
if reading.checks:
body["checks"] = list(reading.checks)
self.messages += 1
self._write(body)
def close(self) -> None:
try:
self._file.close()
except OSError:
pass
def __enter__(self) -> "WeatherLog":
return self
def __exit__(self, *exc) -> None:
self.close()
def _hex(bits: str) -> str:
"""A message's bits as bytes, where they make whole ones."""
if not bits or len(bits) % 8:
return ""
return bytes(int(bits[i:i + 8], 2)
for i in range(0, len(bits), 8)).hex().upper()
def _bits(text: str) -> str:
try:
return "".join(format(byte, "08b") for byte in bytes.fromhex(text))
except ValueError:
return ""
# ---------------------------------------------------------------------------
# Reading it back
# ---------------------------------------------------------------------------
def logs_in(directory) -> list[Path]:
"""Every weather log in a directory, newest first."""
try:
found = list(Path(directory).expanduser().glob("weather_*.jsonl"))
except OSError:
return []
return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
def read_logs(paths) -> list[Reading]:
"""Every reading in one or more logs, in the order they were heard.
A line that will not parse is skipped rather than fatal. A log is
appended to while the disk fills and the power goes off, so the last
line of one is quite often half a line, and losing an evening's weather
over it would be absurd.
"""
out: list[Reading] = []
for path in ([paths] if isinstance(paths, (str, Path)) else paths):
try:
text = Path(path).expanduser().read_text(encoding="utf8")
except OSError:
continue
for line in text.splitlines():
reading = _reading_from(line)
if reading is not None:
out.append(reading)
out.sort(key=lambda r: r.at)
return out
def _reading_from(line: str) -> Reading | None:
line = line.strip()
if not line:
return None
try:
body = json.loads(line)
except ValueError:
return None
if not isinstance(body, dict) or "key" not in body:
return None # the header line, or something else entirely
measures = []
for name, value in (body.get("m") or {}).items():
if not isinstance(value, list) or not value:
continue
measures.append(Measure(name=name, value=float(value[0]),
unit=str(value[1]) if len(value) > 1 else "",
raw=float(value[2]) if len(value) > 2 else None))
return Reading(model=str(body.get("model", "")),
family=str(body.get("family", "")),
sensor=str(body.get("id", "")),
channel=str(body.get("ch", "")),
battery_low=body.get("battery_low"),
message=int(body.get("msg", 0) or 0),
measures=tuple(measures),
bits=_bits(str(body.get("hex", ""))),
checks=tuple(body.get("checks") or ()),
at=float(body.get("t", 0.0) or 0.0),
copies=int(body.get("copies", 1) or 1))
# ---------------------------------------------------------------------------
# Out to a spreadsheet
# ---------------------------------------------------------------------------
def write_csv(path, readings, book=None, imperial: bool = False) -> Path:
"""A column per quantity and a row per reading.
The columns are the union of every quantity any sensor reported, so a
garden with a rain gauge in it has a rain column and the tower sensors
leave it empty. That is the shape a spreadsheet wants; the alternative,
a file per sensor, is the shape a program wants, and this is for people.
"""
path = Path(path).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
names: list[str] = []
for reading in readings:
for measure in reading.measures:
if measure.name not in names:
names.append(measure.name)
heads = ["time", "unix", "name", "key", "model", "sensor", "channel",
"battery"] + [_column(n, readings, imperial) for n in names]
with open(path, "w", encoding="utf8", newline="") as fh:
out = csv.writer(fh)
out.writerow(heads)
for reading in readings:
row = [datetime.fromtimestamp(reading.at).isoformat(
timespec="seconds") if reading.at else "",
f"{reading.at:.3f}" if reading.at else "",
book.name_for(reading.key) if book is not None else "",
reading.key, reading.model, reading.sensor,
reading.channel,
"" if reading.battery_low is None else
("low" if reading.battery_low else "ok")]
values = {m.name: m for m in reading.measures}
for name in names:
measure = values.get(name)
# str() rather than a format: %g turns a rain counter of a
# million into 1e+06, which a spreadsheet reads as text.
row.append("" if measure is None
else str(_converted(measure, imperial)))
out.writerow(row)
return path
# The units a column is written in. Named in the heading rather than beside
# every number, because a column of "21.5 C" is text and a column of 21.5 is
# a temperature, and only one of those can be plotted.
_IMPERIAL = {"C": "F", "km/h": "mph", "mm": "in", "km": "mi"}
def _column(name: str, readings, imperial: bool) -> str:
unit = ""
for reading in readings:
for measure in reading.measures:
if measure.name == name and measure.unit:
unit = measure.unit
break
if unit:
break
if imperial:
unit = _IMPERIAL.get(unit, unit)
return f"{name} ({unit})" if unit else name
def _converted(measure: Measure, imperial: bool) -> float:
if not imperial:
return round(measure.value, 3)
if measure.unit == "C":
return round(measure.value * 9 / 5 + 32, 2)
if measure.unit == "km/h":
return round(measure.value / 1.609344, 2)
if measure.unit == "mm":
return round(measure.value / 25.4, 3)
if measure.unit == "km":
return round(measure.value / 1.609344, 2)
return round(measure.value, 3)