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
251 lines
9.7 KiB
Python
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)
|