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
|
|
@ -24,7 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
|
|||
from .ranges import RangeError, ScanRange, parse_frequency
|
||||
|
||||
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
||||
"aircraft_menu", "first_run_setup", "TUIAbort"]
|
||||
"aircraft_menu", "weather_menu", "first_run_setup", "TUIAbort"]
|
||||
|
||||
_BACK = ("", "b", "back", "q", "quit", "x")
|
||||
|
||||
|
|
@ -646,13 +646,36 @@ A picture takes minutes rather than seconds, so 'Max record time' has to be
|
|||
long enough or what arrives is the top of one. A partial picture is kept and
|
||||
labelled partial.
|
||||
|
||||
Utility meters on 900 MHz and AcuRite weather sensors on 433 MHz are named
|
||||
rather than reported as hexadecimal, and neither is believed without its own
|
||||
checksum.
|
||||
Utility meters on 900 MHz are named rather than reported as hexadecimal, and
|
||||
are not believed without their own checksum.
|
||||
|
||||
Aircraft are a separate command: `bandsaunter adsb` parks the receiver on
|
||||
1090 MHz. ADS-B is a megabit a second and will not go through a channel
|
||||
twelve and a half kilohertz wide, which is what a scan is made of."""),
|
||||
Two things are separate commands, because neither fits through a scan.
|
||||
`bandsaunter adsb` parks the receiver on 1090 MHz: ADS-B is a megabit a
|
||||
second and will not go through a channel twelve and a half kilohertz wide.
|
||||
`bandsaunter weather` parks it on 433.92 MHz for the weather sensors, whose
|
||||
messages are bursts of a carrier switched on and off, which a scan records as
|
||||
clicks."""),
|
||||
"13": ("Weather sensors on 433 MHz", """
|
||||
The plastic box on a fence post that came with a consumer weather station
|
||||
broadcasts what it can see every sixteen seconds, in the clear, on 433.92
|
||||
MHz. `bandsaunter weather` reads it, and reads five families of them:
|
||||
|
||||
Tower 592TXR temperature, humidity
|
||||
5-in-1 06014RM wind speed, wind direction, rainfall, temperature, humidity
|
||||
Lightning 6045M temperature, humidity, strike count, how far off the storm is
|
||||
609TXC temperature, humidity
|
||||
606TX temperature
|
||||
|
||||
Battery state comes from all of them. Nothing is reported that has not
|
||||
satisfied its own checksum and, on the older two models, arrived twice.
|
||||
|
||||
The identity in the message is a number that came out of a hat in a factory,
|
||||
so press n while listening to name whichever sensor is on the screen -- the
|
||||
shed, the greenhouse -- and it keeps the name from then on. Names live in
|
||||
sensors.yaml beside the settings and can be edited by hand.
|
||||
|
||||
`bandsaunter readings --csv` turns a log into a spreadsheet: a column per
|
||||
quantity, a row per reading, the name in the second column."""),
|
||||
"11": ("Keys during a scan", """
|
||||
q stop the scan
|
||||
p pause and resume
|
||||
|
|
@ -687,11 +710,27 @@ _AIRCRAFT_INTRO = (
|
|||
)
|
||||
|
||||
|
||||
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
|
||||
def _section(section=None):
|
||||
"""The module that owns a set of options: aircraft unless told otherwise.
|
||||
|
||||
Every helper below works off ``OPTIONS``, ``OPTION_GROUPS``, ``in_group``,
|
||||
``defaults`` and ``format_option``, which both sections provide and which
|
||||
say nothing about aircraft or weather. That is what lets one set of
|
||||
menus drive both, and what will let it drive a third.
|
||||
"""
|
||||
if section is not None:
|
||||
return section
|
||||
from . import aircraft as air
|
||||
|
||||
return air
|
||||
|
||||
|
||||
def _options_table(console: Console, options, group: str,
|
||||
section=None) -> list[st.Setting]:
|
||||
air = _section(section)
|
||||
|
||||
items = air.in_group(group)
|
||||
default = air.AircraftOptions()
|
||||
default = air.defaults()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
|
|
@ -782,15 +821,15 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
|
|||
_pick_option(console, options)
|
||||
|
||||
|
||||
def _option_groups(console: Console, options) -> None:
|
||||
def _option_groups(console: Console, options, section=None) -> None:
|
||||
"""The groups, and how many of each has been changed from the default.
|
||||
|
||||
A list of six lines rather than a table of thirty-three: the options
|
||||
are all still there, and this is the way in to them.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
air = _section(section)
|
||||
|
||||
default = air.AircraftOptions()
|
||||
default = air.defaults()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title="[bold]options[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
|
|
@ -810,7 +849,7 @@ def _option_groups(console: Console, options) -> None:
|
|||
console.print(t)
|
||||
|
||||
|
||||
def _find_options(text: str) -> list:
|
||||
def _find_options(text: str, section=None) -> list:
|
||||
"""Every option this could mean, nearest match first.
|
||||
|
||||
An exact name wins outright. Typing "seconds" should reach the setting
|
||||
|
|
@ -818,7 +857,7 @@ def _find_options(text: str) -> list:
|
|||
to mention the word -- so a name that matches exactly is the answer, and
|
||||
the wider search is only what happens when nothing does.
|
||||
"""
|
||||
from . import aircraft as air
|
||||
air = _section(section)
|
||||
|
||||
wanted = text.strip().lower()
|
||||
if not wanted:
|
||||
|
|
@ -832,11 +871,12 @@ def _find_options(text: str) -> list:
|
|||
or wanted in o.help.lower()]
|
||||
|
||||
|
||||
def _option_list(console: Console, options, items, title: str) -> None:
|
||||
def _option_list(console: Console, options, items, title: str,
|
||||
section=None) -> None:
|
||||
"""One table of whichever options were asked for."""
|
||||
from . import aircraft as air
|
||||
air = _section(section)
|
||||
|
||||
default = air.AircraftOptions()
|
||||
default = air.defaults()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False,
|
||||
title=f"[bold]{title}[/bold]", title_justify="left")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
|
|
@ -852,24 +892,25 @@ def _option_list(console: Console, options, items, title: str) -> None:
|
|||
console.print(t)
|
||||
|
||||
|
||||
def _pick_option(console: Console, options) -> None:
|
||||
def _pick_option(console: Console, options, section=None) -> None:
|
||||
"""Ask which of the options just listed to change, and change it."""
|
||||
console.print("[grey62]Enter an option number to change it, "
|
||||
"[cyan]?N[/cyan] for what it does, or blank to go back."
|
||||
"[/grey62]")
|
||||
answer = _ask(console, " option").strip().lower()
|
||||
if answer and answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer)
|
||||
_edit_option(console, options, answer, section)
|
||||
|
||||
|
||||
def _option_group_menu(console: Console, options, group: str) -> None:
|
||||
def _option_group_menu(console: Console, options, group: str,
|
||||
section=None) -> None:
|
||||
"""One group of options, on a screen of its own."""
|
||||
from . import aircraft as air
|
||||
air = _section(section)
|
||||
|
||||
while True:
|
||||
_rule(console, group.lower())
|
||||
items = air.in_group(group)
|
||||
_option_list(console, options, items, group.lower())
|
||||
_option_list(console, options, items, group.lower(), air)
|
||||
console.print("\n[grey62]Enter an option number to change it, "
|
||||
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
|
||||
"to go back.[/grey62]")
|
||||
|
|
@ -877,15 +918,16 @@ def _option_group_menu(console: Console, options, group: str) -> None:
|
|||
if not answer or answer in _BACK:
|
||||
return
|
||||
if answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer)
|
||||
_edit_option(console, options, answer, air)
|
||||
else:
|
||||
console.print(" [yellow]enter a number from the list, "
|
||||
"or b[/yellow]")
|
||||
|
||||
|
||||
def _edit_option(console: Console, options, answer: str) -> None:
|
||||
def _edit_option(console: Console, options, answer: str,
|
||||
section=None) -> None:
|
||||
"""Change one option, or explain it when asked with a question mark."""
|
||||
from . import aircraft as air
|
||||
air = _section(section)
|
||||
|
||||
want_help = answer.startswith("?")
|
||||
index = int(answer.lstrip("?").strip())
|
||||
|
|
@ -894,15 +936,16 @@ def _edit_option(console: Console, options, answer: str) -> None:
|
|||
return
|
||||
option = air.OPTIONS[index - 1]
|
||||
if want_help:
|
||||
option_help(console, option, options)
|
||||
option_help(console, option, options, air)
|
||||
else:
|
||||
edit_setting(console, option, options,
|
||||
default=air.AircraftOptions(), show_help=option_help)
|
||||
edit_setting(console, option, options, default=air.defaults(),
|
||||
show_help=lambda c, o, v: option_help(c, o, v, air))
|
||||
|
||||
|
||||
def option_help(console: Console, option: st.Setting, options) -> None:
|
||||
"""The same help panel the settings menu shows, for an aircraft option."""
|
||||
from . import aircraft as air
|
||||
def option_help(console: Console, option: st.Setting, options,
|
||||
section=None) -> None:
|
||||
"""The same help panel the settings menu shows, for a section option."""
|
||||
air = _section(section)
|
||||
|
||||
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
|
||||
"", option.help.capitalize() + "."]
|
||||
|
|
@ -910,7 +953,7 @@ def option_help(console: Console, option: st.Setting, options) -> None:
|
|||
body += ["", option.detail]
|
||||
if option.guidance and option.guidance != option.detail:
|
||||
body += ["", f"[grey62]{option.guidance}[/grey62]"]
|
||||
default = air.AircraftOptions()
|
||||
default = air.defaults()
|
||||
body += ["", f"[grey62]now:[/grey62] "
|
||||
f"{air.format_option(option, getattr(options, option.key))}"
|
||||
f" [grey62]default:[/grey62] "
|
||||
|
|
@ -927,6 +970,216 @@ def option_help(console: Console, option: st.Setting, options) -> None:
|
|||
border_style="blue", padding=(0, 1)))
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weather sensors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WEATHER_INTRO = (
|
||||
"Every consumer weather station has a plastic box on a fence post which "
|
||||
"says what it can see, in the clear, on 433.92 MHz, every sixteen "
|
||||
"seconds \u2014 to the display in the kitchen and to anyone else "
|
||||
"listening. This reads the box.\n\n"
|
||||
"Temperature and humidity from all of them; wind speed, wind direction "
|
||||
"and rainfall from a 5-in-1; lightning strikes and how far off the storm "
|
||||
"is from a 6045M. Battery state from every one.\n\n"
|
||||
"What a sensor cannot tell you is which sensor it is: the identity in "
|
||||
"the message came out of a hat in a factory. So [bold]press n while "
|
||||
"listening[/bold] to give whichever is on the screen a name \u2014 the "
|
||||
"shed, the greenhouse, the back fence \u2014 and it keeps it from then "
|
||||
"on, in the display, in the log and in the spreadsheet."
|
||||
)
|
||||
|
||||
|
||||
def weather_menu(console: Console, cfg: ScanConfig) -> None:
|
||||
"""Listen to the weather sensors, and name them, without a command line."""
|
||||
from . import weather as wx
|
||||
from .sensors import SensorBook
|
||||
|
||||
options = wx.load_options()
|
||||
while True:
|
||||
_rule(console, "weather sensors (433 MHz)")
|
||||
console.print(Panel(Text.from_markup(_WEATHER_INTRO),
|
||||
border_style="blue", padding=(0, 1)))
|
||||
_option_groups(console, options, wx)
|
||||
logs = wx.logs_in(cfg.output_dir)
|
||||
book = SensorBook()
|
||||
kept = "no logs yet" if not logs else \
|
||||
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
|
||||
named = sum(1 for s in book.ordered() if s.named)
|
||||
known = (f"{len(book)} heard, {named} named" if len(book)
|
||||
else "none heard yet")
|
||||
console.print(
|
||||
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
|
||||
f" [grey62]{wx.describe(options)}[/grey62]\n"
|
||||
f" [cyan]n[/cyan] Name the sensors [grey62]{known}"
|
||||
f"[/grey62]\n"
|
||||
f" [cyan]r[/cyan] Read a log back [grey62]{kept} in "
|
||||
f"{cfg.output_dir}[/grey62]\n"
|
||||
f" [cyan]N[/cyan] open group N "
|
||||
f"[grey62]or type part of an option's name to find it[/grey62]\n"
|
||||
f" [cyan]s[/cyan] Save these as default "
|
||||
f"[grey62]kept in {wx.options_path()}[/grey62]\n"
|
||||
f" [cyan]d[/cyan] Reset them\n"
|
||||
f" [cyan]b[/cyan] Back\n")
|
||||
answer = _ask(console, " choice", "l").strip().lower()
|
||||
|
||||
if answer in _BACK:
|
||||
return
|
||||
if answer in ("l", "listen", "p"):
|
||||
_weather_listen(console, cfg, options)
|
||||
elif answer in ("n", "name", "names"):
|
||||
_sensor_names(console, book)
|
||||
elif answer in ("r", "read", "readings", "m"):
|
||||
_weather_readings(console, cfg, options, logs)
|
||||
elif answer == "s":
|
||||
try:
|
||||
where = wx.save_options(options)
|
||||
console.print(f" [green]saved to {where}[/green]")
|
||||
except OSError as exc:
|
||||
console.print(f" [red]could not save: {exc}[/red]")
|
||||
elif answer == "d":
|
||||
if _confirm(" reset every weather option"):
|
||||
options = wx.WeatherOptions()
|
||||
console.print(" [green]reset[/green]")
|
||||
elif answer.isdigit() and 1 <= int(answer) <= len(wx.OPTION_GROUPS):
|
||||
_option_group_menu(console, options,
|
||||
wx.OPTION_GROUPS[int(answer) - 1], wx)
|
||||
elif answer.lstrip("?").strip().isdigit():
|
||||
_edit_option(console, options, answer, wx)
|
||||
elif answer:
|
||||
found = _find_options(answer, wx)
|
||||
if not found:
|
||||
console.print(f" [yellow]nothing matches {answer!r} \u2014 "
|
||||
f"enter a group number, or l, n, r, s, d or b"
|
||||
f"[/yellow]")
|
||||
elif len(found) == 1:
|
||||
_edit_option(console, options,
|
||||
str(wx.OPTIONS.index(found[0]) + 1), wx)
|
||||
else:
|
||||
_option_list(console, options, found, f"matching {answer!r}",
|
||||
wx)
|
||||
_pick_option(console, options, wx)
|
||||
|
||||
|
||||
def _weather_listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||
"""Run a listening session from the menu and come back afterwards."""
|
||||
from . import weather as wx
|
||||
|
||||
errs = options.validate()
|
||||
if errs:
|
||||
for e in errs:
|
||||
console.print(f" [red]{e}[/red]")
|
||||
return
|
||||
console.print("[grey62]control-C stops listening and comes back here. "
|
||||
"Press [cyan]n[/cyan] while it runs to name a sensor."
|
||||
"[/grey62]")
|
||||
try:
|
||||
wx.listen(console, options, cfg.output_dir)
|
||||
except Exception as exc: # a menu must survive it
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
|
||||
|
||||
def _sensor_names(console: Console, book) -> None:
|
||||
"""The list of everything ever heard, and what it is called.
|
||||
|
||||
Everything ever heard rather than everything heard lately, because a
|
||||
sensor with a flat battery is exactly the one somebody wants to look up.
|
||||
"""
|
||||
while True:
|
||||
_rule(console, "sensor names")
|
||||
sensors = book.ordered()
|
||||
if not sensors:
|
||||
console.print(" [yellow]nothing heard yet. Listen first, or "
|
||||
"turn the invented garden on.[/yellow]")
|
||||
return
|
||||
t = Table(box=None, header_style="bold", pad_edge=False)
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("name", width=18)
|
||||
t.add_column("id", style="grey62", width=6)
|
||||
t.add_column("model", style="grey62", width=18)
|
||||
t.add_column("msgs", style="grey62", justify="right", width=7)
|
||||
t.add_column("last heard", style="grey62")
|
||||
t.add_column("note", style="grey62", overflow="fold")
|
||||
for i, sensor in enumerate(sensors, 1):
|
||||
t.add_row(str(i),
|
||||
Text(sensor.name, style="bold") if sensor.named
|
||||
else Text("unnamed", style="yellow"),
|
||||
sensor.sensor, sensor.model, f"{sensor.messages:,}",
|
||||
_when(sensor.last_heard) if sensor.last_heard else "",
|
||||
sensor.note)
|
||||
console.print(t)
|
||||
console.print(f"\n[grey62]Enter a number to name it, "
|
||||
f"[cyan]-N[/cyan] to forget it, or [cyan]b[/cyan] to go "
|
||||
f"back. Kept in {book.path}.[/grey62]")
|
||||
answer = _ask(console, " sensor", "b").strip().lower()
|
||||
if not answer or answer in _BACK:
|
||||
return
|
||||
forget = answer.startswith("-")
|
||||
index = answer.lstrip("-").strip()
|
||||
if not index.isdigit() or not 1 <= int(index) <= len(sensors):
|
||||
console.print(" [yellow]enter a number from the list[/yellow]")
|
||||
continue
|
||||
sensor = sensors[int(index) - 1]
|
||||
if forget:
|
||||
if _confirm(f" forget {sensor.label()}"):
|
||||
book.forget(sensor.key)
|
||||
console.print(" [green]forgotten[/green]")
|
||||
continue
|
||||
name = _ask(console, f" a name for {sensor.sensor}",
|
||||
sensor.name).strip()
|
||||
note = _ask(console, " a note (optional)", sensor.note).strip()
|
||||
try:
|
||||
book.tag(sensor.key, name, note)
|
||||
console.print(f" [green]saved[/green]")
|
||||
except OSError as exc:
|
||||
console.print(f" [red]could not save: {exc}[/red]")
|
||||
|
||||
|
||||
def _weather_readings(console: Console, cfg: ScanConfig, options,
|
||||
logs) -> None:
|
||||
"""Pick a log and read it back, newest first."""
|
||||
from . import weather as wx
|
||||
from .sensors import SensorBook
|
||||
from .weatherlog import read_logs, write_csv
|
||||
|
||||
if not logs:
|
||||
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
|
||||
"listen first, or turn the invented garden on[/yellow]")
|
||||
return
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("log")
|
||||
t.add_column("when", style="grey62")
|
||||
t.add_column("size", style="grey62", justify="right")
|
||||
for i, path in enumerate(logs[:12], 1):
|
||||
stat = path.stat()
|
||||
t.add_row(str(i), path.name, _when(stat.st_mtime),
|
||||
f"{stat.st_size / 1e6:.2f} MB")
|
||||
console.print(t)
|
||||
answer = _ask(console, " which log", "1").strip()
|
||||
if answer in _BACK or not answer.isdigit():
|
||||
return
|
||||
index = int(answer)
|
||||
if not 1 <= index <= min(12, len(logs)):
|
||||
console.print(" [yellow]no such number[/yellow]")
|
||||
return
|
||||
path = logs[index - 1]
|
||||
readings = read_logs([path])
|
||||
if not readings:
|
||||
console.print(f" [yellow]{path.name} holds no readings[/yellow]")
|
||||
return
|
||||
book = SensorBook()
|
||||
wx.report(console, wx.Garden.of(readings), book, options.imperial)
|
||||
if _confirm(" write it as a spreadsheet too"):
|
||||
try:
|
||||
where = write_csv(path.with_suffix(".csv"), readings, book,
|
||||
options.imperial)
|
||||
console.print(f" [green]wrote {where}[/green]")
|
||||
except OSError as exc:
|
||||
console.print(f" [red]could not write it: {exc}[/red]")
|
||||
|
||||
|
||||
def _listen(console: Console, cfg: ScanConfig, options) -> None:
|
||||
"""Run a listening session from the menu and come back afterwards."""
|
||||
from . import aircraft as air
|
||||
|
|
@ -1122,6 +1375,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
|||
f" [cyan]4[/cyan] Saved settings and profiles\n"
|
||||
f" [cyan]5[/cyan] Aircraft (ADS-B) "
|
||||
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
|
||||
f" [cyan]6[/cyan] Weather sensors "
|
||||
f"[grey62]listen on 433 MHz, name what is out there[/grey62]\n"
|
||||
f" [cyan]h[/cyan] Help\n"
|
||||
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
||||
f" [cyan]q[/cyan] Quit\n")
|
||||
|
|
@ -1137,6 +1392,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
|||
cfg = profiles_menu(console, cfg)
|
||||
elif choice == "5":
|
||||
aircraft_menu(console, cfg)
|
||||
elif choice == "6":
|
||||
weather_menu(console, cfg)
|
||||
elif choice in ("h", "?", "help"):
|
||||
help_screen(console)
|
||||
elif choice in ("s", "start", "go"):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue