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
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""What each sensor is called, which is the only thing the sensor cannot say.
|
|
|
|
A weather sensor broadcasts an identity -- fourteen bits on a tower sensor,
|
|
eight on a 609 -- and that identity is a number drawn at random in a factory,
|
|
or redrawn at random the next time somebody changes the batteries. It is
|
|
enough to tell one sensor from another and it is no use at all for telling
|
|
which is which: 1A2B is not a place.
|
|
|
|
So this keeps a small file saying that 1A2B is the back fence. It is the
|
|
only part of the weather section that holds anything a person typed, which
|
|
makes it the only part worth being careful with:
|
|
|
|
* Names are written the moment they are given, not when the program exits.
|
|
A listening session ends when the operator gets bored and presses
|
|
control-C, and a file that only reached the disk on a clean shutdown would
|
|
lose exactly the names that had just been thought of.
|
|
|
|
* Writing is done to a neighbouring file which is then renamed over the old
|
|
one, so that a machine losing power halfway through leaves either the old
|
|
names or the new ones and never half of each.
|
|
|
|
* Nothing is ever removed for being stale. A sensor whose battery ran out
|
|
two winters ago keeps its name, because the alternative is that putting a
|
|
battery back in loses it.
|
|
|
|
The file is YAML with one entry per sensor, meant to be opened and edited by
|
|
hand -- it is a list of things in a garden, and typing them is often quicker
|
|
than tagging them one at a time off the air.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
__all__ = ["Sensor", "SensorBook", "names_path", "UNHEARD"]
|
|
|
|
# The family part of the key given to a sensor named before it has been
|
|
# heard. Nobody knows yet which model it is -- that is in the message, and
|
|
# there has not been one -- so the name waits under this until there is.
|
|
UNHEARD = "?"
|
|
|
|
|
|
@dataclass
|
|
class Sensor:
|
|
"""One sensor: what it calls itself, and what its owner calls it."""
|
|
|
|
key: str = "" # family and identity: "tower/1A2B"
|
|
name: str = "" # what a person calls it
|
|
note: str = "" # anything else worth remembering
|
|
model: str = "" # what it said it was, when last heard
|
|
channel: str = "" # the switch position, when last heard
|
|
first_heard: float = 0.0
|
|
last_heard: float = 0.0
|
|
messages: int = 0
|
|
|
|
@property
|
|
def sensor(self) -> str:
|
|
"""The identity on its own, without the family in front of it."""
|
|
return self.key.split("/", 1)[-1]
|
|
|
|
@property
|
|
def family(self) -> str:
|
|
return self.key.split("/", 1)[0] if "/" in self.key else ""
|
|
|
|
@property
|
|
def named(self) -> bool:
|
|
return bool(self.name.strip())
|
|
|
|
def label(self) -> str:
|
|
"""What to put at the front of a line about this sensor.
|
|
|
|
The name where there is one, because that is what the person watching
|
|
is looking for; the identity where there is not, because that is all
|
|
there is and pretending otherwise would make two nameless sensors
|
|
look like one.
|
|
"""
|
|
return self.name.strip() or self.sensor
|
|
|
|
def describe(self) -> str:
|
|
bits = [self.label()]
|
|
if self.named:
|
|
bits.append(f"({self.sensor})")
|
|
if self.model:
|
|
bits.append(self.model)
|
|
if self.channel:
|
|
bits.append(f"ch {self.channel}")
|
|
return " ".join(bits)
|
|
|
|
|
|
def names_path(directory=None) -> Path:
|
|
from .config import DEFAULT_CONFIG_DIR
|
|
|
|
return Path(directory or DEFAULT_CONFIG_DIR) / "sensors.yaml"
|
|
|
|
|
|
class SensorBook:
|
|
"""Every sensor ever heard, and whatever it has been called.
|
|
|
|
Two jobs, deliberately in one place. It remembers the names, and it
|
|
remembers when each sensor was last heard and how often -- because the
|
|
second is what makes the first usable: a list of eleven identities is
|
|
unnameable, and a list of eleven identities with "last heard four
|
|
seconds ago" beside one of them is a sensor somebody can walk out and
|
|
look at.
|
|
"""
|
|
|
|
def __init__(self, path=None, directory=None):
|
|
self.path = Path(path) if path is not None else names_path(directory)
|
|
self.sensors: dict[str, Sensor] = {}
|
|
self.dirty = False
|
|
self.load()
|
|
|
|
# -- the file ---------------------------------------------------------
|
|
def load(self) -> "SensorBook":
|
|
"""Read the names. A file with a mistake in it costs no names.
|
|
|
|
A broken file is not an error here for the same reason it is not one
|
|
anywhere else in this program: it is hand-edited, the mistake is
|
|
usually one line of it, and refusing to listen to the weather because
|
|
of a stray colon would be the wrong trade. What is unreadable is
|
|
left alone rather than overwritten, so the mistake can be found.
|
|
"""
|
|
self.sensors = {}
|
|
try:
|
|
body = yaml.safe_load(self.path.read_text(encoding="utf8")) or {}
|
|
except (OSError, ValueError, yaml.YAMLError):
|
|
return self
|
|
entries = body.get("sensors") if isinstance(body, dict) else body
|
|
if not isinstance(entries, list):
|
|
return self
|
|
known = set(Sensor().__dict__)
|
|
for entry in entries:
|
|
if not isinstance(entry, dict) or not entry.get("key"):
|
|
continue
|
|
sensor = Sensor()
|
|
for field_name, value in entry.items():
|
|
if field_name in known and value is not None:
|
|
try:
|
|
setattr(sensor, field_name,
|
|
type(getattr(sensor, field_name))(value))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
self.sensors[sensor.key] = sensor
|
|
return self
|
|
|
|
def save(self) -> Path:
|
|
"""Write the names, whole or not at all."""
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
body = {"sensors": [asdict(s) for s in self.ordered()]}
|
|
beside = self.path.with_name(self.path.name + ".new")
|
|
with open(beside, "w", encoding="utf8") as fh:
|
|
fh.write("# What each weather sensor is called. Edit the names "
|
|
"freely; the rest is\n# filled in from what was heard "
|
|
"and will be overwritten.\n")
|
|
yaml.safe_dump(body, fh, sort_keys=False, allow_unicode=True,
|
|
default_flow_style=False)
|
|
os.replace(beside, self.path)
|
|
self.dirty = False
|
|
return self.path
|
|
|
|
# -- what is in it ----------------------------------------------------
|
|
def __len__(self) -> int:
|
|
return len(self.sensors)
|
|
|
|
def __contains__(self, key: str) -> bool:
|
|
return key in self.sensors
|
|
|
|
def get(self, key: str) -> Sensor | None:
|
|
return self.sensors.get(key)
|
|
|
|
def name_for(self, key: str) -> str:
|
|
sensor = self.sensors.get(key)
|
|
return sensor.name if sensor is not None else ""
|
|
|
|
def label_for(self, key: str) -> str:
|
|
sensor = self.sensors.get(key)
|
|
return sensor.label() if sensor is not None else key.split("/")[-1]
|
|
|
|
def ordered(self) -> list[Sensor]:
|
|
"""Named ones first, then by how recently they were heard.
|
|
|
|
The named ones are at the top because they are the ones being
|
|
watched; the nameless ones are sorted by when they were last heard
|
|
because that is the order in which somebody would want to name them.
|
|
"""
|
|
return sorted(self.sensors.values(),
|
|
key=lambda s: (not s.named, -s.last_heard, s.key))
|
|
|
|
def unnamed(self) -> list[Sensor]:
|
|
return [s for s in self.ordered() if not s.named]
|
|
|
|
def find(self, text: str) -> list[Sensor]:
|
|
"""Sensors matching what somebody typed: a key, a name, or an id.
|
|
|
|
An exact match on the key or the identity wins outright, so that
|
|
naming a sensor whose identity happens to read like a word does not
|
|
turn into a list of everything else in the garden.
|
|
"""
|
|
wanted = (text or "").strip().lower()
|
|
if not wanted:
|
|
return []
|
|
exact = [s for s in self.ordered()
|
|
if wanted in (s.key.lower(), s.sensor.lower(),
|
|
s.name.strip().lower())]
|
|
if exact:
|
|
return exact
|
|
return [s for s in self.ordered()
|
|
if wanted in s.key.lower() or wanted in s.name.lower()
|
|
or wanted in s.note.lower()]
|
|
|
|
# -- changing it ------------------------------------------------------
|
|
def heard(self, reading, when: float = 0.0) -> Sensor:
|
|
"""Note one reception. Returns the sensor it belonged to.
|
|
|
|
This does not save. A sensor reports every sixteen seconds and there
|
|
may be a dozen of them, and rewriting the file for each would be a
|
|
few thousand writes an hour to record nothing a person typed. The
|
|
counts are saved when the listening stops, and the names -- which are
|
|
the part that matters -- are saved the moment they are given.
|
|
"""
|
|
key = getattr(reading, "key", "") or ""
|
|
sensor = self.sensors.get(key) or self._claim(key)
|
|
if sensor is None:
|
|
sensor = self.sensors[key] = Sensor(key=key)
|
|
at = when or getattr(reading, "at", 0.0) or time.time()
|
|
sensor.first_heard = sensor.first_heard or at
|
|
sensor.last_heard = max(sensor.last_heard, at)
|
|
sensor.messages += 1
|
|
sensor.model = getattr(reading, "model", "") or sensor.model
|
|
sensor.channel = getattr(reading, "channel", "") or sensor.channel
|
|
self.dirty = True
|
|
return sensor
|
|
|
|
def _claim(self, key: str) -> Sensor | None:
|
|
"""Hand a waiting name to the sensor it turns out to belong to.
|
|
|
|
Somebody who knows there is a sensor on the shed can name it before
|
|
it has ever been received, and that name is filed under the identity
|
|
alone because nothing yet knows which model it is. The first message
|
|
from it says, and this is the moment the name moves across -- so the
|
|
display says "shed" from the first reception rather than listing the
|
|
shed and the sensor on it as two separate things.
|
|
"""
|
|
identity = key.split("/", 1)[-1]
|
|
waiting = self.sensors.pop(f"{UNHEARD}/{identity}", None)
|
|
if waiting is None:
|
|
return None
|
|
waiting.key = key
|
|
self.sensors[key] = waiting
|
|
self.dirty = True
|
|
return waiting
|
|
|
|
def tag(self, key: str, name: str, note: str | None = None,
|
|
save: bool = True) -> Sensor:
|
|
"""Give a sensor a name, and put it on the disk straight away."""
|
|
sensor = self.sensors.get(key)
|
|
if sensor is None:
|
|
sensor = self.sensors[key] = Sensor(key=key)
|
|
sensor.name = (name or "").strip()
|
|
if note is not None:
|
|
sensor.note = note.strip()
|
|
self.dirty = True
|
|
if save:
|
|
self.save()
|
|
return sensor
|
|
|
|
def forget(self, key: str, save: bool = True) -> bool:
|
|
"""Remove a sensor entirely. Returns whether there was one."""
|
|
if key not in self.sensors:
|
|
return False
|
|
del self.sensors[key]
|
|
self.dirty = True
|
|
if save:
|
|
self.save()
|
|
return True
|
|
|
|
def flush(self) -> Path | None:
|
|
"""Save if anything has changed since the last write."""
|
|
return self.save() if self.dirty else None
|