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:
parent
f01de4117f
commit
65cc03b78d
18 changed files with 6006 additions and 123 deletions
15
INSTALL.md
15
INSTALL.md
|
|
@ -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).
|
[a virtual environment](#b-anywhere-else-a-virtual-environment).
|
||||||
|
|
||||||
**You can try the whole program before buying or plugging in a receiver.**
|
**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` runs the scanner against a synthetic band, `bandsaunter adsb
|
||||||
--simulate` flies imaginary aircraft past an imaginary receiver. Neither needs
|
--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.
|
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 scan -b 2m --simulate # a whole scan against a synthetic band
|
||||||
bandsaunter adsb --simulate --seconds 30
|
bandsaunter adsb --simulate --seconds 30
|
||||||
bandsaunter flights # draws what the last command heard
|
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:
|
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
|
`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.
|
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
|
**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
|
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
|
answers for a month; `--no-lookup` turns them off, and what the address and
|
||||||
|
|
|
||||||
309
README.md
309
README.md
|
|
@ -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
|
floor, records it, and works out what kind of signal it was. CW/Morse is
|
||||||
decoded to text.
|
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
|
Two programs: `bandsaunter` scans, and
|
||||||
[`saunterbrowse`](#browsing-what-you-recorded) reads back what it collected —
|
[`saunterbrowse`](#browsing-what-you-recorded) reads back what it collected —
|
||||||
transcripts, identifications and playback, in one screen.
|
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 -b 2m -b marine-vhf # band-plan presets
|
||||||
bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges
|
bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges
|
||||||
bandsaunter scan -b 2m --simulate # try it without hardware
|
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
|
## 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
|
2 Band plan 107 US presets
|
||||||
3 Settings record no limit, hang 6s, squelch +12 dB, keep voice, cw
|
3 Settings record no limit, hang 6s, squelch +12 dB, keep voice, cw
|
||||||
4 Saved settings and profiles
|
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
|
h Help
|
||||||
s Start scanning
|
s Start scanning
|
||||||
q Quit
|
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,
|
Settings are grouped, show their current value against the built-in default,
|
||||||
and carry their own help:
|
and carry their own help:
|
||||||
|
|
||||||
|
|
@ -1784,30 +1798,299 @@ form of every one.
|
||||||
copy between machines. API keys are deliberately **not** kept there — see
|
copy between machines. API keys are deliberately **not** kept there — see
|
||||||
[Knowing the leg for certain](#knowing-the-leg-for-certain).
|
[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
|
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
|
**Utility meters.** The Itron ERT modules fitted to electricity, gas and water
|
||||||
meters across North America broadcast their reading every thirty seconds or so
|
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
|
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
|
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 sensors.** A scan of 433 MHz that catches one of these names it the
|
||||||
weather station send temperature, humidity, battery state and a channel letter
|
same way, using the same decoders as
|
||||||
every sixteen seconds.
|
[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.
|
||||||
Neither is guessed at: a meter message carries a 16-bit BCH check and a sensor
|
The two thinly-checked models are not reported from a scan at all: they need
|
||||||
message a checksum and four parity bits, and nothing is reported that has not
|
the same message twice before they are believed, and a scan hears one.
|
||||||
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.
|
|
||||||
|
|
||||||
## Hex into words
|
## Hex into words
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ and transcribing speech.
|
||||||
# Versions are the release date and a revision within that day, so
|
# 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
|
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
|
||||||
# to two digits so versions sort as text.
|
# to two digits so versions sort as text.
|
||||||
VERSION_DATE = "2026-09-06"
|
VERSION_DATE = "2026-09-07"
|
||||||
VERSION_REVISION = 4
|
VERSION_REVISION = 1
|
||||||
|
|
||||||
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"
|
||||||
|
|
||||||
|
|
|
||||||
1313
bandsaunter/acurite.py
Normal file
1313
bandsaunter/acurite.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -28,7 +28,7 @@ from .adsb import ADSB_HZ, SAMPLE_RATE
|
||||||
from .flightlog import read_position
|
from .flightlog import read_position
|
||||||
from .settings import Setting, format_value
|
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",
|
"logs_in", "open_device", "open_log", "pump", "finish",
|
||||||
"windowed",
|
"windowed",
|
||||||
"format_option",
|
"format_option",
|
||||||
|
|
@ -519,6 +519,15 @@ OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map",
|
||||||
"Labels", "Effects")
|
"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]:
|
def in_group(group: str) -> list[Setting]:
|
||||||
return [o for o in OPTIONS if o.group == group]
|
return [o for o in OPTIONS if o.group == group]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ examples:
|
||||||
bandsaunter adsb --window live map of the aircraft
|
bandsaunter adsb --window live map of the aircraft
|
||||||
bandsaunter flights --out sky.gif animate what they did
|
bandsaunter flights --out sky.gif animate what they did
|
||||||
bandsaunter flights --theme phosphor draw it as a vector display
|
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
|
bandsaunter scan -b 2m --simulate try it without hardware
|
||||||
""")
|
""")
|
||||||
# The GNU form: the version, then who holds the copyright and what the
|
# The GNU form: the version, then who holds the copyright and what the
|
||||||
|
|
@ -288,6 +292,92 @@ examples:
|
||||||
"phosphor, amber and red")
|
"phosphor, amber and red")
|
||||||
ad.set_defaults(log_frames=True, lookup=True)
|
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 --------------------------------------------------------------
|
# -- flights --------------------------------------------------------------
|
||||||
fl = sub.add_parser("flights",
|
fl = sub.add_parser("flights",
|
||||||
help="read an ADS-B log: report, map, animation")
|
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)))
|
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):
|
def _make_device(cfg: ScanConfig, simulate: bool):
|
||||||
if simulate:
|
if simulate:
|
||||||
from .simulator import SimulatedDevice
|
from .simulator import SimulatedDevice
|
||||||
|
|
@ -605,6 +722,7 @@ def cmd_scan(args) -> int:
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
_warn_about_aircraft_bands(cfg)
|
_warn_about_aircraft_bands(cfg)
|
||||||
|
_warn_about_sensor_bands(cfg)
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
_print_plan(cfg)
|
_print_plan(cfg)
|
||||||
|
|
@ -1274,6 +1392,205 @@ def cmd_adsb(args) -> int:
|
||||||
return 0
|
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:
|
def cmd_flights(args) -> int:
|
||||||
"""Turn a log of ADS-B frames into something worth looking at.
|
"""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,
|
"config": cmd_config, "transcribe": cmd_transcribe,
|
||||||
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
||||||
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
|
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
|
||||||
"flights": cmd_flights,
|
"flights": cmd_flights, "weather": cmd_weather,
|
||||||
|
"readings": cmd_readings, "sensors": cmd_sensors,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
return handlers[args.command](args)
|
return handlers[args.command](args)
|
||||||
|
|
|
||||||
|
|
@ -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
|
**AcuRite weather sensors.** The 433.92 MHz outdoor sensors sold with every
|
||||||
consumer weather station send temperature, humidity, battery state and a
|
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
|
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
|
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 dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from . import acurite as _acurite
|
||||||
|
|
||||||
__all__ = ["decode_ism", "IsmReading", "decode_scm", "decode_acurite",
|
__all__ = ["decode_ism", "IsmReading", "decode_scm", "decode_acurite",
|
||||||
"scm_frame", "acurite_frame", "SCM_PREAMBLE", "ERT_TYPES"]
|
"scm_frame", "acurite_frame", "SCM_PREAMBLE", "ERT_TYPES"]
|
||||||
|
|
||||||
|
|
@ -142,77 +148,42 @@ def scm_frame(meter: int, consumption: int, ert_type: int = 4,
|
||||||
# AcuRite
|
# 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_BYTES = 7
|
||||||
ACURITE_CHANNELS = "ABCD"
|
ACURITE_CHANNELS = "".join(_acurite.CHANNELS)
|
||||||
|
|
||||||
|
|
||||||
def _parity(value: int) -> int:
|
def _parity(value: int) -> int:
|
||||||
value ^= value >> 4
|
"""Kept under its old name; the implementation is in :mod:`acurite`."""
|
||||||
value ^= value >> 2
|
return _acurite.parity8(value)
|
||||||
value ^= value >> 1
|
|
||||||
return value & 1
|
|
||||||
|
|
||||||
|
|
||||||
def decode_acurite(bits: str) -> IsmReading | None:
|
def decode_acurite(bits: str) -> IsmReading | None:
|
||||||
"""Read one AcuRite 592TXR / Tower outdoor sensor message.
|
"""Whichever AcuRite sensor a run of bits turns out to be, or None."""
|
||||||
|
reading = _acurite.decode(bits)
|
||||||
Seven bytes: fourteen bits of sensor number with the channel above them,
|
if reading is None:
|
||||||
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:
|
|
||||||
return None
|
return None
|
||||||
# Every offset, because what reaches here has a sync pattern of some
|
fields = [(m.name, _acurite.format_measure(m)) for m in reading.measures]
|
||||||
# length in front of it and the message does not begin on a byte
|
if reading.channel:
|
||||||
# boundary of the recovered bits. The checksum and the four parity bits
|
fields.append(("channel", reading.channel))
|
||||||
# are what make that affordable.
|
if reading.battery_low:
|
||||||
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.append(("battery", "low"))
|
fields.append(("battery", "low"))
|
||||||
return IsmReading(kind="AcuRite", device="AcuRite sensor",
|
return IsmReading(kind="AcuRite", device=reading.model,
|
||||||
identifier=f"{sensor:04X}", fields=fields,
|
identifier=reading.sensor, fields=fields,
|
||||||
bits=bits,
|
bits=reading.bits, checks=list(reading.checks))
|
||||||
checks=["checksum-8", "parity"])
|
|
||||||
|
|
||||||
|
|
||||||
def acurite_frame(sensor: int, celsius: float, humidity: int,
|
def acurite_frame(sensor: int, celsius: float, humidity: int,
|
||||||
channel: str = "A", battery_low: bool = False) -> str:
|
channel: str = "A", battery_low: bool = False) -> str:
|
||||||
"""Build one AcuRite sensor message, checksum and parity included."""
|
"""Build one 592TXR tower message, checksum and parity included."""
|
||||||
raw = int(round((celsius + 100.0) * 10.0))
|
return _acurite.tower_frame(sensor, celsius, humidity, channel,
|
||||||
data = [((ACURITE_CHANNELS.index(channel) & 3) << 6) | ((sensor >> 8) & 0x3F),
|
battery_low)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
284
bandsaunter/sensors.py
Normal file
284
bandsaunter/sensors.py
Normal 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
|
||||||
|
|
@ -24,7 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
|
||||||
from .ranges import RangeError, ScanRange, parse_frequency
|
from .ranges import RangeError, ScanRange, parse_frequency
|
||||||
|
|
||||||
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
__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")
|
_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
|
long enough or what arrives is the top of one. A partial picture is kept and
|
||||||
labelled partial.
|
labelled partial.
|
||||||
|
|
||||||
Utility meters on 900 MHz and AcuRite weather sensors on 433 MHz are named
|
Utility meters on 900 MHz are named rather than reported as hexadecimal, and
|
||||||
rather than reported as hexadecimal, and neither is believed without its own
|
are not believed without their own checksum.
|
||||||
checksum.
|
|
||||||
|
|
||||||
Aircraft are a separate command: `bandsaunter adsb` parks the receiver on
|
Two things are separate commands, because neither fits through a scan.
|
||||||
1090 MHz. ADS-B is a megabit a second and will not go through a channel
|
`bandsaunter adsb` parks the receiver on 1090 MHz: ADS-B is a megabit a
|
||||||
twelve and a half kilohertz wide, which is what a scan is made of."""),
|
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", """
|
"11": ("Keys during a scan", """
|
||||||
q stop the scan
|
q stop the scan
|
||||||
p pause and resume
|
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
|
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)
|
items = air.in_group(group)
|
||||||
default = air.AircraftOptions()
|
default = air.defaults()
|
||||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
|
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
|
||||||
t.add_column("#", style="grey62", width=3, justify="right")
|
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)
|
_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.
|
"""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
|
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.
|
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,
|
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
title="[bold]options[/bold]", title_justify="left")
|
title="[bold]options[/bold]", title_justify="left")
|
||||||
t.add_column("#", style="grey62", width=3, justify="right")
|
t.add_column("#", style="grey62", width=3, justify="right")
|
||||||
|
|
@ -810,7 +849,7 @@ def _option_groups(console: Console, options) -> None:
|
||||||
console.print(t)
|
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.
|
"""Every option this could mean, nearest match first.
|
||||||
|
|
||||||
An exact name wins outright. Typing "seconds" should reach the setting
|
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
|
to mention the word -- so a name that matches exactly is the answer, and
|
||||||
the wider search is only what happens when nothing does.
|
the wider search is only what happens when nothing does.
|
||||||
"""
|
"""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
wanted = text.strip().lower()
|
wanted = text.strip().lower()
|
||||||
if not wanted:
|
if not wanted:
|
||||||
|
|
@ -832,11 +871,12 @@ def _find_options(text: str) -> list:
|
||||||
or wanted in o.help.lower()]
|
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."""
|
"""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,
|
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||||
title=f"[bold]{title}[/bold]", title_justify="left")
|
title=f"[bold]{title}[/bold]", title_justify="left")
|
||||||
t.add_column("#", style="grey62", width=3, justify="right")
|
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)
|
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."""
|
"""Ask which of the options just listed to change, and change it."""
|
||||||
console.print("[grey62]Enter an option number to change it, "
|
console.print("[grey62]Enter an option number to change it, "
|
||||||
"[cyan]?N[/cyan] for what it does, or blank to go back."
|
"[cyan]?N[/cyan] for what it does, or blank to go back."
|
||||||
"[/grey62]")
|
"[/grey62]")
|
||||||
answer = _ask(console, " option").strip().lower()
|
answer = _ask(console, " option").strip().lower()
|
||||||
if answer and answer.lstrip("?").strip().isdigit():
|
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."""
|
"""One group of options, on a screen of its own."""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
_rule(console, group.lower())
|
_rule(console, group.lower())
|
||||||
items = air.in_group(group)
|
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, "
|
console.print("\n[grey62]Enter an option number to change it, "
|
||||||
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
|
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
|
||||||
"to go back.[/grey62]")
|
"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:
|
if not answer or answer in _BACK:
|
||||||
return
|
return
|
||||||
if answer.lstrip("?").strip().isdigit():
|
if answer.lstrip("?").strip().isdigit():
|
||||||
_edit_option(console, options, answer)
|
_edit_option(console, options, answer, air)
|
||||||
else:
|
else:
|
||||||
console.print(" [yellow]enter a number from the list, "
|
console.print(" [yellow]enter a number from the list, "
|
||||||
"or b[/yellow]")
|
"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."""
|
"""Change one option, or explain it when asked with a question mark."""
|
||||||
from . import aircraft as air
|
air = _section(section)
|
||||||
|
|
||||||
want_help = answer.startswith("?")
|
want_help = answer.startswith("?")
|
||||||
index = int(answer.lstrip("?").strip())
|
index = int(answer.lstrip("?").strip())
|
||||||
|
|
@ -894,15 +936,16 @@ def _edit_option(console: Console, options, answer: str) -> None:
|
||||||
return
|
return
|
||||||
option = air.OPTIONS[index - 1]
|
option = air.OPTIONS[index - 1]
|
||||||
if want_help:
|
if want_help:
|
||||||
option_help(console, option, options)
|
option_help(console, option, options, air)
|
||||||
else:
|
else:
|
||||||
edit_setting(console, option, options,
|
edit_setting(console, option, options, default=air.defaults(),
|
||||||
default=air.AircraftOptions(), show_help=option_help)
|
show_help=lambda c, o, v: option_help(c, o, v, air))
|
||||||
|
|
||||||
|
|
||||||
def option_help(console: Console, option: st.Setting, options) -> None:
|
def option_help(console: Console, option: st.Setting, options,
|
||||||
"""The same help panel the settings menu shows, for an aircraft option."""
|
section=None) -> None:
|
||||||
from . import aircraft as air
|
"""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]",
|
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
|
||||||
"", option.help.capitalize() + "."]
|
"", option.help.capitalize() + "."]
|
||||||
|
|
@ -910,7 +953,7 @@ def option_help(console: Console, option: st.Setting, options) -> None:
|
||||||
body += ["", option.detail]
|
body += ["", option.detail]
|
||||||
if option.guidance and option.guidance != option.detail:
|
if option.guidance and option.guidance != option.detail:
|
||||||
body += ["", f"[grey62]{option.guidance}[/grey62]"]
|
body += ["", f"[grey62]{option.guidance}[/grey62]"]
|
||||||
default = air.AircraftOptions()
|
default = air.defaults()
|
||||||
body += ["", f"[grey62]now:[/grey62] "
|
body += ["", f"[grey62]now:[/grey62] "
|
||||||
f"{air.format_option(option, getattr(options, option.key))}"
|
f"{air.format_option(option, getattr(options, option.key))}"
|
||||||
f" [grey62]default:[/grey62] "
|
f" [grey62]default:[/grey62] "
|
||||||
|
|
@ -927,6 +970,216 @@ def option_help(console: Console, option: st.Setting, options) -> None:
|
||||||
border_style="blue", padding=(0, 1)))
|
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:
|
def _listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||||
"""Run a listening session from the menu and come back afterwards."""
|
"""Run a listening session from the menu and come back afterwards."""
|
||||||
from . import aircraft as air
|
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]4[/cyan] Saved settings and profiles\n"
|
||||||
f" [cyan]5[/cyan] Aircraft (ADS-B) "
|
f" [cyan]5[/cyan] Aircraft (ADS-B) "
|
||||||
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
|
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]h[/cyan] Help\n"
|
||||||
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
||||||
f" [cyan]q[/cyan] Quit\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)
|
cfg = profiles_menu(console, cfg)
|
||||||
elif choice == "5":
|
elif choice == "5":
|
||||||
aircraft_menu(console, cfg)
|
aircraft_menu(console, cfg)
|
||||||
|
elif choice == "6":
|
||||||
|
weather_menu(console, cfg)
|
||||||
elif choice in ("h", "?", "help"):
|
elif choice in ("h", "?", "help"):
|
||||||
help_screen(console)
|
help_screen(console)
|
||||||
elif choice in ("s", "start", "go"):
|
elif choice in ("s", "start", "go"):
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ from .flightlog import in_speed, speed_label
|
||||||
from .recorder import HitRecord
|
from .recorder import HitRecord
|
||||||
from .scanner import Detection, Scanner
|
from .scanner import Detection, Scanner
|
||||||
|
|
||||||
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
|
__all__ = ["ScanDisplay", "AircraftDisplay", "WeatherDisplay", "KeyReader",
|
||||||
"print_band_table"]
|
"print_hit", "print_band_table"]
|
||||||
|
|
||||||
_SPARK = " ▁▂▃▄▅▆▇█"
|
_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)}")
|
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)
|
t.add_row(p.key, p.name, extent, p.mode, p.note)
|
||||||
console.print(t)
|
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
887
bandsaunter/weather.py
Normal 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
251
bandsaunter/weatherlog.py
Normal 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)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
.\" Generated by packaging/make-man.py -- do not edit by hand.
|
.\" 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
|
.SH NAME
|
||||||
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|
@ -85,6 +85,20 @@ animation. See
|
||||||
.B AIRCRAFT
|
.B AIRCRAFT
|
||||||
below.
|
below.
|
||||||
.TP
|
.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
|
.B analyze
|
||||||
Identify a signal in an already-recorded file, decode Morse from it, or write
|
Identify a signal in an already-recorded file, decode Morse from it, or write
|
||||||
out the picture it turns out to be.
|
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
|
.B \-\-mode usb
|
||||||
is not needed. The frequency in the filename is the carrier \[em] the
|
is not needed. The frequency in the filename is the carrier \[em] the
|
||||||
frequency to dial into a radio.
|
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
|
.SH FILES
|
||||||
.TP
|
.TP
|
||||||
.I ~/.config/bandsaunter/config.yaml
|
.I ~/.config/bandsaunter/config.yaml
|
||||||
|
|
@ -2156,6 +2453,13 @@ The settings every run starts from.
|
||||||
.I ~/.config/bandsaunter/aircraft.yaml
|
.I ~/.config/bandsaunter/aircraft.yaml
|
||||||
The aircraft options, as saved from the menus.
|
The aircraft options, as saved from the menus.
|
||||||
.TP
|
.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
|
.I ~/.config/bandsaunter/*.yaml
|
||||||
Named profiles.
|
Named profiles.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -2164,6 +2468,11 @@ Every ADS-B frame heard in one listening session, with a
|
||||||
.I .txt
|
.I .txt
|
||||||
report and any picture drawn from it beside it.
|
report and any picture drawn from it beside it.
|
||||||
.TP
|
.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/
|
.I ~/bandsaunter/
|
||||||
Where recordings, transcripts and logs are written, unless
|
Where recordings, transcripts and logs are written, unless
|
||||||
.B \-\-output
|
.B \-\-output
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,29 @@ def settings_section() -> list[str]:
|
||||||
|
|
||||||
|
|
||||||
def aircraft_section() -> list[str]:
|
def aircraft_section() -> list[str]:
|
||||||
"""Every ADS-B option, from the same table the menu and flags come from.
|
"""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.
|
|
||||||
"""
|
|
||||||
from bandsaunter import aircraft as air
|
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 = []
|
out = []
|
||||||
defaults = air.AircraftOptions()
|
defaults = air.defaults()
|
||||||
for group in air.OPTION_GROUPS:
|
for group in air.OPTION_GROUPS:
|
||||||
out.append(f'.SS {esc(group)}')
|
out.append(f'.SS {esc(group)}')
|
||||||
for o in air.in_group(group):
|
for o in air.in_group(group):
|
||||||
|
|
@ -180,6 +194,20 @@ animation. See
|
||||||
.B AIRCRAFT
|
.B AIRCRAFT
|
||||||
below.
|
below.
|
||||||
.TP
|
.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
|
.B analyze
|
||||||
Identify a signal in an already-recorded file, decode Morse from it, or write
|
Identify a signal in an already-recorded file, decode Morse from it, or write
|
||||||
out the picture it turns out to be.
|
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
|
.B \-\-mode usb
|
||||||
is not needed. The frequency in the filename is the carrier \[em] the
|
is not needed. The frequency in the filename is the carrier \[em] the
|
||||||
frequency to dial into a radio.
|
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
|
.SH FILES
|
||||||
.TP
|
.TP
|
||||||
.I ~/.config/bandsaunter/config.yaml
|
.I ~/.config/bandsaunter/config.yaml
|
||||||
|
|
@ -1254,6 +1453,13 @@ The settings every run starts from.
|
||||||
.I ~/.config/bandsaunter/aircraft.yaml
|
.I ~/.config/bandsaunter/aircraft.yaml
|
||||||
The aircraft options, as saved from the menus.
|
The aircraft options, as saved from the menus.
|
||||||
.TP
|
.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
|
.I ~/.config/bandsaunter/*.yaml
|
||||||
Named profiles.
|
Named profiles.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -1262,6 +1468,11 @@ Every ADS-B frame heard in one listening session, with a
|
||||||
.I .txt
|
.I .txt
|
||||||
report and any picture drawn from it beside it.
|
report and any picture drawn from it beside it.
|
||||||
.TP
|
.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/
|
.I ~/bandsaunter/
|
||||||
Where recordings, transcripts and logs are written, unless
|
Where recordings, transcripts and logs are written, unless
|
||||||
.B \-\-output
|
.B \-\-output
|
||||||
|
|
@ -1373,6 +1584,8 @@ def main() -> int:
|
||||||
text = "\n".join(out)
|
text = "\n".join(out)
|
||||||
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
|
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
|
||||||
"\n".join(aircraft_section()))
|
"\n".join(aircraft_section()))
|
||||||
|
text = text.replace(".WEATHER_OPTIONS_HERE",
|
||||||
|
"\n".join(weather_section()))
|
||||||
text = text.replace("\n\n", "\n") # troff dislikes blank lines
|
text = text.replace("\n\n", "\n") # troff dislikes blank lines
|
||||||
target = Path(sys.argv[1] if len(sys.argv) > 1
|
target = Path(sys.argv[1] if len(sys.argv) > 1
|
||||||
else Path(__file__).parent / "bandsaunter.1")
|
else Path(__file__).parent / "bandsaunter.1")
|
||||||
|
|
|
||||||
542
tests/test_acurite.py
Normal file
542
tests/test_acurite.py
Normal 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
|
||||||
|
|
@ -42,10 +42,11 @@ def test_every_setting_explains_itself_in_plain_words(page):
|
||||||
|
|
||||||
def test_the_commands_and_the_keys_are_documented(page):
|
def test_the_commands_and_the_keys_are_documented(page):
|
||||||
for word in ("scan", "bands", "config", "transcribe", "devices",
|
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"
|
assert f".B {word}\n" in page, f"command {word} undocumented"
|
||||||
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
|
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
|
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), \
|
assert any(flag in page for flag in flags), \
|
||||||
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
f"{option.key} ({', '.join(flags)}) is not in the manual"
|
||||||
assert option.key in page, f"{option.key} is not named 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
|
||||||
|
|
|
||||||
|
|
@ -240,8 +240,10 @@ def test_random_bits_behind_a_real_preamble_are_refused():
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.parametrize("sensor,celsius,humidity,channel", [
|
@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"),
|
(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,
|
def test_a_sensor_reading_comes_back_as_it_was_sent(sensor, celsius,
|
||||||
humidity, channel):
|
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():
|
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"
|
assert decode_acurite(bits).identifier == "0ABC"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
1075
tests/test_weather.py
Normal file
1075
tests/test_weather.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue