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

@ -9,8 +9,10 @@ If you are on Debian, Ubuntu or Mint, [build the package](#a-debian-ubuntu-mint-
[a virtual environment](#b-anywhere-else-a-virtual-environment).
**You can try the whole program before buying or plugging in a receiver.**
`--simulate` runs the scanner against a synthetic band, and `bandsaunter adsb
--simulate` flies imaginary aircraft past an imaginary receiver. Neither needs
`--simulate` runs the scanner against a synthetic band, `bandsaunter adsb
--simulate` flies imaginary aircraft past an imaginary receiver, and
`bandsaunter weather --simulate` puts six weather sensors on a fence that does
not exist. None of the three needs
hardware or a network.
---
@ -116,6 +118,8 @@ bandsaunter bands # the built-in US band plan; no hardware need
bandsaunter scan -b 2m --simulate # a whole scan against a synthetic band
bandsaunter adsb --simulate --seconds 30
bandsaunter flights # draws what the last command heard
bandsaunter weather --simulate --seconds 60
bandsaunter readings --csv # turns what it heard into a spreadsheet
```
With a receiver plugged in:
@ -205,6 +209,13 @@ package. `python3-pyqt6` on Debian and Fedora, `python-pyqt6` on Arch, or
`pip install PyQt6` in the environment bandsaunter runs from. Without it the
window is the only thing missing, and the program says so rather than failing.
**The weather sensors need nothing at all** beyond what is already in the
table above — no network, no extra package, no key. They are a few milliwatts
on 433.92 MHz and the only thing that decides whether they are heard is the
aerial: a quarter-wave whip is 17 cm, which the stock telescopic aerial does
if it is collapsed to about that. `bandsaunter weather --simulate` runs the
whole thing without one.
**Aircraft and callsign lookups need no installation**, only a network. They
ask public registers about a callsign or a 24-bit address and cache the
answers for a month; `--no-lookup` turns them off, and what the address and

309
README.md
View file

@ -7,6 +7,11 @@ built-in US band plan — and it sweeps them, stops on anything above the noise
floor, records it, and works out what kind of signal it was. CW/Morse is
decoded to text.
Two things get sections of their own, because neither fits through a scan:
[the aircraft overhead](#aircraft) on 1090 MHz, drawn on a moving map, and
[the weather sensors](#weather-sensors-on-433-mhz) on 433 MHz, which you can
give names to as they arrive.
Two programs: `bandsaunter` scans, and
[`saunterbrowse`](#browsing-what-you-recorded) reads back what it collected —
transcripts, identifications and playback, in one screen.
@ -240,6 +245,8 @@ bandsaunter # the menus: set up and scan
bandsaunter scan -b 2m -b marine-vhf # band-plan presets
bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges
bandsaunter scan -b 2m --simulate # try it without hardware
bandsaunter adsb --window # aircraft overhead, on a map
bandsaunter weather # the weather sensors on 433 MHz
```
## Two ways to drive it
@ -253,11 +260,18 @@ table of settings, so neither can offer something the other cannot.
2 Band plan 107 US presets
3 Settings record no limit, hang 6s, squelch +12 dB, keep voice, cw
4 Saved settings and profiles
5 Aircraft (ADS-B) listen on 1090 MHz, draw where they went
6 Weather sensors listen on 433 MHz, name what is out there
h Help
s Start scanning
q Quit
```
Items 5 and 6 are sections of their own, with their own options and their own
menus, because neither fits through a scan: ADS-B is a megabit a second and a
scan channel is 12.5 kHz wide, and a weather sensor message is a burst of a
carrier switched on and off that a scan would record as clicks.
Settings are grouped, show their current value against the built-in default,
and carry their own help:
@ -1784,30 +1798,299 @@ form of every one.
copy between machines. API keys are deliberately **not** kept there — see
[Knowing the leg for certain](#knowing-the-leg-for-certain).
## Meters and weather sensors
## Weather sensors on 433 MHz
Two things on the ISM bands are worth naming rather than reporting as hex.
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, on 433.92 MHz, to anyone who happens
to be listening. `bandsaunter weather` reads the box.
```
bandsaunter weather listen, and name things as they arrive
bandsaunter weather --simulate a garden of sensors that are not there
bandsaunter readings --csv turn a log into something plottable
bandsaunter sensors what is out there, and what it is called
```
It is 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.
```
╭──────────────────────────────────────────────────────────────────────────────────────╮
│ 433.92 MHz 6 sensors 148 messages 41:02 weather_2026-09-07_11_31_04.jsonl │
│ n to name (2 unnamed) control-C to stop │
╰──────────────────────────────────────────────────────────────────────────────────────╯
# name id model readings batt msgs ago
1 back fence 1A2B Tower 592TXR temperature 8.4 C humidity 88% ok 41 0:07
2 mast 0777 5-in-1 06014RM wind 14.2 km/h wind from 248° WSW ok 36 0:11
rain 41.4 mm temperature 8.1 C
humidity 90%
3 greenhouse 0C41 Tower 592TXR temperature 15.9 C humidity 61% ok 39 0:04
4 unnamed 0311 Lightning 6045M temperature 8.6 C humidity 87% ok 24 0:19
strikes 4 storm 19 km
5 shed 5C 609TXC temperature 3.1 C humidity 94% low 21 0:12
6 unnamed 93 606TX temperature -18.2 C ok 19 0:22
```
### Naming things while they arrive
A sensor broadcasts an identity, and that identity is a number that came out
of a hat in a factory — or a different number out of the same hat the next
time the batteries were changed. 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 **press `n` while listening**. The display comes down, the sensors are
listed with numbers, you pick one and type a name, and it goes back up. The
receiver keeps running throughout: a slow typist loses a few seconds of
weather and nothing else.
That is the moment it is possible to do. 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 can also be given from the command line, before or after anything has
been heard:
```
bandsaunter weather --name 1A2B="back fence" --name 5C=shed
bandsaunter sensors --name 0311="lightning detector" --note 0311="south gable"
bandsaunter sensors --forget 93
```
A name given before the sensor has ever been received waits under its
identity alone — nothing yet knows which model it is, because that is in the
message and there has not been one — and moves across the moment the first
message arrives. So naming the garden from a list and then listening works,
and the display says `shed` from the first reception rather than listing the
shed and the sensor on it as two separate things.
They live in `sensors.yaml` beside the settings, 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.
Names are written the moment they are given rather than when the program
exits, because it exits on control-C; and they are written to a neighbouring
file which is then renamed over the old one, so a machine losing power halfway
through leaves either the old names or the new ones and never half of each.
Nothing is ever dropped 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.
### What it reads
Five families, each with its own framing and its own check:
| model | bytes | what it says |
|---|---|---|
| Tower 592TXR / 06002RM | 7 | temperature, humidity |
| 5-in-1 06014RM / VN1TXC | 8 | wind speed, wind direction, rainfall / wind speed, temperature, humidity |
| Lightning 6045M | 9 | temperature, humidity, strike count, how far off the storm is |
| 609TXC | 5 | temperature, humidity |
| 606TX | 4 | temperature |
Battery state comes from all of them. The 5-in-1 has more to say than fits in
one message and alternates two of them, so its last message is always either
the wind and the rain or the temperature and the humidity, never both — the
display keeps the newest value of each quantity rather than the newest
message, so the line is the whole sensor rather than half of it flickering.
**What is not read.** The Atlas, the 986 and 515 fridge thermometers, the
00275rm room monitor and the 899 standalone rain gauge. They are on the same
band and are not decoded. A message from one of them whose framing happens to
match is reported as an unknown message type with its identity and nothing
else, rather than guessed at — because knowing that something is out there
transmitting, with an identity that stays the same, is worth a line on its
own. `--no-unknown` leaves them off.
These formats are implemented from their published descriptions and are
checked against frames built from the same descriptions. That proves the
framing, the parity, the checksums and the arithmetic; it is not the same as
having held every one of these sensors.
### Why nothing false gets through
433 MHz is a crowded band. Doorbells, car keys, tyre-pressure sensors, garage
doors and the neighbours' weather station are all on it, and a decoder that
looks at every bit offset of every burst will find a message in noise if it is
allowed to. Four things stop it.
**The checks the message carries.** The three newer models have an eight-bit
sum plus odd parity in the top bit of every payload byte — twelve to fourteen
bits of check on a message of seven to nine bytes.
**Arriving twice.** The two older models carry one byte of check between them,
which is one false message in two hundred and fifty-six, and that is not a
rate a display can be trusted at. So those two are only believed when the same
message arrives twice. It costs nothing: these sensors send everything three
times in a row, for exactly this reason.
**A plausibility range.** A checksum can be satisfied by a message the
hardware could not have sent. Nothing outside −40 to 70 °C, 0 to 100% or a
wind the anemometer cannot physically report is accepted.
**Where the message sits.** This is the one that is easy to get wrong. 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 at all and what is left is one byte of sum.
Corroboration does not help either, because the copies of a message are
identical, so a coincidence in one copy is a coincidence in all three. What
gives that window away every time is that it ends a whole byte before the
burst does. A real message begins after the sync and runs to the end.
### Off the air
Three things happen between the aerial and a bit, in this order.
The receiver is tuned a little to one side of 433.92 MHz, because every
RTL-SDR puts a spike of its own at whatever it is tuned to, and a spike
sitting on top of a signal that works by being switched on and off is the one
thing that stops it being off. The sensors are shifted back to the middle in
software, which puts the spike out at the edge instead. `--offset 0` tunes
straight at them, which is worth trying once to see what the spike was costing.
Then a running average of the complex samples, long enough that its first null
lands on the spike. It is a crude filter and a deliberately crude one: what it
has to reject is one tone at a frequency this end chose.
Only then is the magnitude taken. Filtering before detection rather than after
is what keeps the neighbours — a doorbell, a tyre sensor, a car key — from
adding themselves to the envelope of the sensor.
Slicing the envelope into bits never measures anything against a clock. The
newer sensors vary the length of the pulse and keep the gaps even; the two
older ones keep the pulse even and vary the gap. Both readings of the same
pulses are tried and the checksums say which it was — rather than deciding
from the timings, which is guessing, and wrong on a weak burst where the edges
have moved. A transmitter running ten per cent fast is read correctly and
never noticed, which matters: these transmitters are unlocked and drift tens
of kilohertz and a few per cent of rate with the temperature. An outdoor
sensor in January is not the one that was on the fence in July.
### Afterwards
```
sensors heard
name id model ch msgs every battery last heard
back fence 1A2B Tower 592TXR A 148 16 s ok 23:14:07
mast 0777 5-in-1 06014RM A 131 18 s ok 23:14:02
greenhouse 0C41 Tower 592TXR B 140 17 s ok 23:14:05
shed 5C 609TXC 76 30 s low 23:13:58
what they said
sensor reading first last lowest highest
back fence temperature 8.4 C 6.1 C 5.9 C 9.2 C
humidity 88% 94% 86% 94%
mast wind 14.2 km/h 3.1 km/h 0.0 km/h 38.6 km/h
wind from 248° WSW 202° SSW
rain 41.4 mm 43.9 mm 41.4 mm 43.9 mm
```
The two tables are split on purpose. The first is about reception — who, how
often, how well — and is the one to look at when something is missing; `every`
is the honest measure of an aerial, because these transmit on a fixed cycle,
so thirty seconds from a sensor that sends every sixteen means half of them
are being missed. The second is about the weather, and is the one to look at
when nothing is.
There is no average. These arrive every sixteen seconds when the sensor is in
range and not at all when it is not, and rain and cold both shorten the range
of a 433 MHz transmitter — so the mean of what was received is the mean of a
sample whose gaps are themselves the weather, and it would read like a number
when it is not one. A bearing gets no lowest or highest either: north is 0 and
also 360.
`--csv`, or `bandsaunter readings --csv` afterwards, writes a column per
quantity and a row per reading, with the sensor's name in the second column
and the unit in the heading rather than beside every number — a column of
"21.5 C" is text, and a column of 21.5 is a temperature.
The log keeps the raw bytes of every message underneath whatever was made of
them, because the message is the evidence and the rest of the line is an
opinion about it. A later version of this program that reads a model this one
cannot will be able to go back through old logs and read them properly.
Readings are converted once, on the way in, to degrees Celsius, kilometres an
hour, millimetres and kilometres — different models report in different units,
and the tower sends Celsius while the 5-in-1 and the lightning detector send
Fahrenheit. The log holds the converted value, so `--units imperial` changes
only what is shown and can be changed afterwards on an old log.
### Every weather option
| option | flag | default | what it does |
|---|---|---|---|
| Receiver | `--device` | 0 | which receiver, when more than one is plugged in |
| Gain | `--gain` | auto | tuner gain in dB, or automatic |
| Sample rate | `--rate` | 1.024 MS/s | how fast to sample; 250 kS/s is the minimum |
| Sensors on | `--frequency`, `--freq` | 433.92 MHz | where the sensors transmit |
| Tuning offset | `--offset` | 250 kHz | how far to one side of them to tune |
| Invent a garden | `--simulate` / `--no-simulate` | no | six sensors that are not there |
| Listen for | `--seconds` | until stopped | how long before stopping |
| Write a log | `--log` / `--no-log` | yes | one line of JSON per message |
| Print every message | `--messages` / `--no-messages` | no | a stream of lines instead of a table |
| Keep on screen for | `--hold` | 1800 s | how long a sensor stays after its last message |
| Show readings in | `--units` | metric | metric or imperial, for the display and the export |
| Only named sensors | `--only-named` / `--all-sensors` | no | ignore anything without a name |
| Show unreadable models | `--unknown` / `--no-unknown` | yes | list sensors whose model cannot be read |
| Report at the end | `--report` / `--no-report` | yes | print what each sensor said |
| Also write a spreadsheet | `--csv` / `--no-csv` | no | CSV beside the log |
`--name ID=NAME` is not a setting: it names a sensor and is repeatable.
Saved in `weather.yaml` beside the other settings, from the menu's **s** or by
hand. `bandsaunter readings` also takes `--sensor NAME` to narrow a log to one
sensor, and `--no-report` to write the spreadsheet and say nothing.
### Without a sensor
`--simulate` puts six sensors on a fence that does not exist — one of every
model — transmitting real messages with real checksums, keyed on and off as a
real one does, through the real filter, the real slicer and the real decoders.
Nothing touches the receiver. The naming, the log, the report and the export
can all be tried before an aerial exists.
The weather moves at roughly a real afternoon's pace, which is slower than it
sounds like it should be: about a degree an hour of drift, wind gusting around
a mean, rain falling a tenth of a millimetre at a time. A garden that swung
six degrees a minute would exercise everything downstream perfectly well and
would look, to anyone running `--simulate` to see what the program does, like
a program that cannot read a thermometer.
### If nothing is heard
These are a few milliwatts. A quarter-wave whip for 433.92 MHz is 17 cm of
wire, and the stock telescopic aerial set to about that length works well.
Indoors, behind a wall, with the dongle in the back of a machine, is usually
the problem. Try `--gain 40` if the automatic gain control is not finding
them, and `--messages` to watch individual receptions arrive while moving the
aerial about.
## Meters on 900 MHz
A scan of 902–928 MHz that turns up a burst gets it named rather than reported
as hex.
```
915.0 MHz decoded: electricity meter 12345678 reading 987654
433.92 MHz decoded: AcuRite sensor 1234 temperature 21.5 C humidity 48% channel A
433.92 MHz decoded: Tower 592TXR 1A2B temperature 21.5 C humidity 48% channel A
```
**Utility meters.** The Itron ERT modules fitted to electricity, gas and water
meters across North America broadcast their reading every thirty seconds or so
on 902–928 MHz, in the clear, so a van can drive past and read a street. The
message says which meter, what kind, what the register reads, and whether the
tamper switches have been tripped.
tamper switches have been tripped. It is not guessed at: the message carries a
16-bit BCH check and nothing is reported that has not satisfied it.
**AcuRite sensors.** The 433.92 MHz outdoor sensors sold with every consumer
weather station send temperature, humidity, battery state and a channel letter
every sixteen seconds.
Neither is guessed at: a meter message carries a 16-bit BCH check and a sensor
message a checksum and four parity bits, and nothing is reported that has not
satisfied them. Both are implemented from their published descriptions and
checked against frames built from the same descriptions — which proves the
framing and the arithmetic, and is not the same as having held a meter.
**Weather sensors.** A scan of 433 MHz that catches one of these names it the
same way, using the same decoders as
[the weather section](#weather-sensors-on-433-mhz) — which is where they
belong, because a scan catches one burst and the weather is a night of them.
The two thinly-checked models are not reported from a scan at all: they need
the same message twice before they are believed, and a scan hears one.
## Hex into words

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-09-06"
VERSION_REVISION = 4
VERSION_DATE = "2026-09-07"
VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

1313
bandsaunter/acurite.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ from .adsb import ADSB_HZ, SAMPLE_RATE
from .flightlog import read_position
from .settings import Setting, format_value
__all__ = ["AircraftOptions", "OPTIONS", "listen", "watch", "draw",
__all__ = ["AircraftOptions", "OPTIONS", "defaults", "listen", "watch", "draw",
"logs_in", "open_device", "open_log", "pump", "finish",
"windowed",
"format_option",
@ -519,6 +519,15 @@ OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map",
"Labels", "Effects")
def defaults() -> AircraftOptions:
"""A fresh set, for showing what has been changed from it.
Named the same as the weather section's, so that the menus can show
either without knowing which they are showing.
"""
return AircraftOptions()
def in_group(group: str) -> list[Setting]:
return [o for o in OPTIONS if o.group == group]

View file

@ -58,6 +58,10 @@ examples:
bandsaunter adsb --window live map of the aircraft
bandsaunter flights --out sky.gif animate what they did
bandsaunter flights --theme phosphor draw it as a vector display
bandsaunter weather read the 433 MHz weather sensors
bandsaunter weather --name 1A2B="back fence" name one while listening
bandsaunter readings --csv turn a weather log into a graph
bandsaunter sensors what is out there, and what it is called
bandsaunter scan -b 2m --simulate try it without hardware
""")
# The GNU form: the version, then who holds the copyright and what the
@ -288,6 +292,92 @@ examples:
"phosphor, amber and red")
ad.set_defaults(log_frames=True, lookup=True)
# -- weather -------------------------------------------------------------
we = sub.add_parser("weather",
help="read the AcuRite weather sensors on 433 MHz")
we.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
we.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; a quarter of a megasample is the "
"minimum")
we.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
we.add_argument("--device", type=int, default=None, help="which receiver")
we.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="where the sensors are (default: 433.92 MHz)")
we.add_argument("--offset", type=float, default=None, metavar="HZ",
help="how far to one side of them to tune, to keep the "
"receiver's own spike off the signal")
we.add_argument("--simulate", dest="simulate", action="store_true",
default=None,
help="invent a garden of sensors, for a receiver with no "
"aerial")
we.add_argument("--no-simulate", dest="simulate", action="store_false",
default=None, help="listen to real sensors")
we.add_argument("--log", default=None, metavar="FILE",
help="where to write the message log "
"(default: weather_<time>.jsonl in the output "
"directory)")
we.add_argument("--no-log", dest="log_messages", action="store_false",
default=None, help="listen without writing anything down")
we.add_argument("--messages", dest="messages", action="store_true",
default=None,
help="print every message as it arrives, not a table")
we.add_argument("--no-messages", dest="messages", action="store_false",
default=None, help="show the table that updates in place")
we.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a sensor stays on the display after its "
"last message")
we.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in (the log is always metric)")
we.add_argument("--only-named", dest="only_named", action="store_true",
default=None,
help="ignore sensors that have not been given a name")
we.add_argument("--all-sensors", dest="only_named", action="store_false",
default=None, help="show every sensor heard")
we.add_argument("--unknown", dest="unknown", action="store_true",
default=None,
help="list sensors whose model cannot be read")
we.add_argument("--no-unknown", dest="unknown", action="store_false",
default=None, help="only sensors that can be read")
we.add_argument("--report", dest="report", action="store_true",
default=None, help="print what each sensor said at the end")
we.add_argument("--no-report", dest="report", action="store_false",
default=None, help="no report when the listening stops")
we.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write the readings as CSV beside the log")
we.add_argument("--no-csv", dest="csv", action="store_false", default=None,
help="no spreadsheet")
we.add_argument("--name", action="append", default=[], metavar="ID=NAME",
help="give a sensor a friendly name before listening; "
"repeatable, and the same thing the n key does while "
"the display is running")
# -- readings -------------------------------------------------------------
rd = sub.add_parser("readings",
help="read a weather log: report, and a spreadsheet")
rd.add_argument("path", nargs="*",
help="weather logs (default: the newest in the output "
"directory)")
rd.add_argument("--csv", nargs="?", const="", default=None, metavar="FILE",
help="write the readings as CSV (default: beside the log)")
rd.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in")
rd.add_argument("--sensor", default=None, metavar="NAME",
help="only this sensor, by name or by identity")
rd.add_argument("--no-report", dest="report", action="store_false",
default=True, help="write the spreadsheet and say nothing")
# -- sensors --------------------------------------------------------------
se = sub.add_parser("sensors",
help="what has been heard, and what it is called")
se.add_argument("--name", action="append", default=[], metavar="ID=NAME",
help="give a sensor a friendly name; repeatable")
se.add_argument("--note", action="append", default=[], metavar="ID=TEXT",
help="anything else worth remembering about a sensor")
se.add_argument("--forget", action="append", default=[], metavar="ID",
help="remove a sensor from the list entirely")
# -- flights --------------------------------------------------------------
fl = sub.add_parser("flights",
help="read an ADS-B log: report, map, animation")
@ -548,6 +638,33 @@ def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
border_style="yellow", padding=(0, 1)))
def _warn_about_sensor_bands(cfg: ScanConfig) -> None:
"""The same courtesy for 433 MHz, which is in the band plan too.
A sweep of it is a fair thing to want -- there is a great deal there
besides weather -- so the sweep is not stopped, only told about.
"""
from . import weather as wx
warning = wx.scanning_sensor_band(cfg.ranges)
if not warning:
return
console.print(Panel(
Text.from_markup(
f"{escape(warning)}\n\n"
"[bold]bandsaunter weather[/bold] decodes them properly: "
"temperature, humidity, wind, rain and lightning, from each "
"sensor by name.\n"
"[bold]bandsaunter readings[/bold] then turns the log into a "
"spreadsheet.\n\n"
"[grey62]Both are in the menus as well, under Weather sensors "
"(433 MHz). Scanning it anyway is fine if what you want is the "
"raw spectrum \u2014 there are doorbells, car keys and tyre "
"sensors there too.[/grey62]"),
title="[yellow]this band needs the weather mode",
border_style="yellow", padding=(0, 1)))
def _make_device(cfg: ScanConfig, simulate: bool):
if simulate:
from .simulator import SimulatedDevice
@ -605,6 +722,7 @@ def cmd_scan(args) -> int:
return 2
_warn_about_aircraft_bands(cfg)
_warn_about_sensor_bands(cfg)
if args.dry_run:
_print_plan(cfg)
@ -1274,6 +1392,205 @@ def cmd_adsb(args) -> int:
return 0
def _weather_options(args, options):
"""Fold whatever was given on the command line into the saved options.
Every one of these defaults to None rather than to the option's default,
so that a flag left off means "whatever was saved" rather than "the
factory setting". A person who has set the gain in the menu and then
runs `bandsaunter weather --seconds 60` should get their gain.
"""
for flag, key in (("seconds", "seconds"), ("rate", "rate"),
("gain", "gain"), ("device", "device"),
("frequency", "frequency"), ("offset", "offset"),
("simulate", "simulate"), ("log_messages", "log"),
("messages", "messages"), ("hold", "hold"),
("units", "units"), ("only_named", "only_named"),
("unknown", "unknown"), ("report", "report"),
("csv", "csv")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
return options
def _name_sensors(book, given, quiet: bool = False) -> int:
"""Apply every --name or --note on the command line. Returns how many.
The identity may be given as it appears on the display -- 1A2B -- or as
the whole key, tower/1A2B, which is what to use on the vanishingly rare
occasion that two models have drawn the same identity out of the hat.
"""
done = 0
for pair in given or ():
ident, _, name = str(pair).partition("=")
ident, name = ident.strip(), name.strip()
if not ident or not name:
console.print(f"[yellow]--name wants ID=NAME, not {pair!r}"
"[/yellow]")
continue
found = book.find(ident)
if not found:
# Nothing of that identity has been heard yet. Named anyway,
# under the key as typed: the sensor on the shed is on the shed
# whether or not it has been received in the last five minutes,
# and a name waiting for it is better than a name refused.
from .sensors import UNHEARD
key = ident if "/" in ident else f"{UNHEARD}/{ident.upper()}"
book.tag(key, name)
if not quiet:
console.print(f" [green]{ident} is now {name}[/green] "
f"[grey62](not heard yet)[/grey62]")
done += 1
continue
if len(found) > 1:
console.print(f"[yellow]{ident!r} matches "
f"{', '.join(s.key for s in found)} — "
"use the whole key[/yellow]")
continue
book.tag(found[0].key, name)
if not quiet:
console.print(f" [green]{found[0].sensor} is now {name}[/green]")
done += 1
return done
def cmd_weather(args) -> int:
"""Park the receiver on 433.92 MHz and read the weather sensors.
A command of its own for the same reason the aircraft mode is: this does
not fit through the scanner. A sensor message is a burst of on-off
keying, and the scan path is a squelch and a recorder -- it would record
the bursts as clicks in a WAV file and decode nothing.
"""
from . import weather as wx
from .sensors import SensorBook
cfg, _ = load_default()
options = _weather_options(args, wx.load_options())
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
book = SensorBook()
_name_sensors(book, args.name)
heard = wx.listen(console, options, cfg.output_dir, log_path=args.log,
book=book)
return 0 if heard.sensors else 1
def cmd_readings(args) -> int:
"""Read a weather log back: what was heard, and what it said."""
from . import weather as wx
from .sensors import SensorBook
from .weatherlog import logs_in, read_logs, write_csv
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no weather logs found. Record one with "
"`bandsaunter weather`.[/yellow]")
return 1
for path in paths:
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
readings = read_logs(paths)
if not readings:
console.print(f"[yellow]{paths[0].name} holds no readings[/yellow]")
return 1
options = wx.load_options()
if args.units:
options.units = args.units
book = SensorBook()
if args.sensor:
wanted = {s.key for s in book.find(args.sensor)}
wanted |= {r.key for r in readings
if args.sensor.strip().lower() in r.key.lower()}
readings = [r for r in readings if r.key in wanted]
if not readings:
console.print(f"[yellow]nothing in the log matches "
f"{args.sensor!r}[/yellow]")
return 1
if args.report:
wx.report(console, wx.Garden.of(readings), book, options.imperial)
if args.csv is not None:
where = Path(args.csv).expanduser() if args.csv \
else paths[0].with_suffix(".csv")
try:
written = write_csv(where, readings, book, options.imperial)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return 1
console.print(f"[green]wrote {written}[/green] "
f"[grey62]{len(readings):,} readings[/grey62]")
return 0
def cmd_sensors(args) -> int:
"""List what has been heard, and give things names.
The list is everything ever heard rather than everything heard lately,
because a sensor with a flat battery is exactly the one somebody wants
to look up.
"""
from .sensors import SensorBook
book = SensorBook()
changed = _name_sensors(book, args.name)
for pair in args.note or ():
ident, _, note = str(pair).partition("=")
found = book.find(ident.strip())
if len(found) == 1:
book.tag(found[0].key, found[0].name, note.strip())
changed += 1
else:
console.print(f"[yellow]--note: nothing matches "
f"{ident.strip()!r}[/yellow]")
for ident in args.forget or ():
found = book.find(str(ident).strip())
if len(found) == 1:
book.forget(found[0].key)
console.print(f" [green]forgot {found[0].sensor}[/green]")
changed += 1
else:
console.print(f"[yellow]--forget: nothing matches "
f"{ident!r}[/yellow]")
if not len(book):
console.print("[yellow]no sensors heard yet. Listen with "
"`bandsaunter weather`.[/yellow]")
return 1
t = Table(box=None, header_style="bold", pad_edge=False,
title="[bold]sensors[/bold]", title_justify="left")
t.add_column("name", overflow="fold")
t.add_column("id", style="grey62", no_wrap=True)
t.add_column("key", style="grey62", no_wrap=True)
t.add_column("model", style="grey62", overflow="fold")
t.add_column("ch", style="grey62", justify="center")
t.add_column("msgs", justify="right", style="grey62")
t.add_column("last heard", style="grey62", no_wrap=True)
t.add_column("note", style="grey62", overflow="fold")
for sensor in book.ordered():
last = time.strftime("%Y-%m-%d %H:%M",
time.localtime(sensor.last_heard)) \
if sensor.last_heard else ""
t.add_row(sensor.name or "[yellow]unnamed[/yellow]", sensor.sensor,
sensor.key, sensor.model, sensor.channel,
f"{sensor.messages:,}", last, sensor.note)
console.print(t)
console.print(f"[grey62]kept in {book.path} — "
f"name one with `bandsaunter sensors --name ID=NAME`"
f"[/grey62]")
return 0
def cmd_flights(args) -> int:
"""Turn a log of ADS-B frames into something worth looking at.
@ -1546,7 +1863,8 @@ def main(argv=None) -> int:
"config": cmd_config, "transcribe": cmd_transcribe,
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
"flights": cmd_flights,
"flights": cmd_flights, "weather": cmd_weather,
"readings": cmd_readings, "sensors": cmd_sensors,
}
try:
return handlers[args.command](args)

View file

@ -11,7 +11,11 @@ reads, and whether the tamper switches have been tripped.
**AcuRite weather sensors.** The 433.92 MHz outdoor sensors sold with every
consumer weather station send temperature, humidity, battery state and a
channel letter every sixteen seconds.
channel letter every sixteen seconds. Reading them is a section of this
program in its own right -- see :mod:`bandsaunter.acurite`, which knows five
models and the two ways they draw a bit -- and what is here is the doorway
to it, so that a burst caught by an ordinary scan of 433 MHz gets named
instead of being reported as hexadecimal.
Neither is guessed at. A meter message carries a sixteen-bit BCH checksum
and a sensor message carries a checksum and four parity bits, and nothing is
@ -27,6 +31,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from . import acurite as _acurite
__all__ = ["decode_ism", "IsmReading", "decode_scm", "decode_acurite",
"scm_frame", "acurite_frame", "SCM_PREAMBLE", "ERT_TYPES"]
@ -142,77 +148,42 @@ def scm_frame(meter: int, consumption: int, ert_type: int = 4,
# AcuRite
# ---------------------------------------------------------------------------
# The messages themselves, the checks on them and the arithmetic all live in
# :mod:`bandsaunter.acurite`, which reads five models rather than the one
# this used to, and is the section of the program devoted to them. What is
# left here is the shape the generic classifier wants: a run of bits in, one
# ``IsmReading`` out, so that a burst caught by a scan of 433 MHz is named
# without the scanner having to know anything about weather.
ACURITE_BYTES = 7
ACURITE_CHANNELS = "ABCD"
ACURITE_CHANNELS = "".join(_acurite.CHANNELS)
def _parity(value: int) -> int:
value ^= value >> 4
value ^= value >> 2
value ^= value >> 1
return value & 1
"""Kept under its old name; the implementation is in :mod:`acurite`."""
return _acurite.parity8(value)
def decode_acurite(bits: str) -> IsmReading | None:
"""Read one AcuRite 592TXR / Tower outdoor sensor message.
Seven bytes: fourteen bits of sensor number with the channel above them,
a status byte, humidity, temperature in tenths of a degree offset by a
hundred, and a checksum that is the sum of the six bytes before it. The
four middle bytes each carry odd parity in their top bit, which is what
makes a seven-byte message safe to accept on a band this crowded.
"""
need = ACURITE_BYTES * 8
if len(bits) < need:
"""Whichever AcuRite sensor a run of bits turns out to be, or None."""
reading = _acurite.decode(bits)
if reading is None:
return None
# Every offset, because what reaches here has a sync pattern of some
# length in front of it and the message does not begin on a byte
# boundary of the recovered bits. The checksum and the four parity bits
# are what make that affordable.
for at in range(len(bits) - need + 1):
data = [int(bits[at + i * 8:at + (i + 1) * 8], 2)
for i in range(ACURITE_BYTES)]
if (sum(data[:6]) & 0xFF) == data[6] and any(data) and \
all(_parity(byte) == 1 for byte in data[2:6]):
break
else:
return None
bits = bits[at:at + need]
channel = ACURITE_CHANNELS[(data[0] >> 6) & 0x03]
sensor = ((data[0] & 0x3F) << 8) | data[1]
humidity = data[3] & 0x7F
raw = ((data[4] & 0x0F) << 7) | (data[5] & 0x7F)
celsius = raw / 10.0 - 100.0
if not (-40.0 <= celsius <= 70.0) or humidity > 100:
return None # outside what the sensor can report
fields = [("temperature", f"{celsius:.1f} C"),
("humidity", f"{humidity}%"),
("channel", channel)]
if data[2] & 0x40:
fields = [(m.name, _acurite.format_measure(m)) for m in reading.measures]
if reading.channel:
fields.append(("channel", reading.channel))
if reading.battery_low:
fields.append(("battery", "low"))
return IsmReading(kind="AcuRite", device="AcuRite sensor",
identifier=f"{sensor:04X}", fields=fields,
bits=bits,
checks=["checksum-8", "parity"])
return IsmReading(kind="AcuRite", device=reading.model,
identifier=reading.sensor, fields=fields,
bits=reading.bits, checks=list(reading.checks))
def acurite_frame(sensor: int, celsius: float, humidity: int,
channel: str = "A", battery_low: bool = False) -> str:
"""Build one AcuRite sensor message, checksum and parity included."""
raw = int(round((celsius + 100.0) * 10.0))
data = [((ACURITE_CHANNELS.index(channel) & 3) << 6) | ((sensor >> 8) & 0x3F),
sensor & 0xFF,
0x04 | (0x40 if battery_low else 0x00),
humidity & 0x7F,
(raw >> 7) & 0x0F,
raw & 0x7F]
for i in range(2, 6):
if _parity(data[i]) != 1:
data[i] |= 0x80
data.append(sum(data[:6]) & 0xFF)
return "".join(format(byte, "08b") for byte in data)
"""Build one 592TXR tower message, checksum and parity included."""
return _acurite.tower_frame(sensor, celsius, humidity, channel,
battery_low)
# ---------------------------------------------------------------------------

284
bandsaunter/sensors.py Normal file
View file

@ -0,0 +1,284 @@
"""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

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"):

View file

@ -22,8 +22,8 @@ from .flightlog import in_speed, speed_label
from .recorder import HitRecord
from .scanner import Detection, Scanner
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
"print_band_table"]
__all__ = ["ScanDisplay", "AircraftDisplay", "WeatherDisplay", "KeyReader",
"print_hit", "print_band_table"]
_SPARK = " ▁▂▃▄▅▆▇█"
@ -790,3 +790,142 @@ def print_band_table(console: Console, presets, title: str = "band plan") -> Non
if p.is_group else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
t.add_row(p.key, p.name, extent, p.mode, p.note)
console.print(t)
class WeatherDisplay:
"""One line per weather sensor, updated in place while listening.
A sensor appears when its first message arrives and stays until nothing
has been heard from it for ``hold`` seconds -- half an hour by default,
which is long compared with the sixteen seconds between messages and so
means a sensor that vanishes has really stopped rather than been missed.
The line shows the *latest value of every quantity*, not the latest
message. A 5-in-1 has more to say than fits in one message and sends two
kinds alternately, so its last message is either the wind and the rain or
the temperature and the humidity, never both; showing the newest of each
means the line is the whole sensor rather than half of it flickering.
Rows are numbered and the numbers do not move, because naming a sensor
means reading a row and then typing its number, and a list that reorders
itself between those two moments is a list that gets things named wrong.
"""
def __init__(self, console: Console, book=None, hold: float = 1800.0,
imperial: bool = False, frequency: float = 433.92e6):
self.console = console
self.book = book
self.hold = hold
self.imperial = imperial
self.frequency = frequency
self.messages = 0
self.started = time.time()
self.log_path = None
self.garden = None
# -- what the listener tells it ---------------------------------------
def update(self, garden, messages: int, log_path=None) -> None:
self.garden = garden
self.messages = messages
if log_path is not None:
self.log_path = log_path
def showing(self, now: float | None = None) -> list:
if self.garden is None:
return []
return self.garden.showing(self.hold, now)
# -- drawing ----------------------------------------------------------
def render(self, now: float | None = None, width: int | None = None):
now = time.time() if now is None else now
width = self.console.size.width if width is None else width
here = self.showing(now)
parts = [self._header(here, now)]
if here:
parts.append(self._table(here, now, width))
else:
parts.append(Panel(_one_line(
"[grey62]nothing heard yet — these are a few milliwatts at "
"433.92 MHz, and a quarter-wave whip is 17 cm[/grey62]"),
border_style="grey37", padding=(0, 1)))
return Group(*parts)
def _header(self, here, now: float) -> Panel:
elapsed = max(0.001, now - self.started)
nameless = sum(1 for s in here if not self._name(s))
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
else ""
# The offer to name something is in the header rather than at the
# bottom because the bottom of this display moves as sensors arrive.
naming = f"[bold cyan]n[/bold cyan] [grey62]to name" \
f"{f' ({nameless} unnamed)' if nameless else ''}[/grey62]"
return Panel(_one_line(
f"[bold cyan]{self.frequency / 1e6:g} MHz[/bold cyan] "
f"[bold]{len(here)}[/bold] sensor{'s' if len(here) != 1 else ''} "
f"[bold]{self.messages}[/bold] message"
f"{'s' if self.messages != 1 else ''} "
f"[grey62]{_dur(elapsed)}[/grey62]{where} {naming} "
f"[grey62]control-C to stop[/grey62]"),
border_style="blue", padding=(0, 1))
def _name(self, station) -> str:
return self.book.name_for(station.key) if self.book is not None else ""
def _table(self, here, now: float, width: int) -> Table:
"""As many columns as the terminal has room for, widest first.
A narrow terminal keeps the number, the name and what the sensor
said, and drops the model and the reception statistics: the first
three are why anyone is looking, and the rest can be read in the
report afterwards.
"""
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
t.add_column("#", style="grey62", width=2, justify="right")
t.add_column("name", width=14, no_wrap=True)
t.add_column("id", width=5, style="grey62", no_wrap=True)
if width >= 92:
t.add_column("model", width=16, style="grey62", no_wrap=True)
t.add_column("readings", overflow="fold")
t.add_column("batt", width=4, justify="center")
if width >= 76:
t.add_column("msgs", width=5, justify="right", style="grey62")
t.add_column("ago", width=5, justify="right", style="grey62")
for i, station in enumerate(here, 1):
name = self._name(station)
row = [str(i),
Text(name, style="bold") if name
else Text("unnamed", style="yellow"),
station.sensor]
if width >= 92:
row.append(station.model or "")
row.append(self._readings(station, now))
row.append(Text("low", style="bold red") if station.battery_low
else Text("ok", style="green"))
if width >= 76:
row.append(f"{station.messages:,}")
row.append(_dur(max(0.0, now - station.last)))
t.add_row(*row)
return t
def _readings(self, station, now: float) -> Text:
"""Everything the sensor is currently saying, newest values first.
A quantity that has not been reported for a while is dimmed rather
than dropped. The 5-in-1 alternates its two messages, so half of
what it says is always a message old and dropping that would make
the line flicker; but a quantity that has been stale for minutes
while the sensor is otherwise fine is worth seeing greyed out.
"""
from .acurite import format_measure
out = Text()
for name, measure in station.values.items():
if out.plain:
out.append(" ")
stale = now - station.times.get(name, now) > 90.0
out.append(f"{name} ", style="grey62")
out.append(format_measure(measure, self.imperial),
style="grey58" if stale else "bold white")
if station.unread and not station.values:
out.append("framed, not understood", style="grey62")
return out

887
bandsaunter/weather.py Normal file
View file

@ -0,0 +1,887 @@
"""Listening to the weather sensors on 433 MHz, from either front end.
The command line and the menus do the same thing here -- park on 433.92 MHz,
write down what the sensors in the neighbourhood say, and let them be given
names -- so the doing of it lives here and both front ends call it. Neither
imports the other, and the options are described once, in the same shape as
every other setting in the program, so the menu can print help for each one
without knowing what any of them mean.
This does not go through the scanner, for the same reason ADS-B does not,
though for the opposite reason of size. A sensor message is a burst of
on-off keying two hundred milliseconds long, and the scan path is a squelch
and a recorder: a scan of 433.92 MHz records the bursts as clicks in a WAV
file and decodes nothing. What is needed is the raw envelope of the band,
which is what this takes.
The one thing here that is not radio is the naming. A sensor broadcasts a
number, that number came out of a hat in a factory, and the whole point of
listening is to know what the shed is doing -- so a name can be given to a
sensor the moment it is heard, from the display, while it is on the screen.
That lives in :mod:`bandsaunter.sensors`, which is careful with it, because
it is the only thing in this section that a person typed.
"""
from __future__ import annotations
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
import yaml
from .acurite import ACURITE_HZ, Measure, format_measure
from .settings import Setting, format_value
from .weatherlog import logs_in
__all__ = ["WeatherOptions", "OPTIONS", "OPTION_GROUPS", "defaults",
"in_group", "by_key", "format_option", "describe", "summarise",
"load_options", "save_options", "options_path", "logs_in",
"Heard", "Station", "Garden", "listen", "open_device", "open_log",
"pump", "finish", "report", "ISM_BANDS", "SCAN_WARNING",
"scanning_sensor_band"]
# ---------------------------------------------------------------------------
# The band that looks like a scan and is not one
# ---------------------------------------------------------------------------
# (name, low, high)
ISM_BANDS = (
("433 MHz sensors", 433_500_000.0, 434_400_000.0),
)
SCAN_WARNING = (
"These ranges cover a band the scanner cannot decode: {bands}. "
"The sensors there key a carrier on and off in bursts a fifth of a "
"second long, and a scan records that as clicks and finds no weather."
)
def scanning_sensor_band(ranges) -> str:
"""Warn when a sweep covers the band that needs this mode instead.
Returns the warning to print, or an empty string. 433 MHz is in the
band plan and choosing it from the band plan is the obvious thing to do
and the wrong one; saying so before the sweep starts costs a line.
"""
hit = []
for name, low, high in ISM_BANDS:
for r in ranges or ():
start = float(getattr(r, "start", 0.0) or 0.0)
stop = float(getattr(r, "stop", start) or start)
if start <= high and stop >= low:
hit.append(name)
break
if not hit:
return ""
return SCAN_WARNING.format(bands=", ".join(hit))
# ---------------------------------------------------------------------------
# The options
# ---------------------------------------------------------------------------
@dataclass
class WeatherOptions:
"""Everything the weather mode can be told, in one place."""
# -- receiver -------------------------------------------------------
device: int = 0
gain: str = "auto"
rate: float = 1_024_000.0
frequency: float = ACURITE_HZ
offset: float = 250_000.0
simulate: bool = False
# -- listening ------------------------------------------------------
seconds: float = 0.0
log: bool = True
messages: bool = False
hold: float = 1800.0
# -- sensors --------------------------------------------------------
units: str = "metric"
only_named: bool = False
unknown: bool = True
# -- afterwards -----------------------------------------------------
report: bool = True
csv: bool = False
@property
def imperial(self) -> bool:
return str(self.units).lower().startswith("imp")
def validate(self) -> list[str]:
out = []
if self.rate < 250_000.0:
out.append("the sample rate must be at least 250 kS/s or the "
"shortest pulse is too narrow to measure")
if abs(self.offset) >= self.rate / 2:
out.append("the tuning offset must be less than half the sample "
"rate or the sensors fall outside what is received")
if self.seconds < 0:
out.append("times cannot be negative")
if self.hold <= 0:
out.append("a sensor must stay on the display for some time")
return out
def to_dict(self) -> dict:
return asdict(self)
def defaults() -> WeatherOptions:
"""A fresh set, for showing what has been changed from it."""
return WeatherOptions()
O = Setting
OPTIONS: tuple[Setting, ...] = (
O("device", "Receiver", "Receiver", "int",
"which receiver to use, when more than one is plugged in",
"The index shown by `bandsaunter devices`. Zero unless you have "
"several dongles.",
minimum=0, flags=("--device",), example="0"),
O("gain", "Gain", "Receiver", "gain",
"tuner gain in dB, or automatic",
"These sensors are a few milliwatts from the end of the garden, and "
"the automatic gain control usually finds them. A fixed high gain "
"reaches further where there is nothing strong nearby; 433 MHz also "
"carries car keys, doorbells and tyre sensors, any of which can be "
"close enough to overload a front end wound all the way up.",
flags=("--gain",), example="auto",
guidance="Leave it automatic first. If a sensor you know is there "
"never appears, try 40 or so."),
O("rate", "Sample rate", "Receiver", "float",
"how fast to sample; a quarter of a megasample is the minimum",
"The shortest pulse these sensors send is about two hundred "
"microseconds, so even the lowest rate a dongle will do gives fifty "
"samples to measure it with. The rate matters less here than it does "
"for aircraft; what it buys is room to sit off to one side of the "
"signal, which is what 'Tuning offset' is for.",
unit="Hz", minimum=250_000.0, flags=("--rate",), example="1024000"),
O("frequency", "Sensors on", "Receiver", "float",
"where the sensors transmit",
"433.92 MHz, which is where every one of these is sold to transmit. "
"It is worth knowing that the transmitters in them are unlocked and "
"drift tens of kilohertz with temperature -- an outdoor sensor in "
"January is not on the same frequency it was in July -- which is why "
"this listens to a band rather than to a channel.",
unit="Hz", minimum=300_000_000.0, maximum=1_000_000_000.0,
flags=("--frequency", "--freq"), example="433920000"),
O("offset", "Tuning offset", "Receiver", "float",
"how far to one side of the sensors to tune the receiver",
"Every RTL-SDR puts a spike of its own making at whatever it is tuned "
"to. A spike sitting on top of a signal that works by being switched "
"on and off is the one thing that stops it being off, so the receiver "
"is tuned to one side and the signal is shifted back in software. "
"Zero tunes straight at the sensors, which works with an R820T2 that "
"has been calibrated and not otherwise.",
unit="Hz", minimum=0.0, flags=("--offset",), example="250000",
guidance="A quarter of the sample rate is right and is the default. "
"Only set it to zero to see what the spike was costing."),
O("simulate", "Invent a garden", "Receiver", "bool",
"put imaginary sensors on an imaginary fence",
"Six sensors that are not there -- one of every model this reads -- "
"transmitting real messages with real checksums, keyed on and off as "
"a real one does, through the real slicer and the real decoders. "
"Nothing touches the receiver, so the naming, the log, the report and "
"the export can all be tried before an aerial exists.",
flags=("--simulate",), off_flags=("--no-simulate",),
guidance="Turn this on to see what the whole thing does without "
"hardware. Turn it off to hear real sensors."),
# -- listening ------------------------------------------------------
O("seconds", "Listen for", "Listening", "float",
"how long to listen before stopping (0 = until interrupted)",
"Weather is a long game: a sensor reports every sixteen seconds and "
"the interesting part is the shape of a night, so 0 -- until "
"control-C -- is the usual answer. Everything heard is on the disk as "
"it arrives, so stopping never loses anything. A number is useful for "
"finding out whether the aerial hears anything at all.",
unit="s", minimum=0.0, flags=("--seconds",), example="600"),
O("log", "Write a log", "Listening", "bool",
"write every message down as it arrives",
"One line of JSON per message, in the output directory, holding the "
"raw bytes as well as what was made of them. It is what the report "
"and the spreadsheet are made from afterwards, and it is what lets a "
"later version of this program read a model this one cannot.",
flags=("--log",), off_flags=("--no-log",)),
O("messages", "Print every message", "Listening", "bool",
"one line per message instead of a table that updates in place",
"The table is easier to watch and useless in a pipe or a file. Turn "
"this on to get a plain stream of lines that can be piped somewhere, "
"or to watch individual receptions arrive while working out whether "
"an aerial is any good.",
flags=("--messages",), off_flags=("--no-messages",)),
O("hold", "Keep on screen for", "Listening", "float",
"how long a sensor stays on the display after its last message",
"Half an hour by default, which is long compared with the sixteen "
"seconds between messages and short compared with an evening. A "
"sensor that has stopped transmitting stays visible long enough to "
"notice that it has, which is the thing worth noticing: it usually "
"means a battery.",
unit="s", minimum=1.0, flags=("--hold",), example="1800"),
# -- sensors --------------------------------------------------------
O("units", "Show readings in", "Sensors", "choice",
"metric or imperial, for the display and the export",
"Only what is shown. What the sensors send is converted once, on the "
"way in, to degrees Celsius, kilometres an hour and millimetres, and "
"the log holds that -- so the choice here can be changed afterwards "
"and old logs reread in the other system.",
choices=("metric", "imperial"), flags=("--units",), example="metric"),
O("only_named", "Only named sensors", "Sensors", "bool",
"ignore sensors that have not been given a name",
"433 MHz is a busy band in a street of houses, and the sensors next "
"door are received as readily as your own. Once everything of yours "
"has a name, this makes the display yours alone. It applies to the "
"log as well, so turning it on is also how to stop recording the "
"neighbours' weather.",
flags=("--only-named",), off_flags=("--all-sensors",),
guidance="Leave it off until you have named things, or there will be "
"nothing to name."),
O("unknown", "Show unreadable models", "Sensors", "bool",
"list sensors whose messages framed correctly and were not understood",
"A message can satisfy its checksum and its parity and still be from "
"a model this does not read -- an Atlas, a fridge thermometer. There "
"is no weather to show for it, but there is an identity that stays "
"the same, and knowing that something is out there transmitting is "
"worth a line. Turn it off for a display of only what can be read.",
flags=("--unknown",), off_flags=("--no-unknown",)),
# -- afterwards -----------------------------------------------------
O("report", "Report at the end", "Afterwards", "bool",
"print what each sensor said when the listening stops",
"A table per sensor: how many messages arrived, how often, and the "
"first, last, lowest and highest of everything it reported.",
flags=("--report",), off_flags=("--no-report",)),
O("csv", "Also write a spreadsheet", "Afterwards", "bool",
"write the readings as CSV beside the log",
"A column per quantity and a row per reading, with the sensor's name "
"in it, which is the shape anything that draws a graph wants. It can "
"also be made later from a log with `bandsaunter readings --csv`.",
flags=("--csv",), off_flags=("--no-csv",)),
)
OPTION_GROUPS = ("Receiver", "Listening", "Sensors", "Afterwards")
def in_group(group: str) -> list[Setting]:
return [o for o in OPTIONS if o.group == group]
def by_key(key: str) -> Setting | None:
return next((o for o in OPTIONS if o.key == key), None)
_ZERO_MEANS = {"seconds": "until stopped", "offset": "straight at them"}
def format_option(option: Setting, value) -> str:
"""Render an option the way the menu should show it."""
if not value and option.key in _ZERO_MEANS:
return _ZERO_MEANS[option.key]
return format_value(option, value)
def describe(options: WeatherOptions) -> str:
"""One line for a menu: what listening would do now."""
how_long = ("until stopped" if not options.seconds
else f"{options.seconds:g} s")
where = "simulated" if options.simulate \
else f"{options.frequency / 1e6:g} MHz"
return (f"{where}, {how_long}, "
f"{'named sensors only' if options.only_named else 'everything'}, "
f"{options.units}")
def summarise(options: WeatherOptions) -> str:
"""The same thing, for the main menu's one grey line."""
return describe(options)
# ---------------------------------------------------------------------------
# Where the options are kept
# ---------------------------------------------------------------------------
def options_path(directory=None) -> Path:
from .config import DEFAULT_CONFIG_DIR
return Path(directory or DEFAULT_CONFIG_DIR) / "weather.yaml"
def load_options(directory=None) -> WeatherOptions:
"""The saved options, or the defaults. A broken file is not an error."""
options = WeatherOptions()
try:
body = yaml.safe_load(options_path(directory).read_text()) or {}
except (OSError, ValueError, yaml.YAMLError):
return options
if not isinstance(body, dict):
return options
known = set(options.__dict__)
for key, value in body.items():
if key in known and value is not None:
try:
setattr(options, key, type(getattr(options, key))(value))
except (TypeError, ValueError):
pass
return options
def save_options(options: WeatherOptions, directory=None) -> Path:
path = options_path(directory)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as fh:
yaml.safe_dump(options.to_dict(), fh, sort_keys=False,
default_flow_style=False)
return path
# ---------------------------------------------------------------------------
# What is out there, and what it last said
# ---------------------------------------------------------------------------
@dataclass
class Station:
"""One sensor as the display thinks of it: the latest of everything.
Not the latest message -- the latest *value of each quantity*, which is
not the same thing and is what somebody watching wants. The 5-in-1 has
more to say than fits in one message and alternates two of them, so its
last message is either the wind and the rain or the temperature and the
humidity, never both. Keeping the newest of each means the line on the
screen shows the whole sensor rather than half of it flickering.
Each value remembers when it arrived, so a quantity that has stopped
being reported while the sensor is otherwise fine can be seen to have.
"""
key: str = ""
model: str = ""
channel: str = ""
first: float = 0.0
last: float = 0.0
messages: int = 0
copies: int = 0
battery_low: bool = False
values: dict = field(default_factory=dict) # name -> the latest Measure
times: dict = field(default_factory=dict) # name -> when that arrived
firsts: dict = field(default_factory=dict) # name -> the first value
low: dict = field(default_factory=dict) # name -> the lowest seen
high: dict = field(default_factory=dict) # name -> the highest seen
unread: int = 0 # messages not understood
@property
def sensor(self) -> str:
return self.key.split("/", 1)[-1]
@property
def family(self) -> str:
return self.key.split("/", 1)[0] if "/" in self.key else ""
def add(self, reading) -> None:
self.model = reading.model or self.model
self.channel = reading.channel or self.channel
self.first = self.first or reading.at
self.last = max(self.last, reading.at)
self.messages += 1
self.copies += max(1, reading.copies)
self.battery_low = bool(reading.battery_low)
if not reading.measures:
self.unread += 1
for measure in reading.measures:
name, value = measure.name, measure.value
if name not in self.values:
self.firsts[name] = value
self.low[name] = self.high[name] = value
else:
self.low[name] = min(self.low[name], value)
self.high[name] = max(self.high[name], value)
self.values[name] = measure
self.times[name] = reading.at
def line(self, imperial: bool = False) -> str:
"""Everything it is currently saying, on one line."""
return " ".join(f"{name} {format_measure(m, imperial)}"
for name, m in self.values.items())
@property
def gap(self) -> float:
"""The average wait between messages, in seconds, or zero.
The honest measure of how well a sensor is being received: these
transmit on a fixed cycle, so a gap of thirty seconds from a sensor
that sends every sixteen means half of them are being missed, and
that is an aerial problem rather than a weather one.
"""
return (self.last - self.first) / (self.messages - 1) \
if self.messages > 1 else 0.0
def span(self, name: str):
"""First, last, lowest and highest of one quantity, or None.
Not an average. These arrive every sixteen seconds when the sensor
is in range and not at all when it is not, and rain and cold both
shorten the range of a 433 MHz transmitter -- so the mean of what was
received is the mean of a sample whose gaps are themselves the
weather, and it would read like a number when it is not one.
"""
if name not in self.values:
return None
return (self.firsts[name], self.values[name].value,
self.low[name], self.high[name])
class Garden:
"""Every sensor heard in this session, in the order they first spoke.
In the order they were first heard, deliberately, so that a row does not
jump about under the eye while it is being read -- which matters more
here than it does for aircraft, because naming a sensor means reading a
row and then typing its number.
"""
def __init__(self):
self.stations: dict[str, Station] = {}
self.messages = 0
def __len__(self) -> int:
return len(self.stations)
def add(self, reading) -> Station:
station = self.stations.get(reading.key)
if station is None:
station = self.stations[reading.key] = Station(key=reading.key)
station.add(reading)
self.messages += 1
return station
def showing(self, hold: float, now: float | None = None) -> list[Station]:
"""The sensors still worth a line, first heard at the top."""
now = time.time() if now is None else now
alive = [s for s in self.stations.values() if now - s.last <= hold]
return sorted(alive, key=lambda s: (s.first, s.key))
def all(self) -> list[Station]:
return sorted(self.stations.values(), key=lambda s: (s.first, s.key))
@classmethod
def of(cls, readings) -> "Garden":
"""The same thing, built from a log rather than from the air."""
garden = cls()
for reading in readings:
garden.add(reading)
return garden
# ---------------------------------------------------------------------------
# Listening
# ---------------------------------------------------------------------------
@dataclass
class Heard:
"""What one listening session came to."""
messages: int = 0
garden: object = None
book: object = None
log_path: Path | None = None
csv_path: Path | None = None
named: int = 0 # how many were given a name while listening
@property
def sensors(self) -> int:
return len(self.garden) if self.garden is not None else 0
def open_device(console, options: WeatherOptions):
"""The receiver, or an invented garden, or None if neither can be had."""
from rich.panel import Panel
from rich.text import Text
from .acurite import SimulatedSensors
from .device import RtlSdrDevice, RtlSdrError
if options.simulate:
console.print("[yellow]simulated: these sensors are not there."
"[/yellow]")
return SimulatedSensors(sample_rate=options.rate,
offset=options.offset, realtime=True).open()
try:
device = RtlSdrDevice(index=options.device,
sample_rate=int(options.rate),
gain=options.gain,
agc=options.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return None
return device
def open_log(console, options: WeatherOptions, output_dir: str,
started: float, log_path=None):
"""The message log, or None if it was not wanted or cannot be written."""
from .weatherlog import WeatherLog
if not options.log:
return None
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
where = Path(log_path).expanduser() if log_path else \
Path(output_dir).expanduser() / f"weather_{stamp}.jsonl"
try:
return WeatherLog(where, frequency=options.frequency,
sample_rate=options.rate,
receiver="simulated" if options.simulate else
f"device {options.device}", started=started)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return None
def pump(device, options: WeatherOptions, garden: Garden, book, log,
started: float, on_block=None, on_reading=None,
stopping=None) -> int:
"""Read the receiver until it stops, or until told to.
The one loop both front ends are driven from, so that what is written to
the log cannot depend on which one you happened to be looking at.
"""
from .acurite import readings_from
total = 0
# Tuned to one side, and shifted back in software: see the 'Tuning
# offset' option, and :func:`bandsaunter.acurite.baseband`.
device.tune(options.frequency - options.offset)
block = int(options.rate) # a second at a time
while stopping is None or not stopping():
at = time.time()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
for reading in readings_from(samples, options.rate, options.offset,
when=at):
if not reading.measures and not options.unknown:
continue
if options.only_named and not book.name_for(reading.key):
continue
sensor = book.heard(reading)
garden.add(reading)
total += 1
if log is not None:
log.append(reading, name=sensor.name)
if on_reading is not None:
on_reading(reading, sensor)
if on_block is not None:
on_block(total)
if options.seconds and time.time() - started >= options.seconds:
break
return total
def listen(console, options: WeatherOptions, output_dir: str,
log_path=None, book=None) -> Heard:
"""Park on 433.92 MHz and write down what the neighbourhood says.
Everything heard goes into the log as it arrives, and the names go into
their own file the moment they are typed, because those are the two
things that would be a shame to lose and they are lost in different ways.
"""
from .sensors import SensorBook
heard = Heard()
device = open_device(console, options)
if device is None:
return heard
started = time.time()
log = open_log(console, options, output_dir, started, log_path)
book = SensorBook() if book is None else book
garden = Garden()
heard.garden, heard.book = garden, book
console.print(f"[grey62]listening on {options.frequency / 1e6:g} MHz at "
f"{options.rate / 1e6:g} MS/s — control-C to stop"
"[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
display, live = _open_display(console, options, book, started)
if live is not None:
console.print()
def on_block(total: int) -> None:
if live is None:
return
display.update(garden, total, log.path if log is not None else None)
live.update(display.render())
def on_reading(reading, sensor) -> None:
if options.messages or live is None:
label = sensor.name or reading.sensor
console.print(f"[cyan]{label}[/cyan] "
f"{reading.describe(options.imperial)}",
highlight=False)
total = 0
try:
total = _run(console, device, options, garden, book, log, started,
display, live, on_block, on_reading, heard)
except KeyboardInterrupt:
pass
finally:
if live is not None:
live.stop()
console.print("[grey62]stopped listening[/grey62]")
device.close()
if log is not None:
log.close()
heard.log_path = log.path
heard.messages = total
return finish(console, options, output_dir, heard, started)
def _run(console, device, options, garden, book, log, started,
display, live, on_block, on_reading, heard) -> int:
"""The listening loop, with the keyboard live where there is one.
Naming happens here rather than afterwards because that is the moment it
is possible: the sensor is on the screen, saying 4.2 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.
"""
from .ui import KeyReader
if live is None:
return pump(device, options, garden, book, log, started,
on_block=on_block, on_reading=on_reading)
with KeyReader() as keys:
pressed = {"key": ""}
def block(total: int) -> None:
on_block(total)
key = keys.get()
if not key:
return
if key in ("n", "N"):
pressed["key"] = "name"
elif key in ("q", "Q"):
pressed["key"] = "stop"
def stopping() -> bool:
if pressed["key"] == "name":
pressed["key"] = ""
if _name_one(console, keys, live, display, book, garden,
options):
heard.named += 1
return False
return pressed["key"] == "stop"
return pump(device, options, garden, book, log, started,
on_block=block, on_reading=on_reading, stopping=stopping)
def _name_one(console, keys, live, display, book, garden,
options: WeatherOptions) -> bool:
"""Ask which sensor to name, and name it. True if one was named.
The live table has to come down while this happens, and the terminal has
to come out of the mode that reads single keys, or nothing typed would
be visible. Both go back afterwards. The receiver is not stopped: this
runs between two blocks, so a slow typist loses a few seconds of weather
and nothing else.
"""
from rich.prompt import Prompt
live.stop()
try:
# Out of single-key mode, so that a name can be typed and seen, and
# back into it afterwards. Re-entering is what the reader is for.
keys.__exit__(None, None, None)
shown = garden.showing(options.hold)
if not shown:
console.print(" [yellow]nothing heard yet to name[/yellow]")
return False
console.print()
for i, station in enumerate(shown, 1):
name = book.name_for(station.key)
console.print(
f" [cyan]{i:2d}[/cyan] {station.sensor:<6} "
f"[grey62]{station.model:<18}[/grey62] "
f"{('[green]' + name + '[/green]') if name else '[grey62]—[/grey62]'}"
f" [grey62]{station.line(options.imperial)}[/grey62]")
answer = Prompt.ask("\n which one (blank to go back)",
default="", show_default=False).strip()
if not answer.isdigit() or not 1 <= int(answer) <= len(shown):
return False
station = shown[int(answer) - 1]
was = book.name_for(station.key)
name = Prompt.ask(f" a name for {station.sensor}", default=was,
show_default=bool(was)).strip()
if not name:
return False
book.tag(station.key, name)
console.print(f" [green]{station.sensor} is now "
f"{name}[/green] [grey62]saved in {book.path}[/grey62]")
return True
except (EOFError, KeyboardInterrupt):
return False
finally:
keys.__enter__()
live.start()
def _open_display(console, options: WeatherOptions, book, started: float):
"""A live table where there is a terminal to draw it on, else nothing.
Piped output, a test or a log file gets a line per message instead: a
display that redraws itself twice a second is unreadable as a stream of
text, and worse than useless in a file.
"""
if options.messages or not getattr(console, "is_terminal", False):
return None, None
from rich.live import Live
from .ui import WeatherDisplay
display = WeatherDisplay(console, book=book, hold=options.hold,
imperial=options.imperial,
frequency=options.frequency)
display.started = started
live = Live(display.render(), console=console, refresh_per_second=2,
screen=False, transient=False, vertical_overflow="crop")
live.start()
return display, live
# ---------------------------------------------------------------------------
# What the evening came to
# ---------------------------------------------------------------------------
def finish(console, options: WeatherOptions, output_dir: str, heard: Heard,
started: float) -> Heard:
"""Save the counts, say what was heard, and offer the way back in."""
garden, book = heard.garden, heard.book
if book is not None:
# The names were saved when they were typed; this is the counts and
# the times, which are what make the list nameable next time.
try:
book.flush()
except OSError as exc:
console.print(f"[red]cannot save {book.path}: {exc}[/red]")
if garden is None or not len(garden):
console.print("[yellow]nothing heard. These sensors are a few "
"milliwatts at 433.92 MHz: a quarter-wave whip is "
"17 cm, and indoors is usually the problem."
"[/yellow]")
return heard
if options.report:
report(console, garden, book, options.imperial)
if options.csv and heard.log_path is not None:
heard.csv_path = _write_csv(console, heard, book, options)
if heard.csv_path is not None:
console.print(f"[green]wrote {heard.csv_path}[/green]")
if heard.log_path is not None:
console.print(f"[grey62]read it back: bandsaunter readings "
f"{heard.log_path}[/grey62]")
nameless = sum(1 for s in garden.all() if not book.name_for(s.key)) \
if book is not None else 0
if nameless:
console.print(f"[grey62]{nameless} sensor"
f"{'s' if nameless != 1 else ''} without a name — "
f"name them with `bandsaunter sensors --name "
f"ID=NAME`, or press n while listening[/grey62]")
return heard
def _write_csv(console, heard: Heard, book, options: WeatherOptions):
from .weatherlog import read_logs, write_csv
where = Path(heard.log_path).with_suffix(".csv")
try:
return write_csv(where, read_logs([heard.log_path]), book,
options.imperial)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return None
def report(console, garden: Garden, book=None, imperial: bool = False) -> None:
"""Two tables: which sensors were heard, and what each of them said.
Split in two on purpose. The first is about reception -- who, how often,
how well -- and is the one to look at when something is missing. The
second is about the weather, and is the one to look at when nothing is.
"""
from rich.table import Table
stations = garden.all()
t = Table(box=None, header_style="bold", pad_edge=False,
title="[bold]sensors heard[/bold]", title_justify="left")
t.add_column("name", overflow="fold")
t.add_column("id", style="grey62", no_wrap=True)
t.add_column("model", style="grey62", overflow="fold")
t.add_column("ch", style="grey62", justify="center")
t.add_column("msgs", justify="right")
t.add_column("every", style="grey62", justify="right")
t.add_column("battery")
t.add_column("last heard", style="grey62", no_wrap=True)
for station in stations:
name = book.name_for(station.key) if book is not None else ""
t.add_row(name or "[grey62]—[/grey62]",
station.sensor, station.model or "", station.channel or "",
f"{station.messages:,}",
f"{station.gap:.0f} s" if station.gap else "",
"[red]low[/red]" if station.battery_low else "[green]ok[/green]",
datetime.fromtimestamp(station.last).strftime("%H:%M:%S")
if station.last else "")
console.print(t)
rows = [(s, name) for s in stations for name in s.values]
if not rows:
console.print("[grey62]no readable measurements: every message that "
"arrived was from a model this cannot read[/grey62]")
return
w = Table(box=None, header_style="bold", pad_edge=False,
title="\n[bold]what they said[/bold]", title_justify="left")
w.add_column("sensor", overflow="fold")
w.add_column("reading")
w.add_column("first", justify="right")
w.add_column("last", justify="right")
w.add_column("lowest", justify="right", style="grey62")
w.add_column("highest", justify="right", style="grey62")
last_key = None
for station, name in rows:
span = station.span(name)
if span is None:
continue
label = (book.label_for(station.key) if book is not None
else station.sensor)
unit = station.values[name].unit
shown = [_shown(value, unit, imperial) for value in span]
if unit == "deg":
# A bearing has no lowest or highest. North is 0 and also 360,
# so the lowest wind direction of an evening is whichever side
# of north the wind happened to sit, and it would be read as a
# fact about the weather rather than about arithmetic.
shown[2] = shown[3] = ""
w.add_row("" if station.key == last_key else label, name, *shown)
last_key = station.key
console.print(w)
unread = sum(s.unread for s in stations)
if unread:
console.print(f"[grey62]{unread:,} message"
f"{'s' if unread != 1 else ''} framed correctly and "
f"came from a model this cannot read[/grey62]")
def _shown(value: float, unit: str, imperial: bool) -> str:
return format_measure(Measure("", float(value), unit), imperial)

251
bandsaunter/weatherlog.py Normal file
View file

@ -0,0 +1,251 @@
"""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)

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_04" "User Commands"
.TH BANDSAUNTER 1 "2026-09-07" "bandsaunter 2026-09-07_01" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -85,6 +85,20 @@ animation. See
.B AIRCRAFT
below.
.TP
.B weather
Listen to the AcuRite weather sensors on 433.92 MHz, and name them as they
arrive. See
.B WEATHER SENSORS
below.
.TP
.B readings
Read a weather log back: the report, and a spreadsheet. See
.B WEATHER SENSORS
below.
.TP
.B sensors
List every weather sensor heard, and give them names.
.TP
.B analyze
Identify a signal in an already-recorded file, decode Morse from it, or write
out the picture it turns out to be.
@ -2148,6 +2162,289 @@ sideband by which way the signal's energy leans, so
.B \-\-mode usb
is not needed. The frequency in the filename is the carrier \[em] the
frequency to dial into a radio.
.SH WEATHER SENSORS
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, on 433.92 MHz, to anyone who happens
to be listening.
.B bandsaunter weather
reads the box.
.PP
It is a mode 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 \[em] it would record the bursts as clicks in a WAV file and
decode nothing.
.SS What it reads
Five families, each with its own framing and its own check.
.TP
.B "Tower 592TXR / 06002RM"
Seven bytes: temperature and humidity.
.TP
.B "5-in-1 06014RM / VN1TXC"
Eight bytes, in two kinds sent alternately: wind speed with wind direction and
rainfall, or wind speed with temperature and humidity. It has more to say than
fits in one message, so the display keeps the newest value of each quantity
rather than the newest message.
.TP
.B "Lightning 6045M"
Nine bytes: temperature, humidity, the cumulative strike count, and how far
off the storm is. A bit set when the detector believes it is being interfered
with is shown too, because a strike count that climbs while it is set is not
lightning.
.TP
.B 609TXC
Five bytes: temperature and humidity.
.TP
.B 606TX
Four bytes: temperature, and nothing else at all.
.PP
Battery state comes from all of them. The Atlas, the 986 and 515 fridge
thermometers, the 00275rm room monitor and the 899 standalone rain gauge are
on the same band and are not decoded; a message from one whose framing happens
to match is reported as an unknown message type with its identity and nothing
else, rather than guessed at.
.PP
These formats are implemented from their published descriptions and are
checked against frames built from the same descriptions. That proves the
framing, the parity, the checksums and the arithmetic; it is not the same as
having held every one of these sensors.
.SS Naming a sensor
A sensor broadcasts an identity, and that identity is a number that came out
of a hat in a factory \[em] or a different number out of the same hat the next
time the batteries were changed. It tells one sensor from another and is no
use at all for telling which is which.
.PP
So press
.B n
while listening. The display comes down, the sensors are listed with numbers,
you pick one and type a name, and it goes back up. The receiver keeps running
throughout: a slow typist loses a few seconds of weather and nothing else.
That is the moment it is possible to do \[em] 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.
.PP
Names can also be given with
.BI \-\-name " ID=NAME"
on
.B "bandsaunter weather"
or
.BR "bandsaunter sensors" ,
before or after anything has been heard: a name given before the sensor has
ever been received waits under its identity alone, because nothing yet knows
which model it is, and moves across the moment the first message arrives. They
live in
.I sensors.yaml
beside the settings, are written the moment they are given rather than when
the program exits, and are written to a neighbouring file which is renamed
over the old one, so a machine losing power halfway through leaves either the
old names or the new ones and never half of each. Nothing is ever dropped for
being stale.
.SS Why nothing false gets through
433 MHz is a crowded band \[em] doorbells, car keys, tyre-pressure sensors,
garage doors \[em] and a decoder that looks at every bit offset of every burst
will find a message in noise if it is allowed to. Four things stop it.
.PP
The three newer models carry an eight-bit sum plus odd parity in the top bit
of every payload byte, which is twelve to fourteen bits of check.
.PP
The two older models carry one byte of check between them, which is one false
message in two hundred and fifty-six, so those two are only believed when the
same message arrives twice. It costs nothing: these sensors send everything
three times in a row, for exactly this reason.
.PP
Nothing outside what the hardware can report is accepted \[em] no temperature
beyond \-40 to 70 \[de]C, no humidity above 100 per cent, no wind the
anemometer cannot physically produce.
.PP
And a message must sit where a message sits. 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. Corroboration does not help either,
the copies of a message being identical. What gives that window away every
time is that it ends a whole byte before the burst does.
.SS Getting it off the air
The receiver is tuned a little to one side of 433.92 MHz, because every
RTL-SDR puts a spike of its own at whatever it is tuned to, and a spike
sitting on top of a signal that works by being switched on and off is the one
thing that stops it being off. The sensors are shifted back to the middle in
software, which puts the spike out at the edge instead.
.B \-\-offset 0
tunes straight at them, which is worth trying once to see what the spike was
costing.
.PP
A running average of the complex samples then rejects the spike, and only
after that is the magnitude taken \[em] filtering before detection rather than
after is what keeps the neighbours out of the envelope of the sensor.
.PP
Slicing the envelope into bits never measures anything against a clock. The
newer sensors vary the length of the pulse and keep the gaps even; the two
older ones keep the pulse even and vary the gap. Both readings of the same
pulses are tried and the checksums say which it was. A transmitter running ten
per cent fast is read correctly and never noticed, which matters: these are
unlocked and drift with the temperature, and an outdoor sensor in January is
not the one that was on the fence in July.
.SS Afterwards
When the listening stops, two tables. The first is about reception \[em] who,
how often, how well \[em] and is the one to look at when something is missing:
these transmit on a fixed cycle, so a gap of thirty seconds from a sensor that
sends every sixteen means half of them are being missed, and that is an aerial
problem rather than a weather one. The second is the first, last, lowest and
highest of everything each sensor reported.
.PP
There is no average, deliberately. These arrive every sixteen seconds when the
sensor is in range and not at all when it is not, and rain and cold both
shorten the range of a 433 MHz transmitter, so the mean of what was received
is the mean of a sample whose gaps are themselves the weather. A bearing gets
no lowest or highest either: north is 0 and also 360.
.PP
.B \-\-csv
writes a column per quantity and a row per reading, with the sensor's name in
the second column and the unit in the heading rather than beside every number.
.B "bandsaunter readings \-\-csv"
does the same to an old log, and takes
.BI \-\-sensor " NAME"
to narrow it to one sensor.
.PP
The log keeps the raw bytes of every message underneath whatever was made of
them, because the message is the evidence and the rest of the line is an
opinion about it. Readings are converted once, on the way in, to Celsius,
kilometres an hour, millimetres and kilometres \[em] different models report
in different units \[em] so
.B \-\-units imperial
changes only what is shown, and can be changed afterwards on an old log.
.SS Without a sensor
.B \-\-simulate
puts six sensors on a fence that does not exist, one of every model,
transmitting real messages with real checksums, keyed on and off as a real one
does, through the real filter, the real slicer and the real decoders. Nothing
touches the receiver.
.SS If nothing is heard
These are a few milliwatts. A quarter-wave whip for 433.92 MHz is 17 cm of
wire, and the stock telescopic aerial set to about that length works well;
indoors, behind a wall, with the dongle in the back of a machine, is usually
the problem. Try
.B \-\-gain 40
if the automatic gain control is not finding them, and
.B \-\-messages
to watch individual receptions arrive while moving the aerial about.
.SH WEATHER OPTIONS
Every option the weather side takes, in the four groups the menu shows them
in. Each is a flag here and a line in the menu, and both come from one table
in the program, so they cannot disagree.
.SS Receiver
.TP
.B --device
Receiver \[em] which receiver to use, when more than one is plugged in.
.br
Setting name \fBdevice\fR, default \fB0\fR.
.br
Accepts: at least 0.
.TP
.B --gain
Gain \[em] tuner gain in dB, or automatic.
.br
Setting name \fBgain\fR, default \fBauto\fR.
.RS
.PP
Leave it automatic first. If a sensor you know is there never appears, try 40 or so.
.RE
.TP
.B --rate
Sample rate \[em] how fast to sample; a quarter of a megasample is the minimum (Hz).
.br
Setting name \fBrate\fR, default \fB1.024 MHz\fR.
.br
Accepts: at least 250000.
.TP
.B --frequency --freq
Sensors on \[em] where the sensors transmit (Hz).
.br
Setting name \fBfrequency\fR, default \fB433.92 MHz\fR.
.br
Accepts: at least 3e+08, at most 1e+09.
.TP
.B --offset
Tuning offset \[em] how far to one side of the sensors to tune the receiver (Hz).
.br
Setting name \fBoffset\fR, default \fB250 kHz\fR.
.br
Accepts: at least 0.
.RS
.PP
A quarter of the sample rate is right and is the default. Only set it to zero to see what the spike was costing.
.RE
.TP
.B --simulate / --no-simulate
Invent a garden \[em] put imaginary sensors on an imaginary fence.
.br
Setting name \fBsimulate\fR, default \fBno\fR.
.RS
.PP
Turn this on to see what the whole thing does without hardware. Turn it off to hear real sensors.
.RE
.PP
.SS Listening
.TP
.B --seconds
Listen for \[em] how long to listen before stopping (0 = until interrupted) (s).
.br
Setting name \fBseconds\fR, default \fBuntil stopped\fR.
.br
Accepts: at least 0.
.TP
.B --log / --no-log
Write a log \[em] write every message down as it arrives.
.br
Setting name \fBlog\fR, default \fByes\fR.
.TP
.B --messages / --no-messages
Print every message \[em] one line per message instead of a table that updates in place.
.br
Setting name \fBmessages\fR, default \fBno\fR.
.TP
.B --hold
Keep on screen for \[em] how long a sensor stays on the display after its last message (s).
.br
Setting name \fBhold\fR, default \fB1800 s\fR.
.br
Accepts: at least 1.
.PP
.SS Sensors
.TP
.B --units
Show readings in \[em] metric or imperial, for the display and the export.
.br
Setting name \fBunits\fR, default \fBmetric\fR.
.br
Accepts: one of: metric, imperial.
.TP
.B --only-named / --all-sensors
Only named sensors \[em] ignore sensors that have not been given a name.
.br
Setting name \fBonly_named\fR, default \fBno\fR.
.RS
.PP
Leave it off until you have named things, or there will be nothing to name.
.RE
.TP
.B --unknown / --no-unknown
Show unreadable models \[em] list sensors whose messages framed correctly and were not understood.
.br
Setting name \fBunknown\fR, default \fByes\fR.
.PP
.SS Afterwards
.TP
.B --report / --no-report
Report at the end \[em] print what each sensor said when the listening stops.
.br
Setting name \fBreport\fR, default \fByes\fR.
.TP
.B --csv / --no-csv
Also write a spreadsheet \[em] write the readings as CSV beside the log.
.br
Setting name \fBcsv\fR, default \fBno\fR.
.PP
.SH FILES
.TP
.I ~/.config/bandsaunter/config.yaml
@ -2156,6 +2453,13 @@ The settings every run starts from.
.I ~/.config/bandsaunter/aircraft.yaml
The aircraft options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/weather.yaml
The weather options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/sensors.yaml
What each weather sensor is called. The only file here holding anything a
person typed; safe to edit by hand.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
@ -2164,6 +2468,11 @@ Every ADS-B frame heard in one listening session, with a
.I .txt
report and any picture drawn from it beside it.
.TP
.IR weather_ * .jsonl
Every weather sensor message heard in one listening session, with a
.I .csv
of the readings beside it where one was asked for.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output

View file

@ -57,15 +57,29 @@ def settings_section() -> list[str]:
def aircraft_section() -> list[str]:
"""Every ADS-B option, from the same table the menu and flags come from.
Written out rather than described in prose, so that an option added to
the program cannot quietly fail to appear in its manual.
"""
"""Every ADS-B option, from the same table the menu and flags come from."""
from bandsaunter import aircraft as air
return options_section(air)
def weather_section() -> list[str]:
"""Every weather option, from the same table."""
from bandsaunter import weather as wx
return options_section(wx)
def options_section(air) -> list[str]:
"""One section's options, written out from the table the program uses.
Written out rather than described in prose, so that an option added to
the program cannot quietly fail to appear in its manual. Both sections
describe their options in the same shape, so this does not need to know
which one it has been handed.
"""
out = []
defaults = air.AircraftOptions()
defaults = air.defaults()
for group in air.OPTION_GROUPS:
out.append(f'.SS {esc(group)}')
for o in air.in_group(group):
@ -180,6 +194,20 @@ animation. See
.B AIRCRAFT
below.
.TP
.B weather
Listen to the AcuRite weather sensors on 433.92 MHz, and name them as they
arrive. See
.B WEATHER SENSORS
below.
.TP
.B readings
Read a weather log back: the report, and a spreadsheet. See
.B WEATHER SENSORS
below.
.TP
.B sensors
List every weather sensor heard, and give them names.
.TP
.B analyze
Identify a signal in an already-recorded file, decode Morse from it, or write
out the picture it turns out to be.
@ -1246,6 +1274,177 @@ sideband by which way the signal's energy leans, so
.B \-\-mode usb
is not needed. The frequency in the filename is the carrier \[em] the
frequency to dial into a radio.
.SH WEATHER SENSORS
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, on 433.92 MHz, to anyone who happens
to be listening.
.B bandsaunter weather
reads the box.
.PP
It is a mode 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 \[em] it would record the bursts as clicks in a WAV file and
decode nothing.
.SS What it reads
Five families, each with its own framing and its own check.
.TP
.B "Tower 592TXR / 06002RM"
Seven bytes: temperature and humidity.
.TP
.B "5-in-1 06014RM / VN1TXC"
Eight bytes, in two kinds sent alternately: wind speed with wind direction and
rainfall, or wind speed with temperature and humidity. It has more to say than
fits in one message, so the display keeps the newest value of each quantity
rather than the newest message.
.TP
.B "Lightning 6045M"
Nine bytes: temperature, humidity, the cumulative strike count, and how far
off the storm is. A bit set when the detector believes it is being interfered
with is shown too, because a strike count that climbs while it is set is not
lightning.
.TP
.B 609TXC
Five bytes: temperature and humidity.
.TP
.B 606TX
Four bytes: temperature, and nothing else at all.
.PP
Battery state comes from all of them. The Atlas, the 986 and 515 fridge
thermometers, the 00275rm room monitor and the 899 standalone rain gauge are
on the same band and are not decoded; a message from one whose framing happens
to match is reported as an unknown message type with its identity and nothing
else, rather than guessed at.
.PP
These formats are implemented from their published descriptions and are
checked against frames built from the same descriptions. That proves the
framing, the parity, the checksums and the arithmetic; it is not the same as
having held every one of these sensors.
.SS Naming a sensor
A sensor broadcasts an identity, and that identity is a number that came out
of a hat in a factory \[em] or a different number out of the same hat the next
time the batteries were changed. It tells one sensor from another and is no
use at all for telling which is which.
.PP
So press
.B n
while listening. The display comes down, the sensors are listed with numbers,
you pick one and type a name, and it goes back up. The receiver keeps running
throughout: a slow typist loses a few seconds of weather and nothing else.
That is the moment it is possible to do \[em] 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.
.PP
Names can also be given with
.BI \-\-name " ID=NAME"
on
.B "bandsaunter weather"
or
.BR "bandsaunter sensors" ,
before or after anything has been heard: a name given before the sensor has
ever been received waits under its identity alone, because nothing yet knows
which model it is, and moves across the moment the first message arrives. They
live in
.I sensors.yaml
beside the settings, are written the moment they are given rather than when
the program exits, and are written to a neighbouring file which is renamed
over the old one, so a machine losing power halfway through leaves either the
old names or the new ones and never half of each. Nothing is ever dropped for
being stale.
.SS Why nothing false gets through
433 MHz is a crowded band \[em] doorbells, car keys, tyre-pressure sensors,
garage doors \[em] and a decoder that looks at every bit offset of every burst
will find a message in noise if it is allowed to. Four things stop it.
.PP
The three newer models carry an eight-bit sum plus odd parity in the top bit
of every payload byte, which is twelve to fourteen bits of check.
.PP
The two older models carry one byte of check between them, which is one false
message in two hundred and fifty-six, so those two are only believed when the
same message arrives twice. It costs nothing: these sensors send everything
three times in a row, for exactly this reason.
.PP
Nothing outside what the hardware can report is accepted \[em] no temperature
beyond \-40 to 70 \[de]C, no humidity above 100 per cent, no wind the
anemometer cannot physically produce.
.PP
And a message must sit where a message sits. 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. Corroboration does not help either,
the copies of a message being identical. What gives that window away every
time is that it ends a whole byte before the burst does.
.SS Getting it off the air
The receiver is tuned a little to one side of 433.92 MHz, because every
RTL-SDR puts a spike of its own at whatever it is tuned to, and a spike
sitting on top of a signal that works by being switched on and off is the one
thing that stops it being off. The sensors are shifted back to the middle in
software, which puts the spike out at the edge instead.
.B \-\-offset 0
tunes straight at them, which is worth trying once to see what the spike was
costing.
.PP
A running average of the complex samples then rejects the spike, and only
after that is the magnitude taken \[em] filtering before detection rather than
after is what keeps the neighbours out of the envelope of the sensor.
.PP
Slicing the envelope into bits never measures anything against a clock. The
newer sensors vary the length of the pulse and keep the gaps even; the two
older ones keep the pulse even and vary the gap. Both readings of the same
pulses are tried and the checksums say which it was. A transmitter running ten
per cent fast is read correctly and never noticed, which matters: these are
unlocked and drift with the temperature, and an outdoor sensor in January is
not the one that was on the fence in July.
.SS Afterwards
When the listening stops, two tables. The first is about reception \[em] who,
how often, how well \[em] and is the one to look at when something is missing:
these transmit on a fixed cycle, so a gap of thirty seconds from a sensor that
sends every sixteen means half of them are being missed, and that is an aerial
problem rather than a weather one. The second is the first, last, lowest and
highest of everything each sensor reported.
.PP
There is no average, deliberately. These arrive every sixteen seconds when the
sensor is in range and not at all when it is not, and rain and cold both
shorten the range of a 433 MHz transmitter, so the mean of what was received
is the mean of a sample whose gaps are themselves the weather. A bearing gets
no lowest or highest either: north is 0 and also 360.
.PP
.B \-\-csv
writes a column per quantity and a row per reading, with the sensor's name in
the second column and the unit in the heading rather than beside every number.
.B "bandsaunter readings \-\-csv"
does the same to an old log, and takes
.BI \-\-sensor " NAME"
to narrow it to one sensor.
.PP
The log keeps the raw bytes of every message underneath whatever was made of
them, because the message is the evidence and the rest of the line is an
opinion about it. Readings are converted once, on the way in, to Celsius,
kilometres an hour, millimetres and kilometres \[em] different models report
in different units \[em] so
.B \-\-units imperial
changes only what is shown, and can be changed afterwards on an old log.
.SS Without a sensor
.B \-\-simulate
puts six sensors on a fence that does not exist, one of every model,
transmitting real messages with real checksums, keyed on and off as a real one
does, through the real filter, the real slicer and the real decoders. Nothing
touches the receiver.
.SS If nothing is heard
These are a few milliwatts. A quarter-wave whip for 433.92 MHz is 17 cm of
wire, and the stock telescopic aerial set to about that length works well;
indoors, behind a wall, with the dongle in the back of a machine, is usually
the problem. Try
.B \-\-gain 40
if the automatic gain control is not finding them, and
.B \-\-messages
to watch individual receptions arrive while moving the aerial about.
.SH WEATHER OPTIONS
Every option the weather side takes, in the four groups the menu shows them
in. Each is a flag here and a line in the menu, and both come from one table
in the program, so they cannot disagree.
.WEATHER_OPTIONS_HERE
.SH FILES
.TP
.I ~/.config/bandsaunter/config.yaml
@ -1254,6 +1453,13 @@ The settings every run starts from.
.I ~/.config/bandsaunter/aircraft.yaml
The aircraft options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/weather.yaml
The weather options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/sensors.yaml
What each weather sensor is called. The only file here holding anything a
person typed; safe to edit by hand.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
@ -1262,6 +1468,11 @@ Every ADS-B frame heard in one listening session, with a
.I .txt
report and any picture drawn from it beside it.
.TP
.IR weather_ * .jsonl
Every weather sensor message heard in one listening session, with a
.I .csv
of the readings beside it where one was asked for.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output
@ -1373,6 +1584,8 @@ def main() -> int:
text = "\n".join(out)
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
"\n".join(aircraft_section()))
text = text.replace(".WEATHER_OPTIONS_HERE",
"\n".join(weather_section()))
text = text.replace("\n\n", "\n") # troff dislikes blank lines
target = Path(sys.argv[1] if len(sys.argv) > 1
else Path(__file__).parent / "bandsaunter.1")

542
tests/test_acurite.py Normal file
View file

@ -0,0 +1,542 @@
"""The weather sensors: the messages, the checks, and getting them off the air.
Every format here is implemented from a published description, and every test
puts a reading in through the encoder and takes the same one out through the
decoder. That proves the framing, the parity, the checksums and the
arithmetic, which is what can be proved without owning one of each of these.
The other half of this file is about what must *not* be read: a run of noise,
a message with a bit wrong, a short model found inside a long one, and a
temperature no thermometer of this kind could report. On a band shared with
doorbells, car keys and tyre-pressure sensors, that half matters more.
"""
import numpy as np
import pytest
from bandsaunter import acurite as a
# A rate and an offset that keep these tests quick. Everything here works
# at 250 kS/s upwards; the program itself defaults to 1.024 MS/s, which is
# exercised by the round trip through the simulator at the bottom.
RATE = 400_000.0
OFFSET = 100_000.0
def heard(bits, coding="pwm", rate=RATE, offset=OFFSET, noise=0.05,
amplitude=1.0, seed=0):
"""One message, put on the air and taken off it again."""
iq = a.modulate(bits, rate, coding=coding, offset=offset, noise=noise,
amplitude=amplitude, seed=seed)
return a.readings_from(iq, rate, offset=offset)
# ---------------------------------------------------------------------------
# The tower sensor: what most people have
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("sensor,celsius,humidity,channel", [
(0x1A2B, 21.5, 48, "A"), (0x0001, -20.0, 5, "C"), (0x3FFF, 45.3, 100, "B"),
(0x2AAA, 0.0, 50, "C"), (0x0555, -39.9, 1, "A"),
])
def test_a_tower_reading_comes_back_as_it_was_sent(sensor, celsius, humidity,
channel):
got = a.decode(a.tower_frame(sensor, celsius, humidity, channel))
assert got is not None
assert got.family == "tower"
assert got.sensor == f"{sensor:04X}"
assert got.channel == channel
assert got.value("temperature") == pytest.approx(celsius, abs=0.05)
assert got.value("humidity") == humidity
def test_the_channel_switch_is_encoded_in_the_order_the_sensor_uses():
"""A is 3, B is 2 and C is 0, which is not the order anyone would guess."""
assert a.CHANNELS[3] == "A" and a.CHANNELS[2] == "B" and a.CHANNELS[0] == "C"
for channel in "ABC":
assert a.decode(a.tower_frame(1, 10.0, 50, channel)).channel == channel
def test_a_flat_battery_is_reported_and_a_good_one_is_not():
good = a.decode(a.tower_frame(0x1234, 20.0, 50, "A", battery_low=False))
flat = a.decode(a.tower_frame(0x1234, 20.0, 50, "A", battery_low=True))
assert good.battery_low is False
assert flat.battery_low is True
def test_a_tower_message_is_found_wherever_in_the_burst_it_starts():
bits = "1011" + a.tower_frame(0x0ABC, 12.3, 77, "B")
assert a.decode(bits).sensor == "0ABC"
# ---------------------------------------------------------------------------
# The 5-in-1, which says half of what it knows at a time
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("kph,degrees,counter", [
(0.0, 0.0, 0), (11.0, 90.0, 1284), (48.5, 337.5, 16383), (2.0, 180.0, 7),
])
def test_the_wind_message_comes_back_as_it_was_sent(kph, degrees, counter):
got = a.decode(a.five_in_one_wind_rain(0x777, kph, degrees, counter))
assert got is not None and got.family == "5n1"
assert got.value("wind") == pytest.approx(kph, abs=0.9)
assert got.value("wind from") == degrees
assert got.value("rain") == pytest.approx(counter * 0.254, abs=0.01)
def test_the_rain_counter_survives_as_well_as_the_millimetres():
"""It is a tipping bucket: the count is the evidence, the depth an opinion."""
got = a.decode(a.five_in_one_wind_rain(0x777, 5.0, 90.0, 1284))
rain = next(m for m in got.measures if m.name == "rain")
assert rain.raw == 1284
assert rain.unit == "mm"
def test_every_one_of_the_sixteen_wind_directions_comes_back():
seen = set()
for point in a.WIND_POINTS:
got = a.decode(a.five_in_one_wind_rain(0x777, 10.0, point, 0))
assert got.value("wind from") == point
seen.add(point)
assert len(seen) == 16
def test_the_weather_message_comes_back_as_it_was_sent():
got = a.decode(a.five_in_one_weather(0x777, 11.0, 16.8, 71))
assert got.message == 0x38
assert got.value("temperature") == pytest.approx(16.8, abs=0.1)
assert got.value("humidity") == 71
assert got.value("wind") == pytest.approx(11.0, abs=0.9)
def test_the_two_halves_of_a_5n1_are_different_messages_from_one_sensor():
wind = a.decode(a.five_in_one_wind_rain(0x777, 11.0, 90.0, 12))
weather = a.decode(a.five_in_one_weather(0x777, 11.0, 16.8, 71))
assert wind.key == weather.key == "5n1/0777"
assert wind.message != weather.message
def test_a_stopped_anemometer_reads_as_nothing_and_not_as_a_breeze():
"""The published conversion has an offset, so zero has to be a special case."""
assert a.decode(a.five_in_one_wind_rain(0x777, 0.0, 0.0, 0)).value("wind") == 0.0
# ---------------------------------------------------------------------------
# The lightning detector
# ---------------------------------------------------------------------------
def test_the_lightning_detector_reports_the_weather_and_the_storm():
got = a.decode(a.lightning_frame(0x311, 19.1, 58, strikes=7, miles=12))
assert got.family == "6045"
assert got.value("temperature") == pytest.approx(19.1, abs=0.1)
assert got.value("humidity") == 58
assert got.value("strikes") == 7
assert got.value("storm") == pytest.approx(12 * 1.609344, abs=0.1)
def test_a_storm_out_of_range_is_not_reported_as_a_distance():
"""Thirty-one means "further off than this can tell", not thirty-one miles."""
got = a.decode(a.lightning_frame(0x311, 19.1, 58, strikes=1, miles=31))
assert got.value("storm") is None
assert got.value("strikes") == 1
def test_interference_is_reported_because_a_strike_count_under_it_is_not_real():
quiet = a.decode(a.lightning_frame(0x311, 19.1, 58, interference=False))
noisy = a.decode(a.lightning_frame(0x311, 19.1, 58, interference=True))
assert "interference" not in quiet.checks
assert "interference" in noisy.checks
# ---------------------------------------------------------------------------
# The two older ones, which carry one byte of check between them
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("celsius", [-39.5, -0.1, 0.0, 12.3, 45.0])
def test_a_609_reading_comes_back_as_it_was_sent(celsius):
got = a.decode(a.frame_609(0x5C, celsius, 80), confirm=False)
assert got.family == "609" and got.sensor == "5C"
assert got.value("temperature") == pytest.approx(celsius, abs=0.05)
assert got.value("humidity") == 80
@pytest.mark.parametrize("celsius", [-39.5, -0.1, 0.0, 12.3, 45.0])
def test_a_606_reading_comes_back_as_it_was_sent(celsius):
got = a.decode(a.frame_606(0x93, celsius), confirm=False)
assert got.family == "606" and got.sensor == "93"
assert got.value("temperature") == pytest.approx(celsius, abs=0.05)
def test_the_thinly_checked_models_are_not_believed_the_first_time():
"""One byte of check is one false message in two hundred and fifty-six.
These sensors send everything three times, so asking for two of them
costs nothing and is the difference between a decoder that can be run on
a shared band and one that cannot. The copies are separate bursts, ten
milliseconds apart, so the counting happens over a whole block.
"""
for bits in (a.frame_609(0x5C, 4.2, 80), a.frame_606(0x93, -3.5)):
once = a.candidates(bits)
assert once and a.confirmed(once) == []
assert len(a.confirmed(once + a.candidates(bits))) == 1
def test_a_thin_message_that_arrives_once_off_the_air_is_not_reported():
"""The same rule, from the antenna rather than from a bit string."""
iq = a.modulate(a.frame_609(0x5C, 4.2, 80), RATE, coding="ppm",
offset=OFFSET, repeats=1, noise=0.02)
assert a.readings_from(iq, RATE, offset=OFFSET) == []
iq = a.modulate(a.frame_609(0x5C, 4.2, 80), RATE, coding="ppm",
offset=OFFSET, repeats=2, noise=0.02)
assert len(a.readings_from(iq, RATE, offset=OFFSET)) == 1
def test_the_well_checked_models_are_believed_the_first_time():
"""Twelve to fourteen bits of check does not need a second opinion."""
for bits in (a.tower_frame(1, 10.0, 50), a.five_in_one_weather(1, 5.0, 10.0, 50),
a.lightning_frame(1, 10.0, 50)):
assert a.decode(bits) is not None
# ---------------------------------------------------------------------------
# What must not be read
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("bits", [
a.tower_frame(0x1234, 21.5, 48, "A"),
a.five_in_one_wind_rain(0x777, 11.0, 90.0, 1284),
a.lightning_frame(0x311, 19.1, 58, 3, 12),
])
def test_a_message_with_a_bit_wrong_is_never_read_as_that_sensor(bits):
"""The property that matters, stated as narrowly as it is true.
A thirteen-bit check refuses about eight thousand messages in eight
thousand and one, and this tries a couple of thousand corruptions, so
"nothing ever gets through" is not something that can honestly be
asserted. What can be, and what a person watching actually depends on,
is that a corrupted message is never attributed to the sensor that sent
it: the temperature on the screen beside "back fence" is either what the
back fence said or nothing at all.
"""
truth = a.decode(bits, confirm=False)
slipped = 0
for i in range(len(bits)):
broken = list(bits)
broken[i] = "1" if broken[i] == "0" else "0"
got = a.decode("".join(broken), confirm=False)
if got is None:
continue
slipped += 1
assert (got.family, got.sensor) != (truth.family, truth.sensor), \
f"bit {i} came back as the same sensor saying something else"
assert slipped <= 2, f"{slipped} of {len(bits)} corruptions framed"
def test_parity_is_what_stops_a_run_of_zeroes_becoming_a_message():
"""A byte of zeroes has even parity, and the payload bytes must be odd."""
assert a.decode("0" * 80) is None
assert a.parity8(0x00) == 0
def test_a_reading_outside_what_the_sensor_can_report_is_refused():
"""A checksum can be satisfied by a message the hardware cannot send."""
boiling = a.tower_frame(0x1234, 130.0, 50, "A") # 130 C on a fence post
assert a.decode(boiling) is None
steam = a.tower_frame(0x1234, 20.0, 120, "A") # 120% humidity
assert a.decode(steam) is None
def test_a_short_message_is_not_read_out_of_the_middle_of_a_long_one():
"""The mistake this guards against, put in on purpose.
A five-byte message inside an eight-byte one satisfies its own eight-bit
sum about once in every two hundred and fifty-six bursts, and would show
up on the display as a sensor that is not there. What tells them apart
is that a real message runs to the end of the burst.
"""
long_one = a.five_in_one_wind_rain(0x777, 11.0, 90.0, 1284)
got = a.decode(long_one, confirm=False)
assert got is not None and got.family == "5n1"
assert [r.family for r in a.candidates(long_one)] == ["5n1"]
def test_two_messages_sharing_bits_do_not_both_survive():
for bits in (a.tower_frame(0x1A2B, 21.5, 48), a.frame_609(0x5C, 4.2, 80),
a.lightning_frame(0x311, 19.1, 58)):
found = a.candidates("0" + bits)
spans = [(r.offset, r.offset + len(r.bits)) for r in found]
for i, (start, end) in enumerate(spans):
for other_start, other_end in spans[i + 1:]:
assert not (start < other_end and other_start < end)
def test_almost_nothing_is_read_out_of_random_bits():
rng = np.random.default_rng(1)
accepted = sum(1 for _ in range(4000)
if a.decode("".join(rng.integers(0, 2, 80).astype(str)),
confirm=False) is not None)
# Five models are tried at every offset of every burst, so the bar is a
# rate rather than zero. What reaches this off the air has also had to
# be a burst of on-off keying with the right shape.
assert accepted <= 20, f"{accepted} of 4000 random runs were believed"
def test_nothing_at_all_is_read_out_of_receiver_noise():
rng = np.random.default_rng(4)
for _ in range(12):
noise = (rng.standard_normal(200_000)
+ 1j * rng.standard_normal(200_000)).astype(np.complex64)
assert a.readings_from(noise * 0.05, RATE, offset=OFFSET) == []
# ---------------------------------------------------------------------------
# Off the air: the slicer
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("bits,coding", [
(a.tower_frame(0x1A2B, 21.5, 48, "A"), "pwm"),
(a.five_in_one_wind_rain(0x777, 11.0, 90.0, 1284), "pwm"),
(a.five_in_one_weather(0x777, 11.0, 16.8, 71), "pwm"),
(a.lightning_frame(0x311, 19.1, 58, 3, 12), "pwm"),
(a.frame_609(0x5C, 4.2, 80), "ppm"),
(a.frame_606(0x93, -3.5), "ppm"),
])
def test_every_model_survives_the_whole_path_from_the_air(bits, coding):
got = heard(bits, coding)
assert len(got) == 1, f"{len(got)} readings, wanted one"
assert got[0].bits == bits
@pytest.mark.parametrize("rate,offset", [
(250_000.0, 60_000.0), (400_000.0, 100_000.0), (1_024_000.0, 250_000.0),
(2_048_000.0, 500_000.0),
])
def test_it_works_at_every_sample_rate_the_options_allow(rate, offset):
got = heard(a.tower_frame(0x1A2B, 21.5, 48, "A"), rate=rate, offset=offset)
assert [r.sensor for r in got] == ["1A2B"]
def test_the_last_bit_of_a_burst_is_recovered():
"""The gap after the final pulse is silence, not part of the bit.
A slicer that reads the bit from that gap loses the last bit of the
checksum, which loses the message -- so this is a message whose final bit
is a one, which is the case that fails if the fallback is not there.
"""
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
assert bits[-1] == "1"
assert [r.sensor for r in heard(bits)] == ["1A2B"]
def test_a_weak_sensor_at_the_end_of_the_garden_is_still_read():
got = heard(a.tower_frame(0x0C41, 20.9, 44, "B"), amplitude=0.08,
noise=0.01)
assert [r.sensor for r in got] == ["0C41"]
def test_the_receivers_own_spike_is_kept_off_the_signal():
"""Tuned straight at an on-off-keyed signal, the spike fills in the gaps.
The spike is a constant added at the tuned frequency, so this puts one
there and checks that tuning to one side and shifting back reads the
sensor while tuning straight at it does not.
"""
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
rate, offset = 1_024_000.0, 250_000.0
clean = a.modulate(bits, rate, offset=offset, amplitude=0.6, noise=0.02)
spike = np.full(clean.size, 4.0, dtype=np.complex64) # at the centre
assert [r.sensor for r in a.readings_from(clean + spike, rate,
offset=offset)] == ["1A2B"]
# The same samples read as if the receiver had been tuned at the sensor:
# the spike is now on top of it and there is nothing to slice.
assert a.readings_from(clean + spike, rate, offset=0.0) == []
def test_a_burst_of_evenly_spaced_gaps_is_not_read_as_pulse_position():
"""In that coding the gap is the bit, so gaps all one length carry none."""
even = a.Burst(marks=(400.0,) * 12, spaces=(400.0,) * 11)
assert a.bits_ppm(even) == ""
assert a.bits_pwm(even) == "0" * 12
def test_a_long_silence_ends_a_burst_and_a_short_one_does_not():
rate = 250_000.0
envelope = np.zeros(int(rate), dtype=np.float32)
per_us = rate / 1e6
def key(at_us, length_us):
lo = int(at_us * per_us)
envelope[lo:lo + int(length_us * per_us)] = 1.0
for i in range(20): # one burst, 600 us apart
key(1_000 + i * 600, 400)
for i in range(20): # another, 20 ms later
key(35_000 + i * 600, 400)
found = a.bursts(envelope, rate)
assert len(found) == 2
assert found[0].pulses == found[1].pulses == 20
def test_one_sample_of_noise_does_not_split_a_pulse_into_three():
rate = 250_000.0
envelope = np.zeros(int(rate * 0.05), dtype=np.float32)
per_us = rate / 1e6
for i in range(20):
lo = int((1_000 + i * 600) * per_us)
envelope[lo:lo + int(400 * per_us)] = 1.0
envelope[int(1_200 * per_us)] = 0.0 # a hole in the middle of one
found = a.bursts(envelope, rate)
assert len(found) == 1 and found[0].pulses == 20
def test_a_burst_too_short_to_be_a_message_is_ignored():
rate = 250_000.0
envelope = np.zeros(int(rate * 0.05), dtype=np.float32)
per_us = rate / 1e6
for i in range(3):
lo = int((1_000 + i * 600) * per_us)
envelope[lo:lo + int(400 * per_us)] = 1.0
assert a.bursts(envelope, rate) == []
def test_a_transmitter_running_ten_per_cent_fast_is_read_anyway():
"""Nothing is measured against a clock, so the drift cannot matter.
Which is just as well: these transmitters are unlocked and change
frequency and rate with the temperature, and an outdoor sensor in
January is not the one that was on the fence in July.
"""
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
marks, spaces = a.pulse_train(bits, "pwm")
per_us = RATE / 1e6
parts = []
for mark, space in zip(marks, spaces + [600.0]):
parts.append(np.full(int(mark * 1.1 * per_us), 1.0, dtype=np.float32))
parts.append(np.zeros(int(space * 1.1 * per_us), dtype=np.float32))
envelope = np.concatenate([np.zeros(int(2000 * per_us),
dtype=np.float32)] + parts)
found = a.bursts(envelope, RATE)
assert len(found) == 1
assert a.decode(a.bits_pwm(found[0])).sensor == "1A2B"
# ---------------------------------------------------------------------------
# Sensors that are not there
# ---------------------------------------------------------------------------
def test_every_invented_sensor_is_heard_within_a_few_minutes():
sky = a.SimulatedSensors(sample_rate=RATE, offset=OFFSET, seed=3)
seen = set()
for i in range(70):
for reading in a.readings_from(sky.read_samples(int(RATE)), RATE,
offset=OFFSET, when=float(i)):
seen.add(reading.key)
assert seen == {s.family + "/" + (f"{s.sensor:04X}" if s.family in
("tower", "5n1", "6045")
else f"{s.sensor:02X}")
for s in a.default_sensors()}
def test_nothing_that_is_not_there_is_heard_either():
"""Every reading over five minutes of an invented garden is a real sensor."""
sky = a.SimulatedSensors(sample_rate=RATE, offset=OFFSET, seed=3)
real = {f"{s.family}/{s.sensor:04X}" if s.family in ("tower", "5n1", "6045")
else f"{s.family}/{s.sensor:02X}" for s in a.default_sensors()}
for i in range(300):
for reading in a.readings_from(sky.read_samples(int(RATE)), RATE,
offset=OFFSET, when=float(i)):
assert reading.key in real, f"{reading.describe()} is not out there"
def test_the_5n1_alternates_its_two_messages():
sky = a.SimulatedSensors(sample_rate=RATE, offset=OFFSET, seed=3)
kinds = set()
for i in range(90):
for reading in a.readings_from(sky.read_samples(int(RATE)), RATE,
offset=OFFSET, when=float(i)):
if reading.family == "5n1":
kinds.add(reading.message)
assert kinds == {0x31, 0x38}
def test_the_same_seed_gives_the_same_garden_twice():
def run():
sky = a.SimulatedSensors(sample_rate=RATE, offset=OFFSET, seed=9)
return [(r.key, r.bits) for i in range(20)
for r in a.readings_from(sky.read_samples(int(RATE)), RATE,
offset=OFFSET, when=float(i))]
assert run() == run()
def test_a_reading_is_stamped_with_the_moment_it_arrived():
"""Not its offset in a buffer: everything downstream of this is a clock."""
sky = a.SimulatedSensors(sample_rate=RATE, offset=OFFSET, seed=3)
for i in range(30):
for reading in a.readings_from(sky.read_samples(int(RATE)), RATE,
offset=OFFSET, when=1_700_000_000.0 + i):
assert 1_700_000_000.0 + i <= reading.at < 1_700_000_001.0 + i
def test_a_message_heard_three_times_comes_back_once_and_says_so():
got = heard(a.tower_frame(0x1A2B, 21.5, 48, "A"))
assert len(got) == 1
assert got[0].copies == 3
# ---------------------------------------------------------------------------
# Saying it out loud
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value,unit,metric,imperial", [
(21.5, "C", "21.5 C", "70.7 F"),
(48.0, "%", "48%", "48%"),
(16.0, "km/h", "16.0 km/h", "9.9 mph"),
(25.4, "mm", "25.4 mm", "1.00 in"),
(16.0, "km", "16 km", "10 mi"),
])
def test_a_measurement_reads_the_same_in_either_system(value, unit, metric,
imperial):
measure = a.Measure("x", value, unit)
assert a.format_measure(measure, False) == metric
assert a.format_measure(measure, True) == imperial
def test_a_bearing_is_given_a_name_as_well_as_a_number():
assert a.format_measure(a.Measure("wind from", 90.0, "deg")) == "90° E"
assert a.compass(0.0) == "N" and a.compass(359.0) == "N"
assert a.compass(180.0) == "S" and a.compass(247.5) == "WSW"
def test_a_sensor_is_filed_under_its_family_and_its_identity():
"""Not its channel: the switch is on the outside and someone will move it."""
one = a.decode(a.tower_frame(0x1A2B, 20.0, 50, "A"))
two = a.decode(a.tower_frame(0x1A2B, 20.0, 50, "C"))
assert one.key == two.key == "tower/1A2B"
def test_a_message_that_frames_and_is_not_understood_is_still_reported():
"""Knowing something is out there transmitting is worth a line."""
odd = a._txr_bits([0xC0, 0x11, a._status(0x1B, False), 0x01, 0x02, 0x03])
got = a.decode(odd, confirm=False)
assert got is not None
assert got.measures == ()
assert got.sensor == "0011"
assert "not understood" in got.describe()
def test_a_nine_byte_sensor_that_is_not_a_lightning_detector_is_not_read_as_one():
"""The Atlas is nine bytes too, and lays its payload out differently.
Nothing but the message type tells them apart, so a nine-byte message of
any other type comes back with its identity and no weather -- rather than
a temperature read off the wrong bits, which would pass the checksum, pass
the parity, and be wrong.
"""
atlas = a._txr_bits([0xC0, 0x11, a._status(0x06, False),
0x22, 0x11, 0x33, 0x44, 0x55])
got = a.decode(atlas, confirm=False)
assert got is not None
assert got.measures == ()
assert got.sensor == "0011"
assert got.value("temperature") is None

View file

@ -42,10 +42,11 @@ def test_every_setting_explains_itself_in_plain_words(page):
def test_the_commands_and_the_keys_are_documented(page):
for word in ("scan", "bands", "config", "transcribe", "devices",
"profiles", "analyze"):
"profiles", "analyze", "weather", "readings", "sensors"):
assert f".B {word}\n" in page, f"command {word} undocumented"
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES"):
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES",
"AIRCRAFT OPTIONS", "WEATHER SENSORS", "WEATHER OPTIONS"):
assert f".SH {section}" in page
@ -147,3 +148,21 @@ def test_the_manual_lists_every_aircraft_option(page):
assert any(flag in page for flag in flags), \
f"{option.key} ({', '.join(flags)}) is not in the manual"
assert option.key in page, f"{option.key} is not named in the manual"
def test_the_manual_lists_every_weather_option(page):
"""The same, for the other section, from the other table."""
from bandsaunter import weather as wx
for option in wx.OPTIONS:
flags = tuple(option.flags) + tuple(option.off_flags)
assert any(flag in page for flag in flags), \
f"{option.key} ({', '.join(flags)}) is not in the manual"
assert option.key in page, f"{option.key} is not named in the manual"
def test_the_manual_says_where_the_sensor_names_are_kept(page):
"""They are the only thing this program stores that somebody typed."""
assert "sensors.yaml" in page
assert "weather.yaml" in page
assert "weather_" in page

View file

@ -240,8 +240,10 @@ def test_random_bits_behind_a_real_preamble_are_refused():
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("sensor,celsius,humidity,channel", [
# A, B and C are the three positions of the switch on the back of the
# sensor; there is no D, whatever the letters on a scanner display say.
(0x1234, 21.5, 48, "A"), (0x0001, -20.0, 5, "C"), (0x3FFF, 45.3, 100, "B"),
(0x2AAA, 0.0, 50, "D"), (0x0555, -39.9, 1, "A"),
(0x2AAA, 0.0, 50, "C"), (0x0555, -39.9, 1, "A"),
])
def test_a_sensor_reading_comes_back_as_it_was_sent(sensor, celsius,
humidity, channel):
@ -262,7 +264,7 @@ def test_a_flat_battery_is_reported_and_a_good_one_is_not():
def test_a_message_is_found_wherever_in_the_burst_it_starts():
bits = "10110" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
bits = "1011" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
assert decode_acurite(bits).identifier == "0ABC"

1075
tests/test_weather.py Normal file

File diff suppressed because it is too large Load diff