Compare commits
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release
|
||||
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, and push so the GitHub release builds. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
|
||||
description: Cut a MeshTerm release — pick the version, write the changelog entry from the commits since the last tag, bump `__version__`, commit, tag, push so the GitHub release builds, and hand JP a Discord announcement to post. Use when JP says to release, cut a release, ship a version, tag a version, or bump the version, and when asked what would go into the next release.
|
||||
---
|
||||
|
||||
# Cutting a MeshTerm release
|
||||
@@ -179,19 +179,48 @@ gh release view vX.Y.Z --json body --jq .body | sed -n '/## What changed/,$p' |
|
||||
```
|
||||
|
||||
If that reads "No changelog entry for X.Y.Z.", the extraction failed rather than the
|
||||
changelog being empty.
|
||||
changelog being empty. Fix the workflow, then repair the published notes in place with
|
||||
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
|
||||
|
||||
Fourteen assets is the right count — five version-stamped binaries, five version-less
|
||||
copies of them, `SHA256SUMS`, and the three licence files:
|
||||
|
||||
```
|
||||
gh release view vX.Y.Z --json assets --jq '.assets | length'
|
||||
``` Fix the workflow, then repair the published notes in place with
|
||||
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
|
||||
```
|
||||
|
||||
If a *build* fails, the tag is already public: fix forward with a new patch version rather
|
||||
than deleting and re-pushing a tag people may have fetched.
|
||||
|
||||
## 7. The Discord announcement — for JP to post
|
||||
|
||||
Once the release has landed and checked out, end by showing JP a short announcement for
|
||||
the Discord server's `#announcements` channel, in a code block he can copy as-is. **He
|
||||
posts it himself** — never post it, never hand it to a bot. Write it for the people on
|
||||
the server, in plain words (they use MeshTerm; they don't read the diff):
|
||||
|
||||
- one bolded opening line naming the version and what it is, in a few words;
|
||||
- two to five short bullets of what changed *for them*, from the changelog entry —
|
||||
the headline items only, not every line;
|
||||
- the release link, and one line on how to get it (the download page, or the README's
|
||||
install line for someone who installed that way);
|
||||
- Discord markdown only (`**bold**`, `- ` bullets, bare links); no headings, no tables,
|
||||
no emoji beyond one at the start if it fits. Well under Discord's 2000 characters —
|
||||
it should fit on a phone screen.
|
||||
|
||||
```
|
||||
**MeshTerm 0.10.1 is out**: three small command-line fixes.
|
||||
|
||||
- `records --width` now tells you when a width doesn't exist, instead of looking empty
|
||||
- The simulator's row in `devices` lost a stray `()`
|
||||
- `platform --help` and `specimen --help` read like the rest
|
||||
|
||||
Download: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
|
||||
```
|
||||
|
||||
Say who it was written for, then show it last in the reply, so it's the easiest thing to
|
||||
copy.
|
||||
|
||||
## At 0.9.0 the changelog resets
|
||||
|
||||
The first public release collapses everything below it into a single entry reading "first
|
||||
|
||||
+7
-2
@@ -1,6 +1,11 @@
|
||||
# Kept in step with meshterm/assets/pages/support.md deliberately. If the app offers a
|
||||
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
|
||||
buy_me_a_coffee: johnputer
|
||||
patreon: MeshTerm
|
||||
# PayPal is not one of GitHub's built-in platforms, so it is a custom link, written
|
||||
# without a scheme so the sidebar shows it as cleanly as it can. It stays the donate
|
||||
# button, not a paypal.me link: a payment through the button is recorded as a donation,
|
||||
# which PayPal's purchase protection does not cover, so a donor cannot claim a refund
|
||||
# for an item that never arrived.
|
||||
custom:
|
||||
- "https://www.patreon.com/cw/MeshTerm"
|
||||
- "https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
|
||||
- "paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6"
|
||||
+49
-1
@@ -9,6 +9,53 @@ one caveat SemVer makes for a leading zero: while the major version is still `0`
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.2] — 2026-09-29
|
||||
|
||||
**Harder to leave by accident, a scope picker on the dashboard and the Time Machine, and
|
||||
T-Deck Bluetooth pairing.** Esc no longer walks you into the quit dialog, ^Q asks before it
|
||||
quits, both traffic screens can narrow to one region, and a T-Deck on MeshOS or wadamesh
|
||||
pairs like any other companion.
|
||||
|
||||
### Added
|
||||
|
||||
- **The dashboard and the Time Machine can show one scope at a time.** Press `s` (F3 on the
|
||||
dashboard, F2 on the Time Machine, on the PicoCalc) to step through all traffic, unscoped
|
||||
floods, each region heard, and floods of an unknown scope. The charts, counts, and signal
|
||||
figures narrow to that scope, and the title says which one you are looking at. A direct
|
||||
packet has no scope, so it only counts under all.
|
||||
- The Channels list leads with each channel's slot number.
|
||||
- The Contacts title counts archived contacts too, and shortens itself on a narrow screen.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Esc at the main menu no longer opens the quit dialog.** Pressing Esc again and again is
|
||||
the natural way back to the menu, and the presses that landed after you got there used to
|
||||
ask whether you wanted to quit. Now Esc stops at the menu (it still clears a typed
|
||||
filter), and you leave through the Quit row or ^Q.
|
||||
- **^Q asks before it quits, from any screen.** It sits right next to ^W, which goes back to
|
||||
the menu, so one slip used to close the app, along with any capture or queued message.
|
||||
Now it opens the same dialog as the Quit row, whatever you were doing keeps running
|
||||
behind it, and Esc puts you back. Pressing ^Q a second time quits straight away. The main
|
||||
menu names it: `^Q quit?` on the hint line, or, on the PicoCalc, in the title bar, with
|
||||
**Quit?** on F3 and **Quit!** (no question) on Shift+F3.
|
||||
- **The dashboard counts each packet once.** Its traffic list used to count an advert twice,
|
||||
once as the device reported it and once as the frame it arrived in. It now counts frames
|
||||
heard on the air, under the same names and colours as the live feed.
|
||||
- The splash screen has new art, and its white letters no longer come out grey.
|
||||
- The Support page's Patreon link is shorter, and so is its QR code.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A T-Deck running MeshOS or wadamesh pairs over Bluetooth.** Those firmwares refuse the
|
||||
first message until the device is paired, and MeshTerm read the silence as "this isn't a
|
||||
MeshCore companion", so the PIN prompt never came. It now recognises the refusal and
|
||||
pairs the way it does with any other companion. On Windows, a mistyped PIN also no longer
|
||||
leaves behind a half-made pairing that made the next, correct attempt fail.
|
||||
- **Rebooting from Device config shows the reconnect dialog straight away.** A board behind
|
||||
a USB serial adapter could restart without MeshTerm noticing, and it kept sending
|
||||
commands to a board that was mid-restart. Over Bluetooth, the reboot also no longer waits
|
||||
a second or more for a reply the restarting board never sends.
|
||||
|
||||
## [0.10.1] — 2026-09-27
|
||||
|
||||
**Three small fixes to the command line.** A mistyped `records --width` now says so instead
|
||||
@@ -206,7 +253,8 @@ exact commands.
|
||||
MeshTerm is free and open source under the Apache 2.0 licence. The name and the logo are
|
||||
not covered by the licence (see `NOTICE`), so a fork is welcome under its own name.
|
||||
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.10.1...HEAD
|
||||
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.10.2...HEAD
|
||||
[0.10.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.2
|
||||
[0.10.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
|
||||
[0.10.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.0
|
||||
[0.9.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.9.0
|
||||
@@ -148,11 +148,21 @@ deliberately, one at a time, and say why in the code.
|
||||
for as long as it is true. Chat is the same shape with a different peelable thing — its
|
||||
first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite
|
||||
outcomes behind the same keystroke on the same affordance.
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are
|
||||
answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no
|
||||
screen to belong to, and the F-key lane has only three free slots per screen. They are
|
||||
stated once, on the About page. `test_navigation` walks every string literal in the
|
||||
package to keep them out of the UI. ^W arms **every frame on the stack**, not just the
|
||||
- **^W** unwinds every frame back to the main menu, **^Q** asks to quit from anywhere: it
|
||||
floats the same confirm the menu's Quit row opens, detached over whatever is up so
|
||||
nothing under it stops, and a second ^Q while it asks leaves at once
|
||||
(`TuiSession.request_quit`; ^Q sits one key from ^W). Both are answered in
|
||||
`TuiSession._dispatch`, and neither is advertised — a global verb has no screen to
|
||||
belong to, and the F-key lane has only three free slots per screen — except `^Q quit?`
|
||||
on the main menu's own hint, where Esc is inert and the reader looks for the way out.
|
||||
On picocalc that atom rides the title bar's tail (`frame._way_out`), and the menu's lane
|
||||
puts the way out on F3: `Quit?` asks, its Shift half F8 `Quit!` leaves without asking
|
||||
(`quit_now`, the one door that skips the confirm, and only on the menu).
|
||||
`test_navigation` walks every string literal in the package to keep them out of the rest
|
||||
of the UI.
|
||||
- **Esc at the main menu does nothing** but peel a typed filter: the menu is the bottom of
|
||||
the stack, so Esc-mashing up to it settles there. Leaving is the Quit row or ^Q, both
|
||||
behind the confirm. ^W arms **every frame on the stack**, not just the
|
||||
top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler
|
||||
— the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no
|
||||
navigation frame awaits, so an unwind sent only to the top died there while the hub
|
||||
@@ -265,7 +275,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
|
||||
- ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**.
|
||||
- Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only
|
||||
floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value
|
||||
picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device
|
||||
picker unchanged · no Esc atom at the main menu, which ends on `^Q quit?` instead ·
|
||||
`Esc bye` only on the device
|
||||
splash, which is the door rather than a screen — nothing has been started there to
|
||||
quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`,
|
||||
because that is what the press does then.
|
||||
|
||||
+1
-1
@@ -1556,7 +1556,7 @@ each link; the scripted face prints the link alone.
|
||||
**`--json`:**
|
||||
|
||||
```json
|
||||
{"page":"discord","title":"Join Discord","version":"0.10.1","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
{"page":"discord","title":"Join Discord","version":"0.10.2","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
|
||||
```
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|
||||
@@ -6,7 +6,7 @@ Connects to a companion over USB, Bluetooth, or TCP. For Windows, macOS, and Lin
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
__version__ = "0.10.1"
|
||||
__version__ = "0.10.2"
|
||||
__author__ = "Jean-Pierre Martineau"
|
||||
|
||||
#: The year MeshTerm was first published; the copyright span starts here.
|
||||
|
||||
@@ -30,10 +30,10 @@ https://www.paypal.com/donate/?hosted_button_id=5RATNEJP2K5U6
|
||||
|
||||
### Become a patron
|
||||
|
||||
- *https://www.patreon.com/cw/MeshTerm*
|
||||
- *https://patreon.com/MeshTerm*
|
||||
|
||||
```qr
|
||||
https://www.patreon.com/cw/MeshTerm
|
||||
https://patreon.com/MeshTerm
|
||||
```
|
||||
|
||||
## Other ways to help
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+37
-4
@@ -8,6 +8,7 @@ never open a serial port.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -151,11 +152,15 @@ class AppContext:
|
||||
#: the older handle no longer names it. Typed ``object`` so ``bleak`` stays optional.
|
||||
_ble_handle: object | None = field(default=None, init=False, repr=False)
|
||||
unpair_on_exit: bool = field(default=False, init=False, repr=False)
|
||||
#: Set by the config editor just before it sends a reboot command, so the session's
|
||||
#: disconnect watcher can label the ensuing (expected) link drop as a reboot in
|
||||
#: progress rather than a surprise unplug. Cleared by the reconnect dialog that
|
||||
#: consumes it (see :func:`meshterm.ui.menu._handle_disconnect`).
|
||||
#: Set by :meth:`announce_reboot` once a reboot command has gone out, so the reconnect
|
||||
#: dialog presents the drop as a reboot in progress rather than a surprise unplug.
|
||||
#: Cleared by :meth:`take_reboot` in the dialog that consumes it (see
|
||||
#: :func:`meshterm.ui.menu._handle_disconnect`).
|
||||
reboot_in_progress: bool = field(default=False, init=False, repr=False)
|
||||
#: Set alongside :attr:`reboot_in_progress`: wakes the session's disconnect watcher at
|
||||
#: once, without waiting for a liveness poll to *observe* the drop — which it may never
|
||||
#: do, since a board behind a USB-UART bridge keeps its port through a reboot.
|
||||
_link_going_down: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
|
||||
_resume_intent: tuple[bool, bool, bool] | None = field(default=None, init=False, repr=False)
|
||||
_events: EventHub | None = field(default=None, init=False, repr=False)
|
||||
_monitor: MonitorService | None = field(default=None, init=False, repr=False)
|
||||
@@ -282,6 +287,34 @@ class AppContext:
|
||||
except Exception: # noqa: BLE001 - a liveness-check failure must not fake a disconnect
|
||||
return True
|
||||
|
||||
def announce_reboot(self) -> None:
|
||||
"""Declare the link down because we just told the companion to reboot.
|
||||
|
||||
Called *after* the reboot command was sent (never before: the watcher it wakes
|
||||
cancels the screen that sent it, and a cancel mid-write could leave the command half
|
||||
out). From then on the device is not ours to talk to until it comes back, so the
|
||||
session's disconnect watcher (:meth:`link_going_down`) fires at once and the
|
||||
reconnect dialog takes over — rather than hoping a liveness poll catches the drop,
|
||||
which misses a quick reboot entirely where the serial port never goes away.
|
||||
"""
|
||||
self.reboot_in_progress = True
|
||||
self._link_going_down.set()
|
||||
|
||||
async def link_going_down(self) -> None:
|
||||
"""Resolve once :meth:`announce_reboot` has declared the link down."""
|
||||
await self._link_going_down.wait()
|
||||
|
||||
def take_reboot(self) -> bool:
|
||||
"""Consume a pending reboot announcement, returning whether there was one.
|
||||
|
||||
Clears both halves, so a later, genuine disconnect goes back to the generic
|
||||
wording and the next watcher waits for a real drop again.
|
||||
"""
|
||||
pending = self.reboot_in_progress
|
||||
self.reboot_in_progress = False
|
||||
self._link_going_down.clear()
|
||||
return pending
|
||||
|
||||
@property
|
||||
def ui(self) -> Ui:
|
||||
"""The active UI surface, defaulting to the plain console surface for the CLI.
|
||||
|
||||
+112
-19
@@ -135,6 +135,25 @@ _LOGIN_STRAY_LOG_CAP = 4
|
||||
#: finishes as a *clean* exit rather than an ``os._exit`` reap.
|
||||
_DISCONNECT_TIMEOUT_S = 2.0
|
||||
|
||||
#: How long :meth:`MeshcoreDevice.reboot` waits on the reboot write before treating it as
|
||||
#: gone out (seconds). Over Bluetooth every command is a write-with-response, and a board
|
||||
#: that restarts on the command may never send the link-layer acknowledgement — the write
|
||||
#: then hangs until the link supervision timeout drops it, a second or more in which the
|
||||
#: app looks like it ignored the keypress. A write that fails does so well inside this.
|
||||
_REBOOT_WRITE_GRACE_S = 0.25
|
||||
|
||||
#: Reboot writes still awaiting an acknowledgement that will likely never come, held so the
|
||||
#: loop keeps a reference until the teardown that follows the reboot ends them.
|
||||
_REBOOT_WRITES: set[asyncio.Future] = set()
|
||||
|
||||
|
||||
def _forget_reboot_write(write: asyncio.Future) -> None:
|
||||
"""Drop a finished reboot write, retrieving its outcome so none is logged as unhandled."""
|
||||
_REBOOT_WRITES.discard(write)
|
||||
if not write.cancelled() and write.exception() is not None:
|
||||
_log.debug("reboot write ended after the device went away: %s", write.exception())
|
||||
|
||||
|
||||
#: Bound on the forced transport close that follows an abandoned graceful teardown (seconds).
|
||||
#: ``_DISCONNECT_TIMEOUT_S + _FORCE_DISCONNECT_TIMEOUT_S`` stays under the exit watchdog.
|
||||
_FORCE_DISCONNECT_TIMEOUT_S = 1.5
|
||||
@@ -799,6 +818,55 @@ def _hold_disconnects_while_connecting(connection) -> _ConnectingClients: # noq
|
||||
return seen
|
||||
|
||||
|
||||
class _WriteRefusals(list):
|
||||
"""Every exception a ``BLEConnection`` write raised, oldest first."""
|
||||
|
||||
def refusal(self) -> BaseException | None:
|
||||
"""The first write the peripheral refused for want of a bond, else ``None``."""
|
||||
return next((exc for exc in self if _is_ble_auth_error(exc)), None)
|
||||
|
||||
|
||||
def _record_write_refusals(connection) -> _WriteRefusals: # noqa: ANN001
|
||||
"""Keep what a refused write said, which meshcore's ``send`` logs and then forgets.
|
||||
|
||||
Stock companion firmware guards *both* halves of its UART service with ENC+MITM, so an
|
||||
unbonded host is refused at the notify subscribe — raised out of ``connect()``, where
|
||||
:meth:`MeshCoreDevice._open_ble` turns it into a pairing. The standalone T-Deck firmwares
|
||||
(MeshOS, wadamesh) guard only the **write** characteristic: the subscribe succeeds, and
|
||||
the refusal ("Insufficient Encryption" unbonded, "Insufficient Authentication" over a
|
||||
Just Works bond) arrives on the identity handshake's first write. ``BLEConnection.send``
|
||||
catches that, logs "BLE write failed", and returns ``False``; the handshake then comes
|
||||
back empty and the device read as "not a MeshCore companion" — with no PIN pairing, no
|
||||
stale-bond repair, and no macOS Passkey wait, because nothing downstream ever saw an
|
||||
authentication error. Verified against a T-Deck on MeshOS 1.3.0.
|
||||
|
||||
So the write is wrapped to record the exception on its way through (it still propagates
|
||||
to ``send``, which behaves as before), and :meth:`MeshCoreDevice._connect_owned_ble`
|
||||
raises the recorded refusal when the handshake came back empty. Both firmwares then take
|
||||
the same pairing path.
|
||||
|
||||
Args:
|
||||
connection: The ``meshcore.BLEConnection`` about to connect.
|
||||
|
||||
Returns:
|
||||
The record of write failures, empty until one happens.
|
||||
"""
|
||||
seen = _WriteRefusals()
|
||||
write = getattr(connection, "_write_locked", None)
|
||||
if write is None: # a meshcore without the hook: the handshake reads as before
|
||||
return seen
|
||||
|
||||
async def _write_locked(data) -> None: # noqa: ANN001 - bytes-like, as meshcore sends
|
||||
try:
|
||||
await write(data)
|
||||
except Exception as exc:
|
||||
seen.append(exc)
|
||||
raise
|
||||
|
||||
connection._write_locked = _write_locked
|
||||
return seen
|
||||
|
||||
|
||||
def _held_client(connection, seen: _ConnectingClients): # noqa: ANN001, ANN202
|
||||
"""The bleak client to keep for teardown: the connection's, else the last one it lost."""
|
||||
client = getattr(connection, "client", None)
|
||||
@@ -1939,25 +2007,31 @@ class MeshCoreDevice(Device):
|
||||
"""
|
||||
from meshcore import BLEConnection
|
||||
|
||||
# Withhold the PIN on macOS. Handed one, ``BLEConnection.connect`` calls bleak's
|
||||
# ``client.pair()``, and CoreBluetooth has no pairing API at all — the macOS
|
||||
# backend raises ``NotImplementedError`` outright — whereupon the library
|
||||
# disconnects and re-raises, so supplying a *correct* PIN is what breaks the
|
||||
# connection. Apple's model is that pairing is the OS's to run, not ours: the
|
||||
# companion firmware puts its UART characteristic at ENC+MITM
|
||||
# bleak never gets the PIN, on any platform. On macOS: handed one,
|
||||
# ``BLEConnection.connect`` calls bleak's ``client.pair()``, and CoreBluetooth has
|
||||
# no pairing API at all — the macOS backend raises ``NotImplementedError`` outright
|
||||
# — whereupon the library disconnects and re-raises, so supplying a *correct* PIN is
|
||||
# what breaks the connection. Apple's model is that pairing is the OS's to run, not
|
||||
# ours: the companion firmware puts its UART characteristic at ENC+MITM
|
||||
# (``SECMODE_ENC_WITH_MITM`` on nRF52, ``ESP_GATT_PERM_*_ENC_MITM`` on ESP32), so
|
||||
# the unbonded subscribe below is answered with "Insufficient Authentication",
|
||||
# and macOS reacts by running Passkey Entry and prompting for the code itself.
|
||||
# Saying nothing here is therefore what *lets* a PIN-protected companion bond;
|
||||
# the OS keeps the bond, and later connections need no PIN. Windows and Linux do
|
||||
# expose explicit pairing, and keep it (see :meth:`_pair_ble_windows`).
|
||||
# Linux is the same story from the other side: bleak's BlueZ ``pair()`` ignores the
|
||||
# PIN and pairs through whatever system agent there is (a desktop dialog, or nothing
|
||||
# at all), so MeshTerm pairs there itself beforehand (see :meth:`_pair_ble_bluez`).
|
||||
# Handing bleak the PIN would only start a second pairing we can't answer.
|
||||
pin = None if sys.platform == "darwin" or sys.platform.startswith("linux") else self._pin
|
||||
connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin)
|
||||
# the unbonded subscribe below is answered with "Insufficient Authentication", and
|
||||
# macOS reacts by running Passkey Entry and prompting for the code itself. Saying
|
||||
# nothing here is therefore what *lets* a PIN-protected companion bond; the OS keeps
|
||||
# the bond, and later connections need no PIN (a firmware that guards only the write
|
||||
# gets there too, via :func:`_record_write_refusals`). Linux is the same story from
|
||||
# the other side: bleak's BlueZ ``pair()`` ignores the PIN and pairs through
|
||||
# whatever system agent there is (a desktop dialog, or nothing at all), so MeshTerm
|
||||
# pairs there itself beforehand (see :meth:`_pair_ble_bluez`). Handing bleak the PIN
|
||||
# would only start a second pairing we can't answer. And Windows: bleak's WinRT
|
||||
# ``pair()`` is hardcoded to CONFIRM_ONLY ("Just Works") and never sends a PIN, so
|
||||
# after a PIN pairing of our own that failed (a mistyped PIN), handing bleak the PIN
|
||||
# made it bond *without* one — and Windows kept that unauthenticated bond. The next
|
||||
# attempt, with the right PIN, then reused it and was refused, until the bond was
|
||||
# removed by hand. Pairing is ours to run on every platform that has an API for it
|
||||
# (see :meth:`_pair_ble`).
|
||||
connection = BLEConnection(address=self._address, device=self._ble_device, pin=None)
|
||||
seen = _hold_disconnects_while_connecting(connection)
|
||||
refused = _record_write_refusals(connection)
|
||||
mc = mesh_core(
|
||||
connection,
|
||||
default_timeout=self._connect_timeout,
|
||||
@@ -1979,6 +2053,11 @@ class MeshCoreDevice(Device):
|
||||
if started is None:
|
||||
await MeshCoreDevice._discard_meshcore(mc)
|
||||
await self._release_ble_client()
|
||||
# An empty handshake whose write was refused for want of a bond is a pairing
|
||||
# problem, not a stranger on the air (see :func:`_record_write_refusals`).
|
||||
refusal = refused.refusal()
|
||||
if refusal is not None:
|
||||
raise refusal
|
||||
return None
|
||||
return mc
|
||||
|
||||
@@ -3784,8 +3863,22 @@ class MeshCoreDevice(Device):
|
||||
async with self.transmitting():
|
||||
self._ok(await self._require().commands.send_advert(flood))
|
||||
|
||||
async def reboot(self) -> None: # noqa: D102 - inherited docstring
|
||||
await self._require().commands.reboot() # device reboots; no OK reply expected
|
||||
async def reboot(self) -> None:
|
||||
"""Reboot the device, returning once the command is out rather than acknowledged.
|
||||
|
||||
The device sends no reply to a reboot, and over Bluetooth it may not even acknowledge
|
||||
the write before restarting (see :data:`_REBOOT_WRITE_GRACE_S`), so waiting for the
|
||||
write to complete only delays the reconnect flow by the link supervision timeout. A
|
||||
write that fails fast still raises; one still pending after the grace is left to
|
||||
finish or die with the link, which the caller is about to tear down anyway.
|
||||
"""
|
||||
write = asyncio.ensure_future(self._require().commands.reboot())
|
||||
done, _ = await asyncio.wait({write}, timeout=_REBOOT_WRITE_GRACE_S)
|
||||
if done:
|
||||
write.result() # raise a write that failed outright
|
||||
return
|
||||
_REBOOT_WRITES.add(write)
|
||||
write.add_done_callback(_forget_reboot_write)
|
||||
|
||||
async def export_private_key(self) -> str: # noqa: D102 - inherited docstring
|
||||
event = self._ok(await self._require().commands.export_private_key())
|
||||
|
||||
@@ -1401,6 +1401,44 @@ class Repository:
|
||||
continue # a malformed stray timestamp simply isn't counted
|
||||
return counts
|
||||
|
||||
def flood_frames(self, *, since: datetime | None = None) -> list[tuple[datetime, dict]]:
|
||||
"""Every stored flood frame, oldest first, with what its scope is read from.
|
||||
|
||||
The Time Machine's scope views: a scoped frame's region is named by recomputing its
|
||||
transport code under each known region's key, which no SQL can do, so the frames
|
||||
come back as the raw mapping :meth:`~meshterm.core.region_store.RegionStore.scope_of`
|
||||
reads (route, transport code, the bytes the code was computed over) for the caller
|
||||
to resolve. Only floods: a direct frame has no scope, and a frame stored before its
|
||||
route was kept cannot say which it was.
|
||||
|
||||
Args:
|
||||
since: Only frames at or after this time, if given.
|
||||
|
||||
Returns:
|
||||
``(observed_at, raw)`` per flood frame, oldest first.
|
||||
"""
|
||||
sql = (
|
||||
"SELECT observed_at, route, transport_code, scope_body FROM observations "
|
||||
"WHERE kind = 'packet' AND upper(route) IN ('FLOOD', 'TC_FLOOD')"
|
||||
)
|
||||
params: list[Any] = []
|
||||
if since is not None:
|
||||
sql += " AND observed_at >= ?"
|
||||
params.append(since.isoformat())
|
||||
sql += " ORDER BY observed_at"
|
||||
frames: list[tuple[datetime, dict]] = []
|
||||
for row in self._conn.execute(sql, params):
|
||||
when = _as_when(row["observed_at"])
|
||||
if when is None:
|
||||
continue # a malformed stray simply isn't counted
|
||||
raw = {"route_typename": row["route"]}
|
||||
if row["transport_code"]:
|
||||
raw["transport_code"] = row["transport_code"]
|
||||
if row["scope_body"]:
|
||||
raw["scope_body"] = row["scope_body"]
|
||||
frames.append((when, raw))
|
||||
return frames
|
||||
|
||||
def self_transmissions(self, *, since: datetime | None = None) -> list[datetime]:
|
||||
"""Timestamps of everything this station put on the air, oldest first.
|
||||
|
||||
|
||||
@@ -302,6 +302,10 @@ async def apply_ops(
|
||||
await device.reboot()
|
||||
ctx.ui.ack("[warn]device rebooting[/warn]")
|
||||
report.append(_acted("reboot", rebooted=True))
|
||||
if ctx.active_transport is not None:
|
||||
# Run from the menu's command line, this hands off to the reconnect dialog
|
||||
# as the config editor's reboot does; a scripted run has no watcher to wake.
|
||||
ctx.announce_reboot()
|
||||
elif kind == "export_key":
|
||||
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
|
||||
elif kind == "import_key":
|
||||
|
||||
+35
-18
@@ -477,7 +477,12 @@ _NAME_WIDTH_MAX = 18
|
||||
#: names give a long region name more of its own. A 30-byte region name is ellipsized rather
|
||||
#: than let widen the row. On the PicoCalc's 53 the row was already cut inside the activity
|
||||
#: lane, so a scope lane there costs the MSGS count behind it; the detail page states it.
|
||||
#: The leading SLOT lane is paid for out of this budget too (see :func:`_menu_items`), for
|
||||
#: the same reason: the sparkline keeps its cells.
|
||||
_NAME_SCOPE_BUDGET = _NAME_WIDTH_MAX + 8
|
||||
#: Narrowest the right-aligned slot-index lane is drawn (two digits: MeshCore's default
|
||||
#: table holds more than ten slots); a wider table widens it to its highest index.
|
||||
_SLOT_WIDTH_MIN = 2
|
||||
#: Width of the unread-badge lane (fits ``● 999``), matching the conversation picker's.
|
||||
_BADGE_WIDTH = 5
|
||||
#: Width of the right-aligned total-messages lane.
|
||||
@@ -507,13 +512,15 @@ def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
|
||||
# --- menus -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _lanes_header(name_w: int, scope_w: int, width: int) -> str:
|
||||
def _lanes_header(slot_w: int, name_w: int, scope_w: int, width: int) -> str:
|
||||
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
|
||||
|
||||
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
|
||||
not separators) plus the glyph lane, *measured* rather than assumed: a channel's glyph
|
||||
is two cells on the desktop and one on the console, so a hard-coded indent put every
|
||||
label a column off there. ``UNREAD`` borrows its lane's trailing gap — the badge lane
|
||||
not separators). ``SLOT`` leads — the index every ``channels`` command and the chat's
|
||||
slot references take — and its lane also spans the unlabelled glyph lane after it,
|
||||
*measured* rather than assumed: a channel's glyph is two cells on the desktop and one on
|
||||
the console, so a hard-coded width put every later label a column off there. ``UNREAD``
|
||||
borrows its lane's trailing gap — the badge lane
|
||||
itself is one cell too narrow for the word — which still leaves a space before the
|
||||
message count. (No TYPE or HASH lane: the glyph already carries the openness and the
|
||||
hash lives in Show key, which buys the activity sparkline its room on a 72-column
|
||||
@@ -530,6 +537,7 @@ def _lanes_header(name_w: int, scope_w: int, width: int) -> str:
|
||||
"""
|
||||
return column_header(
|
||||
[
|
||||
Lane("SLOT", slot_w + 2 + cell_len(channel_glyph("Public", None)) + 1),
|
||||
Lane("CHANNEL", name_w + 2),
|
||||
*([Lane("SCOPE", scope_w + 2)] if scope_w else []),
|
||||
Lane("UNREAD", _BADGE_WIDTH + 2),
|
||||
@@ -541,12 +549,11 @@ def _lanes_header(name_w: int, scope_w: int, width: int) -> str:
|
||||
Lane(("ACTIVITY", "ACT")),
|
||||
],
|
||||
width,
|
||||
indent=2 + cell_len(channel_glyph("Public", None)) + 1,
|
||||
)
|
||||
|
||||
|
||||
def _slot_row(
|
||||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int, scope_w: int
|
||||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int
|
||||
) -> Callable[[], Text]:
|
||||
"""Return a list-row title *callable* the select screen re-renders on each repaint.
|
||||
|
||||
@@ -554,20 +561,20 @@ def _slot_row(
|
||||
:class:`_LiveStats`), so a message arriving while the list sits open updates the row on
|
||||
the next repaint — exactly the conversation picker's behavior.
|
||||
"""
|
||||
return lambda: _slot_text(ctx, slot, stats, name_w, scope_w)
|
||||
return lambda: _slot_text(ctx, slot, stats, slot_w, name_w, scope_w)
|
||||
|
||||
|
||||
def _slot_text(
|
||||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: int, scope_w: int
|
||||
ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, slot_w: int, name_w: int, scope_w: int
|
||||
) -> Text:
|
||||
"""Build one channel's list row as fixed-width, colour-coded lanes.
|
||||
|
||||
Alignment carries the readability — glyph, name, send scope, unread badge,
|
||||
last-message age, total messages, and the activity sparkline each sit in their own lane
|
||||
under the
|
||||
:func:`_lanes_header` line. Colour stays light and purposeful: the name is the row's
|
||||
focus in the base colour, the descriptive lanes are muted, the unread ``●`` badge is
|
||||
red with its count in warn (the conversation picker's language), and the sparkline
|
||||
Alignment carries the readability — slot index (right-aligned, muted), glyph, name,
|
||||
send scope, unread badge, last-message age, total messages, and the activity
|
||||
sparkline each sit in their own lane under the :func:`_lanes_header` line. Colour
|
||||
stays light and purposeful: the name is the row's focus in the base colour, the
|
||||
descriptive lanes are muted, the unread ``●`` badge is red with its count in warn
|
||||
(the conversation picker's language), and the sparkline
|
||||
draws in the ok green over a faint flatline. A muted channel shows a muted ``🔕`` in
|
||||
the unread lane instead of a count — muting zeros its unread and stops it accruing, so
|
||||
that lane is always free to carry the state. The scope is the region name in the
|
||||
@@ -581,6 +588,8 @@ def _slot_text(
|
||||
muted = _is_muted(ctx, slot)
|
||||
unread = ctx.chat.unread(slot.conversation.key)
|
||||
text = Text(no_wrap=True, overflow="ellipsis")
|
||||
text.append(f"{slot.idx:>{slot_w}}", style="muted")
|
||||
text.append(" ")
|
||||
text.append(f"{channel_glyph(slot.name, slot.secret)} ") # # / 🌐 / 🔒 (2 cells) + gap
|
||||
text.append(fit_cells(slot.name, name_w))
|
||||
text.append(" ")
|
||||
@@ -628,15 +637,23 @@ def _menu_items(
|
||||
"""
|
||||
items: list = []
|
||||
if slots:
|
||||
slot_w = max(_SLOT_WIDTH_MIN, *(len(str(s.idx)) for s in slots))
|
||||
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
|
||||
scopes = [cell_len(_channel_scope(ctx, s) or "") for s in slots]
|
||||
# No lane at all while no channel has a scope (see :data:`_NAME_SCOPE_BUDGET`).
|
||||
scope_w = min(_NAME_SCOPE_BUDGET - name_w, max(len("SCOPE"), *scopes)) if any(scopes) else 0
|
||||
# No lane at all while no channel has a scope (see :data:`_NAME_SCOPE_BUDGET`); the
|
||||
# slot lane and its gap come out of the same budget, so the sparkline keeps its room.
|
||||
# A drawn scope lane is never narrower than its label — a long name gives up cells
|
||||
# before the lane would crowd ``SCOPE`` against ``UNREAD``.
|
||||
budget = _NAME_SCOPE_BUDGET - (slot_w + 2)
|
||||
scope_w = max(len("SCOPE"), min(budget - name_w, max(*scopes))) if any(scopes) else 0
|
||||
name_w = min(name_w, budget - scope_w)
|
||||
# The lane names are this block's only landmark (its section carries no ── heading ──),
|
||||
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
|
||||
items.append(Separator(lambda w: _lanes_header(name_w, scope_w, w), heading=True))
|
||||
items.append(Separator(lambda w: _lanes_header(slot_w, name_w, scope_w, w), heading=True))
|
||||
for slot in slots:
|
||||
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w, scope_w), value=slot.idx))
|
||||
items.append(
|
||||
Choice(title=_slot_row(ctx, slot, stats, slot_w, name_w, scope_w), value=slot.idx)
|
||||
)
|
||||
else:
|
||||
items.append(Separator(" no channels yet — add one below"))
|
||||
|
||||
|
||||
@@ -97,13 +97,11 @@ _OTHER = "__other__"
|
||||
#: The setting keys the map pick sets together (each also has its own row, for a typed value).
|
||||
_COORD_KEYS = ("adv_lat", "adv_lon")
|
||||
|
||||
#: How long the reboot flow waits to *observe* the link actually dropping before handing
|
||||
#: off to the session's reconnect dialog (seconds). A companion normally vanishes from the
|
||||
#: bus well within this; on timeout we hand off anyway.
|
||||
_REBOOT_DROP_TIMEOUT_S = 10.0
|
||||
|
||||
#: Poll cadence while waiting for the rebooting companion's link to drop (seconds).
|
||||
_REBOOT_DROP_POLL_S = 0.25
|
||||
#: How long the reboot flow holds the actions screen after announcing the reboot, waiting
|
||||
#: for the session's disconnect watcher to cancel it and raise the reconnect dialog
|
||||
#: (seconds). The watcher wakes on the announcement itself, so this only runs out where
|
||||
#: no watcher is running.
|
||||
_REBOOT_HANDOFF_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
async def cached_snapshot(ctx: AppContext, device: Device) -> dict:
|
||||
@@ -1182,10 +1180,11 @@ async def _show_contact_card(ctx: AppContext, snapshot: dict) -> None:
|
||||
async def _reboot(ctx: AppContext, device: Device, snapshot: dict) -> bool:
|
||||
"""Confirm and reboot the device, handing off to the session's reconnect dialog.
|
||||
|
||||
After the command is sent, we wait to actually observe the link dropping — flagging
|
||||
:attr:`~meshterm.context.AppContext.reboot_in_progress` so the session-wide
|
||||
disconnect watcher labels the ensuing dialog as a reboot, waits for the companion to
|
||||
come back, and reconnects — exactly the unplugged-device flow.
|
||||
Once the command is sent the link is declared down
|
||||
(:meth:`~meshterm.context.AppContext.announce_reboot`) rather than waited on, so the
|
||||
session-wide disconnect watcher raises its dialog at once, labelled as a reboot, waits
|
||||
for the companion to come back, and reconnects — exactly the unplugged-device flow,
|
||||
and nothing else is sent to the board while it restarts.
|
||||
|
||||
Returns:
|
||||
``True`` if the reboot was sent and the actions screen should close; ``False``
|
||||
@@ -1208,22 +1207,15 @@ async def _reboot(ctx: AppContext, device: Device, snapshot: dict) -> bool:
|
||||
await ctx.ui.present(title="Reboot")
|
||||
return False
|
||||
|
||||
# Flag the drop as expected *before* sending, so however quickly the watcher fires,
|
||||
# the reconnect dialog already knows to present it as a reboot.
|
||||
ctx.reboot_in_progress = True
|
||||
try:
|
||||
await device.reboot()
|
||||
except Exception:
|
||||
ctx.reboot_in_progress = False
|
||||
raise
|
||||
# Hold here until the link is actually observed down (or a generous timeout), so the
|
||||
# actions screen doesn't flash back to the menu for the second or two before the
|
||||
# watcher notices. The watcher may cancel us mid-wait when it fires — that's the handoff.
|
||||
deadline = asyncio.get_running_loop().time() + _REBOOT_DROP_TIMEOUT_S
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
if not await ctx.link_alive():
|
||||
break
|
||||
await asyncio.sleep(_REBOOT_DROP_POLL_S)
|
||||
await device.reboot()
|
||||
# The command is out, so the link is as good as gone: declare it, rather than wait for
|
||||
# a liveness poll to notice — a quick reboot behind a port that never disappears is
|
||||
# never noticed at all, and the next command would go to a board mid-restart.
|
||||
ctx.announce_reboot()
|
||||
# The session's watcher now cancels us and raises the reconnect dialog; hold here until
|
||||
# it does, so the actions screen doesn't flash back to the menu in between. The timeout
|
||||
# only matters where no watcher is running to take the handoff.
|
||||
await asyncio.sleep(_REBOOT_HANDOFF_TIMEOUT_S)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -98,9 +98,10 @@ class ContactsScreen(ContactListScreen):
|
||||
span :data:`~meshterm.ui.contactlist.SORT_COLUMNS` so the hash sort is
|
||||
reachable.
|
||||
archived: How many contacts are archived off this device. Non-zero adds the
|
||||
``View archived contacts`` row and shows the count on it, so the tally is
|
||||
visible without opening the list; zero draws no row, since a screen should
|
||||
not offer a way into an empty list.
|
||||
``View archived contacts`` row and shows the count on it and in the title
|
||||
(``Contacts · 198 known + 123 archived``), so the tally is visible without
|
||||
opening the list; zero draws no row and leaves the title at ``… known``,
|
||||
since a screen should not offer a way into an empty list.
|
||||
locked: The full keys (lowercase hex) of the contacts locked against archiving;
|
||||
each of those rows closes its name lane on a padlock.
|
||||
"""
|
||||
@@ -132,13 +133,22 @@ class ContactsScreen(ContactListScreen):
|
||||
label = marked_label("📂", "View archived contacts", "", lane=lane)
|
||||
label.append(f" · {archived}", style="muted")
|
||||
tail.append(Choice(title=label, value=_ARCHIVED))
|
||||
# The title counts both halves of what this device's contacts amount to: those on it
|
||||
# and those a sweep moved off it. Where the frame is too narrow for the words, the
|
||||
# figures stay and the words go (``Contacts · 198 + 123``).
|
||||
known = len(contacts)
|
||||
title = f"Contacts · {known} known"
|
||||
if archived:
|
||||
title += f" + {archived} archived"
|
||||
super().__init__(
|
||||
f"Contacts · {len(contacts)} known",
|
||||
title,
|
||||
rows=rows,
|
||||
prefix_bytes=prefix_bytes,
|
||||
sort=sort,
|
||||
tail=tail,
|
||||
)
|
||||
if archived:
|
||||
self.short_title = f"Contacts · {known} + {archived}"
|
||||
|
||||
def _contact_rows_for(self, locked: frozenset[str]) -> list[ContactRow]:
|
||||
"""Our own node, then every contact, each marked locked or not by ``locked``."""
|
||||
|
||||
+177
-59
@@ -12,15 +12,21 @@ stacks three reads of the mesh, coarsest first:
|
||||
drawn from the same :meth:`~meshterm.services.monitor_service.MonitorService`
|
||||
buckets. A pulse line beneath it reads the rate, who's been heard, and the busiest
|
||||
node of the window.
|
||||
* **Traffic** — the session's tallies by packet class, each with its icon and a
|
||||
proportional bar, from the monitor's kind counters — the raw ``packet`` bucket
|
||||
broken out by payload class (channel text, trace, path, …), so every known frame
|
||||
class shows once heard.
|
||||
* **Traffic** — the frames heard on the air, tallied by class (chan text, trace,
|
||||
path, …) under the names and icons the live feed gives them, each with a
|
||||
proportional bar. Every frame counts once: what the device *reported* about a frame
|
||||
(its advert event, a decoded message) is not counted a second time beside it.
|
||||
* **RF health** — the trailing window's reception quality: median SNR (on the trace
|
||||
tool's quality bar) and RSSI from stored observations, plus the radio's own live
|
||||
numbers — noise floor, last RSSI/SNR, airtime, battery — polled from the device the
|
||||
way Device info reads them.
|
||||
|
||||
**Scope** narrows the whole screen to one region's floods: ``s`` (the PicoCalc's F3 chip)
|
||||
cycles every view — all, unscoped, each region heard, unknown scopes — as the Time
|
||||
Machine's ``w`` cycles its windows. A narrowed view reads the screen's own trailing
|
||||
window (stored + live, 2 h), since a region is named per frame by a cryptographic check
|
||||
the database cannot run; the radio's own numbers stay whole, being the device's.
|
||||
|
||||
The per-packet stream that used to close this screen is now its own tool — the Live
|
||||
feed (see :mod:`meshterm.ui.livefeed_screen`), right under the dashboard in the menu —
|
||||
so this screen is pure overview: it scrolls as one body under the arrow/page keys.
|
||||
@@ -33,9 +39,11 @@ tears them all down when the screen resolves. Esc backs out directly.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import Counter, deque
|
||||
from collections.abc import Callable
|
||||
from statistics import median
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.table import Table
|
||||
@@ -43,16 +51,13 @@ from rich.text import Text
|
||||
|
||||
from ..core.events import MeshEvent
|
||||
from ..core.models import NODE_TYPE_REPEATER, Observation, utcnow
|
||||
from ..core.regions import Scope
|
||||
from ..persistence.repository import OBSERVATION_WINDOW
|
||||
from ..services.monitor_service import ACTIVITY_BUCKET_S, ACTIVITY_BUCKETS
|
||||
from .braillechart import axis_chart, axis_chrome, axis_label_w, meter, timeline_rows
|
||||
from .packet_viewer import (
|
||||
_PAYLOAD_GLOSS,
|
||||
DEFAULT_ICON,
|
||||
KIND_ICONS,
|
||||
KIND_STYLES,
|
||||
PAYLOAD_ICONS,
|
||||
)
|
||||
from .theme import glyph, snr_style
|
||||
from .packet_viewer import KIND_STYLES, payload_marks
|
||||
from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step
|
||||
from .theme import snr_style
|
||||
from .trace_screen import snr_bar
|
||||
from .tui.render import render_lines
|
||||
from .tui.screen import Screen
|
||||
@@ -69,13 +74,10 @@ _STATS_POLL_S = 10.0
|
||||
#: How many braille rows tall the activity chart draws (each row is four dot rows).
|
||||
_CHART_ROWS = 3
|
||||
|
||||
#: The order the traffic panel lists its buckets in (heard ones not listed sort last,
|
||||
#: alphabetically): the decoded families first, then the raw ``packet:<TYPENAME>``
|
||||
#: classes expanding in the old lone-``packet`` slot — chat-ish frames before
|
||||
#: protocol-ish ones — with the class-less ``packet`` closing the raw block.
|
||||
#: The order the traffic panel lists its classes in (heard ones not listed sort last,
|
||||
#: alphabetically): chat-ish frames before protocol-ish ones, the class-less ``packet``
|
||||
#: closing the list.
|
||||
_TRAFFIC_ORDER = (
|
||||
"advert",
|
||||
"telemetry",
|
||||
"packet:GRP_TXT",
|
||||
"packet:GRP_DATA",
|
||||
"packet:TEXT_MSG",
|
||||
@@ -89,31 +91,27 @@ _TRAFFIC_ORDER = (
|
||||
"packet:MULTIPART",
|
||||
"packet:CONTROL",
|
||||
"packet",
|
||||
"message",
|
||||
"ack",
|
||||
)
|
||||
|
||||
#: Raw payload glosses that would read identically to a decoded family's row, retold
|
||||
#: so two meters never share a label (an overheard advert frame vs. the advert event).
|
||||
_RAW_GLOSS_CLASH = {"advert": "raw advert", "ack": "raw ack"}
|
||||
|
||||
def _is_frame_bucket(bucket: str) -> bool:
|
||||
"""Whether a tally bucket counts frames heard on the air (``packet[:<TYPENAME>]``).
|
||||
|
||||
The monitor also tallies what the *device* reported — ``advert``, ``telemetry``,
|
||||
``message``, ``ack`` — but each of those is a frame already counted under its own
|
||||
class (the advert event *and* its RX-logged ``ADVERT`` frame), so the traffic panel
|
||||
counts frames only: every packet once, under the name the live feed gives it.
|
||||
"""
|
||||
return bucket == "packet" or bucket.startswith("packet:")
|
||||
|
||||
|
||||
def _traffic_chrome(bucket: str) -> tuple[str, str, str]:
|
||||
"""One traffic bucket's ``(icon, label, meter style)``.
|
||||
def _traffic_chrome(bucket: str) -> tuple[str, str]:
|
||||
"""One traffic bucket's ``(icon, label)`` — the live feed's, from the same function.
|
||||
|
||||
A decoded family keeps its kind icon, name, and colour; a ``packet:<TYPENAME>``
|
||||
bucket takes the payload class's icon and gloss over the calm muted ``packet``
|
||||
meter — colour keeps marking the decoded families, raw overheard stays quiet.
|
||||
Icons are mapped to single glyphs on PICOCALC via :func:`~meshterm.ui.theme.glyph`.
|
||||
"""
|
||||
if bucket.startswith("packet:"):
|
||||
typename = bucket.split(":", 1)[1]
|
||||
gloss = _PAYLOAD_GLOSS.get(typename, typename.lower())
|
||||
label = _RAW_GLOSS_CLASH.get(gloss, gloss)
|
||||
emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON)
|
||||
return glyph(emoji), label, KIND_STYLES["packet"]
|
||||
emoji = KIND_ICONS.get(bucket, DEFAULT_ICON)
|
||||
return glyph(emoji), bucket, KIND_STYLES.get(bucket, "brand")
|
||||
typename = bucket.split(":", 1)[1] if ":" in bucket else None
|
||||
return payload_marks(typename)
|
||||
|
||||
|
||||
#: How many character cells a traffic lane's meter spans (48 half-step levels).
|
||||
@@ -124,6 +122,26 @@ _TRAFFIC_METER_CELLS = 24
|
||||
_GRID_LABEL_W = 9
|
||||
|
||||
|
||||
#: The screen's title, before any scope atom.
|
||||
_TITLE = "Dashboard — mesh overview"
|
||||
|
||||
|
||||
class _ScopeDigest(NamedTuple):
|
||||
"""One scope view's numbers over the window, in the shapes the sections draw.
|
||||
|
||||
Attributes:
|
||||
histogram: Frames per minute, newest first, the monitor's bucket shape.
|
||||
counts: Frames by traffic bucket (``packet:<TYPENAME>``).
|
||||
snrs: Every SNR reading among them.
|
||||
rssis: Every RSSI reading among them.
|
||||
"""
|
||||
|
||||
histogram: tuple[int, ...]
|
||||
counts: dict[str, int]
|
||||
snrs: list[float]
|
||||
rssis: list[float]
|
||||
|
||||
|
||||
def _span_label(minutes: int) -> str:
|
||||
"""A compact duration — ``45 min`` under two hours, else ``2.5 h`` / ``3 h``."""
|
||||
if minutes < 120:
|
||||
@@ -147,21 +165,28 @@ class DashboardScreen(Screen):
|
||||
:attr:`~meshterm.ui.tui.screen.Screen.content_overflows`); otherwise Esc is the only
|
||||
key that acts.
|
||||
"""
|
||||
if self.content_overflows:
|
||||
return "↑↓ PgUp/PgDn scroll · Esc back"
|
||||
return "Esc back"
|
||||
atoms = ["↑↓ PgUp/PgDn scroll"] if self.content_overflows else []
|
||||
if len(self._ring()) > 1:
|
||||
atoms.append("s scope")
|
||||
return " · ".join([*atoms, "Esc back"])
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
"""The shared lane, dimmed on the same gate the hint above uses.
|
||||
"""The shared lane, dimmed on the same gate the hint above uses, plus the scope cycle.
|
||||
|
||||
The PicoCalc draws no hint line at all — the lane *is* the footer — so the rule
|
||||
the hint follows has to hold there too: an overview that fits whole has nothing
|
||||
for Top/End or the paging pair to move.
|
||||
for Top/End or the paging pair to move. The scope cycle takes F3, as the Time
|
||||
Machine's window cycle does, its chip naming the view a press goes *to* (the title
|
||||
already says where you are); with no flood heard to narrow to, the slot stays empty.
|
||||
"""
|
||||
from .tui.fkeys import default_lane
|
||||
from .tui.fkeys import FPair, default_lane
|
||||
|
||||
return default_lane(nav=self.content_overflows)
|
||||
lane = list(default_lane(nav=self.content_overflows))
|
||||
views = self._ring()
|
||||
if len(views) > 1:
|
||||
lane[2] = FPair(scope_chip(step(views, self._scope)), "scope")
|
||||
return lane
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -172,6 +197,7 @@ class DashboardScreen(Screen):
|
||||
activity: Any,
|
||||
activity_flags: Any,
|
||||
kind_counts: Any,
|
||||
scope_of: Callable[[dict | None], Scope | None] | None = None,
|
||||
) -> None:
|
||||
"""Create the dashboard over its data feeds.
|
||||
|
||||
@@ -183,9 +209,15 @@ class DashboardScreen(Screen):
|
||||
activity_flags: Zero-arg callable returning the histogram's per-bucket
|
||||
this-session flags (seeded history draws grey, live traffic green).
|
||||
kind_counts: Zero-arg callable returning the monitor's kind tallies.
|
||||
scope_of: Names a frame's scope from its raw payload
|
||||
(:meth:`~meshterm.core.region_store.RegionStore.scope_of`); ``None`` offers
|
||||
no scope views.
|
||||
"""
|
||||
super().__init__()
|
||||
self.title = "Dashboard — mesh overview"
|
||||
self._scope_of = scope_of
|
||||
#: The scope view on screen; ``None`` is every packet heard.
|
||||
self._scope: ScopeKey | None = None
|
||||
self._set_title()
|
||||
self._session = session
|
||||
self._resolve = resolve
|
||||
self._activity = activity
|
||||
@@ -209,6 +241,10 @@ class DashboardScreen(Screen):
|
||||
self._win_counts: Counter = Counter()
|
||||
self._win_snrs: list[float] = []
|
||||
self._win_rssis: list[float] = []
|
||||
#: The window's frames that have a scope, each with the view it counts in.
|
||||
self._win_scoped: list[tuple[ScopeKey, Observation]] = []
|
||||
#: The narrowed view's numbers and what they were computed for — see :meth:`_scoped`.
|
||||
self._scoped_memo: tuple[tuple, _ScopeDigest] | None = None
|
||||
#: The last activity chart and the inputs it was drawn from — see
|
||||
#: :meth:`_activity_section`.
|
||||
self._chart_memo: tuple[tuple, list[RenderableType]] | None = None
|
||||
@@ -247,9 +283,63 @@ class DashboardScreen(Screen):
|
||||
self.scroll_to_top()
|
||||
elif action in ("end", "ctrl_end"):
|
||||
self.scroll_to_bottom()
|
||||
elif action == "scope" or (action == "text" and data.lower() == "s"):
|
||||
self._cycle_scope()
|
||||
elif action == "escape":
|
||||
self.resolve(None)
|
||||
|
||||
# --- scope -----------------------------------------------------------------------
|
||||
|
||||
def _ring(self) -> list[ScopeKey | None]:
|
||||
"""Every view on offer: all, then each scope heard in the window."""
|
||||
self._digest_window()
|
||||
return ring((key for key, _ in self._win_scoped), self._scope)
|
||||
|
||||
def _cycle_scope(self) -> None:
|
||||
"""Step to the next view (see :func:`~meshterm.ui.scopering.step`)."""
|
||||
views = self._ring()
|
||||
if len(views) < 2:
|
||||
return
|
||||
self._scope = step(views, self._scope)
|
||||
self._set_title()
|
||||
self.scroll_to_top()
|
||||
self._session.invalidate()
|
||||
|
||||
def _set_title(self) -> None:
|
||||
"""Title the screen for its view; a narrowed one says which, tightened when narrow."""
|
||||
if self._scope is None:
|
||||
self.title, self.short_title = _TITLE, ""
|
||||
return
|
||||
atom = scope_atom(self._scope)
|
||||
self.title = f"{_TITLE} · {atom}"
|
||||
self.short_title = f"Dashboard · {atom}"
|
||||
|
||||
def _scoped(self) -> _ScopeDigest:
|
||||
"""The narrowed view's numbers, recomputed only when the window, view or minute moves."""
|
||||
minute = int(time.time() // ACTIVITY_BUCKET_S)
|
||||
memo_key = (self._window_rev, self._scope, minute)
|
||||
if self._scoped_memo is not None and self._scoped_memo[0] == memo_key:
|
||||
return self._scoped_memo[1]
|
||||
histogram = [0] * ACTIVITY_BUCKETS
|
||||
counts: Counter = Counter()
|
||||
snrs: list[float] = []
|
||||
rssis: list[float] = []
|
||||
for key, o in self._win_scoped:
|
||||
if key != self._scope:
|
||||
continue
|
||||
age = minute - int(o.observed_at.timestamp() // ACTIVITY_BUCKET_S)
|
||||
if 0 <= age < ACTIVITY_BUCKETS:
|
||||
histogram[age] += 1
|
||||
typename = (o.raw or {}).get("payload_typename")
|
||||
counts[f"packet:{typename}" if typename else "packet"] += 1
|
||||
if o.snr is not None:
|
||||
snrs.append(o.snr)
|
||||
if o.rssi is not None:
|
||||
rssis.append(o.rssi)
|
||||
digest = _ScopeDigest(tuple(histogram), dict(counts), snrs, rssis)
|
||||
self._scoped_memo = (memo_key, digest)
|
||||
return digest
|
||||
|
||||
# --- rendering ---------------------------------------------------------------------
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
@@ -290,10 +380,15 @@ class DashboardScreen(Screen):
|
||||
counts: Counter = Counter()
|
||||
snrs: list[float] = []
|
||||
rssis: list[float] = []
|
||||
scoped: list[tuple[ScopeKey, Observation]] = []
|
||||
for o in self._window:
|
||||
if o.node and o.node_type == NODE_TYPE_REPEATER:
|
||||
repeaters.add(o.node)
|
||||
if o.kind == "packet":
|
||||
# The region store memoizes by frame, so a frame is checked once per visit.
|
||||
key = scope_key(self._scope_of(o.raw)) if self._scope_of else None
|
||||
if key is not None:
|
||||
scoped.append((key, o))
|
||||
continue
|
||||
if o.node:
|
||||
nodes.add(o.node)
|
||||
@@ -307,6 +402,7 @@ class DashboardScreen(Screen):
|
||||
self._win_counts = counts
|
||||
self._win_snrs = snrs
|
||||
self._win_rssis = rssis
|
||||
self._win_scoped = scoped
|
||||
|
||||
# -- activity --
|
||||
|
||||
@@ -320,7 +416,8 @@ class DashboardScreen(Screen):
|
||||
terminal simply shows more history. Time runs oldest→now left to right,
|
||||
every MeshTerm timeline's direction.
|
||||
"""
|
||||
histogram = list(self._activity()) # newest first, one count per minute
|
||||
# Newest first, one count per minute.
|
||||
histogram = list(self._scoped().histogram if self._scope else self._activity())
|
||||
# Size the label lane from the whole histogram's peak (not just the visible
|
||||
# slice) so the gutters never shift as a burst scrolls out of view.
|
||||
label_w = axis_label_w(max(histogram, default=0), _CHART_ROWS)
|
||||
@@ -330,7 +427,14 @@ class DashboardScreen(Screen):
|
||||
peak = max(shown)
|
||||
|
||||
heading = Text("Activity", style="accent")
|
||||
heading.append(" · every packet heard · one minute per dot column", style="muted")
|
||||
# A narrowed view spends the aside on what it counts, which the reader has to be
|
||||
# told; the minute per column is on the axis beneath either way.
|
||||
what = (
|
||||
"floods in this scope · last 2 h"
|
||||
if self._scope
|
||||
else "every packet heard · one minute per dot column"
|
||||
)
|
||||
heading.append(f" · {what}", style="muted")
|
||||
# Buckets seeded from a previous session's stored history draw grey; only
|
||||
# what this session heard itself pulses green.
|
||||
flags = (tuple(self._activity_flags()) + (True,) * minutes)[:minutes]
|
||||
@@ -402,6 +506,8 @@ class DashboardScreen(Screen):
|
||||
line = Text()
|
||||
line.append(f"{recent:.1f} pkt/min", style="brand")
|
||||
line.append(f" (15 m) · {overall:.1f} ({span})", style="muted")
|
||||
if self._scope:
|
||||
return line # who was heard is read off adverts, which carry no scope
|
||||
line.append(" · ", style="muted")
|
||||
line.append(str(len(nodes)))
|
||||
suffix = f" node{'s' if len(nodes) != 1 else ''}"
|
||||
@@ -417,7 +523,7 @@ class DashboardScreen(Screen):
|
||||
if len(line.plain) + _GRID_LABEL_W > width:
|
||||
line = compose(heard=False)
|
||||
rows = [("pulse", line)]
|
||||
busiest = self._busiest()
|
||||
busiest = None if self._scope else self._busiest()
|
||||
if busiest is not None:
|
||||
name, count = busiest
|
||||
value = Text(name, style="brand")
|
||||
@@ -439,26 +545,34 @@ class DashboardScreen(Screen):
|
||||
# -- traffic --
|
||||
|
||||
def _traffic_section(self) -> list[RenderableType]:
|
||||
"""Tallies by packet class, each with an icon and a proportional braille meter.
|
||||
"""Frames heard on the air, by class, each with an icon and a proportional meter.
|
||||
|
||||
Every known class shows once heard — the raw ``packet`` bucket is broken out
|
||||
by payload class (channel text, trace, path, …) instead of lumping every
|
||||
overheard frame together; classes never heard stay hidden.
|
||||
Every class shows once heard, named as the live feed names it; classes never
|
||||
heard stay hidden. Every bar is the one colour the feed draws a frame's class in
|
||||
(``packet``'s): the icon and the label say which class a row is, so colour has
|
||||
nothing left to say here.
|
||||
"""
|
||||
heading = Text("Traffic", style="accent")
|
||||
heading.append(" · by packet class · stored history + live", style="muted")
|
||||
counts = self._kind_counts()
|
||||
if self._scope:
|
||||
heading.append(" · by packet class · floods in the last 2 h", style="muted")
|
||||
counts = self._scoped().counts
|
||||
empty = "nothing heard in this scope in the last 2 h"
|
||||
else:
|
||||
heading.append(" · by packet class · stored history + live", style="muted")
|
||||
counts = {k: n for k, n in self._kind_counts().items() if _is_frame_bucket(k)}
|
||||
empty = "nothing heard yet"
|
||||
if not counts:
|
||||
return [heading, Text("nothing heard yet", style="muted")]
|
||||
return [heading, Text(empty, style="muted")]
|
||||
order = {k: i for i, k in enumerate(_TRAFFIC_ORDER)}
|
||||
peak = max(counts.values())
|
||||
chrome = {bucket: _traffic_chrome(bucket) for bucket in counts}
|
||||
label_w = max(len(label) for _, label, _ in chrome.values())
|
||||
label_w = max(len(label) for _, label in chrome.values())
|
||||
count_w = len(str(peak))
|
||||
style = KIND_STYLES["packet"]
|
||||
rows: list[RenderableType] = [heading]
|
||||
for bucket in sorted(counts, key=lambda k: (order.get(k, len(order)), k)):
|
||||
count = counts[bucket]
|
||||
icon, label, style = chrome[bucket]
|
||||
icon, label = chrome[bucket]
|
||||
row = Text(f"{icon} ")
|
||||
row.append(f"{label.ljust(label_w)} ", style="muted")
|
||||
row.append(f"{count:>{count_w}} ")
|
||||
@@ -480,8 +594,11 @@ class DashboardScreen(Screen):
|
||||
heading.append(" · reception over 2 h · radio live", style="muted")
|
||||
rows: list[tuple[str, Text]] = []
|
||||
|
||||
snrs = self._win_snrs # the one-pass window digest (see _digest_window)
|
||||
rssis = self._win_rssis
|
||||
# The one-pass window digest (see _digest_window), or the narrowed view's frames.
|
||||
if self._scope:
|
||||
snrs, rssis = self._scoped().snrs, self._scoped().rssis
|
||||
else:
|
||||
snrs, rssis = self._win_snrs, self._win_rssis
|
||||
if snrs:
|
||||
med = median(snrs)
|
||||
line = Text(f"{med:+.1f} dB median ", style=snr_style(med))
|
||||
@@ -570,6 +687,7 @@ async def open_dashboard(ctx: AppContext) -> None:
|
||||
activity=ctx.monitor.activity_histogram,
|
||||
activity_flags=ctx.monitor.activity_session_flags,
|
||||
kind_counts=ctx.monitor.kind_counts,
|
||||
scope_of=ctx.region_store.scope_of if ctx.region_store is not None else None,
|
||||
)
|
||||
|
||||
unsubscribe = ctx.events.subscribe(screen.on_event)
|
||||
|
||||
@@ -64,6 +64,10 @@ _SGR = re.compile(r"\x1b\[[0-9;]*m")
|
||||
#: lands a bank too high — the theme's own styles state their intent for the same reason.
|
||||
_DIM_FG = re.compile(r"3[0-7]")
|
||||
|
||||
#: The foreground a DOS console stands on after a reset: grey, slot 7 — which bold lifts
|
||||
#: to white.
|
||||
_DEFAULT_FG = 37
|
||||
|
||||
#: Parameters that settle the intensity question themselves, so a span carrying one needs
|
||||
#: no help: a reset, bold, faint, or an explicit return to normal.
|
||||
_SAYS_INTENSITY = frozenset({"", "0", "1", "2", "22"})
|
||||
@@ -215,6 +219,12 @@ def _state_intensity(row: str) -> str:
|
||||
kept.append(standing)
|
||||
if "22" not in kept:
|
||||
kept.insert(0, "22")
|
||||
elif says and here:
|
||||
# No colour named since a reset: the one in force is the console's default,
|
||||
# grey (7), and brightening it gives white. ``ESC[0m`` then ``ESC[1m`` is how
|
||||
# the art says "white", and dropping the "1" without restating anything left
|
||||
# those spans in the terminal's own default grey — a white run drawn grey.
|
||||
kept.append(str(_DEFAULT_FG + 60))
|
||||
if kept:
|
||||
out.append("\x1b[" + ";".join(kept) + "m")
|
||||
else:
|
||||
|
||||
+137
-53
@@ -39,7 +39,6 @@ from .menus import (
|
||||
from .surface import TuiUi
|
||||
from .theme import make_console
|
||||
from .tui import (
|
||||
CANCEL,
|
||||
Choice,
|
||||
PopToMenu,
|
||||
ReconnectDialog,
|
||||
@@ -49,6 +48,7 @@ from .tui import (
|
||||
TuiSession,
|
||||
)
|
||||
from .tui.emoji_width import install as install_emoji_widths
|
||||
from .tui.fkeys import FPair
|
||||
from .tui.spinner import spinner_interval
|
||||
from .widgets import battery_cell
|
||||
|
||||
@@ -79,6 +79,12 @@ _BLE_PRESENCE_SCAN_S = 8.0
|
||||
#: stops the loop spinning when scanning fails instantly (no adapter, Bluetooth switched off).
|
||||
_BLE_RESCAN_PAUSE_S = 1.0
|
||||
|
||||
#: After a reboot we sent, how long to leave the companion alone before the first reconnect
|
||||
#: attempt (seconds). The dialog is up the instant the command is out, which is before the
|
||||
#: board has necessarily started restarting; a port that stays put through the reboot would
|
||||
#: otherwise be reopened against the old session, which dies under it a moment later.
|
||||
_REBOOT_SETTLE_S = 1.5
|
||||
|
||||
|
||||
def _arm_exit_watchdog(seconds: float = _EXIT_WATCHDOG_S) -> None:
|
||||
"""Guarantee the process terminates even if the exit path wedges.
|
||||
@@ -392,6 +398,8 @@ async def run_menu(ctx: AppContext) -> None:
|
||||
header_cache: dict = {}
|
||||
session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols))
|
||||
ctx.ui = TuiUi(session)
|
||||
# The one quit confirm, asked by the menu's Quit row and by the quit chord from anywhere.
|
||||
session.set_quit_confirm(lambda: _confirm_quit(ctx, session))
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
@@ -476,11 +484,95 @@ async def _unpair_on_exit(ctx: AppContext) -> None:
|
||||
#: can show, the same as any tool's.
|
||||
_QUIT_ICON = "🚪"
|
||||
|
||||
#: The value the Quit row resolves with — Esc's ``CANCEL`` and this both open the quit
|
||||
#: confirm (see :func:`_menu_round`).
|
||||
#: The value the Quit row resolves with, which opens the quit confirm (see
|
||||
#: :func:`_menu_round`). Esc at the menu does not: it is inert there (see :class:`_MainMenu`).
|
||||
_QUIT_VALUE = "__quit__"
|
||||
|
||||
|
||||
async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool:
|
||||
"""Float the quit confirm over whatever is up; return whether to leave.
|
||||
|
||||
One dialog for both doors — the menu's Quit row and the quit chord, which asks from
|
||||
anywhere (see :meth:`~meshterm.ui.tui.session.TuiSession.request_quit`). Cancel (Esc)
|
||||
sits left of Quit (Enter), and Quit starts highlighted so Enter commits it.
|
||||
|
||||
It offers to drop the OS pairing on the way out, but only when there is a live Bluetooth
|
||||
bond to drop — never on serial, an open (PIN-less) companion, or a platform we can't
|
||||
unpair. That button sits between Cancel and Quit and is never the default, so it takes a
|
||||
deliberate choice, not a stray Enter. The unpair itself is deferred to teardown (see
|
||||
:func:`run_menu`), because the link has to be disconnected first; the remembered-device
|
||||
record is intentionally kept, so the device just asks for its PIN again.
|
||||
|
||||
Args:
|
||||
ctx: The shared application context (``unpair_on_exit`` is set on *Unpair & quit*).
|
||||
session: The running TUI session.
|
||||
|
||||
Returns:
|
||||
``True`` if the reader chose Quit or Unpair & quit.
|
||||
"""
|
||||
buttons = [("Cancel", "cancel")]
|
||||
if await _can_unpair(ctx):
|
||||
buttons.append(("Unpair & quit", "unpair"))
|
||||
buttons.append(("Quit", "quit"))
|
||||
choice = await session.button_dialog(
|
||||
"Are you sure you want to quit?",
|
||||
buttons,
|
||||
title="Quit MeshTerm",
|
||||
default=len(buttons) - 1, # highlight Quit
|
||||
# The hint stays the dialog default: Enter commits *the highlighted button*,
|
||||
# which is Quit only until ←→ moves it — and with a third button in the row,
|
||||
# naming the key that moves it is the point.
|
||||
prompt_style="warn",
|
||||
button_style="selected",
|
||||
button_idle_style="muted",
|
||||
border_style="warn",
|
||||
)
|
||||
if choice == "unpair":
|
||||
ctx.unpair_on_exit = True
|
||||
return choice in ("unpair", "quit")
|
||||
|
||||
|
||||
class _MainMenu(SelectScreen):
|
||||
"""The main menu's list, on which Esc peels a typed filter and otherwise does nothing.
|
||||
|
||||
Pressing Esc over and over is the natural climb back to the menu, and when Esc at the
|
||||
top opened the quit confirm, the presses that landed after the climb opened it too — the
|
||||
reader asked for the menu and got a question about leaving (issue #22). The menu is the
|
||||
bottom of the stack, so there is nothing for Esc to go back to; leaving is a choice made
|
||||
on the Quit row, or with the quit chord, both of which ask first.
|
||||
|
||||
The footer therefore ends on the quit chord rather than on Esc, until a filter is
|
||||
standing and ``Esc clear`` joins it last, because that is what the press does then —
|
||||
the same atom every filtering list shows.
|
||||
"""
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Answer every key as a select list does, except a bare Esc, which is inert."""
|
||||
if action == "escape" and not self._filter:
|
||||
return
|
||||
super().handle(action, data)
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
"""The list's own lane, with the way out on F3: ``Quit?`` asks, ``Quit!`` doesn't.
|
||||
|
||||
The PicoCalc has no hint line, so the lane is where the menu's way out has to be
|
||||
stated. F3 is the list's one free slot (F1/F2 are the section jumps; a menu row is
|
||||
never deletable). ``Quit?`` opens the same confirm as the Quit row and ^Q; its Shift
|
||||
half, F8, leaves without asking — a two-key reach on the menu alone, for the reader
|
||||
who already decided (JP, 2026-09-29).
|
||||
"""
|
||||
lane = list(super().fkey_lane)
|
||||
lane[2] = FPair("Quit?", "quit", "Quit!", "quit_now")
|
||||
return lane
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str:
|
||||
"""The list's own hint, gaining ``Esc clear`` only while a filter is standing."""
|
||||
base = super().footer_hint
|
||||
return f"{base} · Esc clear" if self._filter else base
|
||||
|
||||
|
||||
def _menu_lane(tools: Sequence[Any]) -> int:
|
||||
"""The main menu's icon column in cells: every tool's mark *and* the Quit row's.
|
||||
|
||||
@@ -608,18 +700,20 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
|
||||
# Drive the menu list ourselves (rather than via session.select) so it stays on the
|
||||
# stack while the quit dialog floats over it: the confirm is drawn as a centered box
|
||||
# on top of the still-visible menu, not as a screen that replaces it.
|
||||
menu = SelectScreen(
|
||||
menu = _MainMenu(
|
||||
"What would you like to do?",
|
||||
items,
|
||||
footer_hint="↑↓ move · type to filter · Enter select · Esc quit",
|
||||
# The one place the quit chord is named: the menu is where a reader looks for the
|
||||
# way out, and with Esc inert here the footer would otherwise name none. Everywhere
|
||||
# else it stays unadvertised, like ^W (see session._CTRL_LETTER_CHORDS).
|
||||
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit?",
|
||||
)
|
||||
session.set_root(menu)
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
try:
|
||||
await _menu_round(ctx, session, menu, tools, loop)
|
||||
except _Quit as chosen:
|
||||
ctx.unpair_on_exit = chosen.unpair
|
||||
except _Quit:
|
||||
return
|
||||
except PopToMenu:
|
||||
# ^W from somewhere deep. Every frame between there and here has already
|
||||
@@ -629,18 +723,14 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None:
|
||||
|
||||
|
||||
class _Quit(Exception):
|
||||
"""The user confirmed the quit dialog; ``unpair`` says whether to drop the OS bond too.
|
||||
"""The user confirmed the quit dialog from the menu's Quit row.
|
||||
|
||||
A menu round runs a whole tool inside itself, so "the user chose Quit" cannot be spelled
|
||||
as a ``return`` — the loop would just show the menu again. It is raised instead, and
|
||||
caught in the one frame that owns the decision to leave.
|
||||
caught in the one frame that owns the decision to leave. Whether to drop the OS bond too
|
||||
is already recorded on the context by the confirm (see :func:`_confirm_quit`).
|
||||
"""
|
||||
|
||||
def __init__(self, *, unpair: bool) -> None:
|
||||
"""Record whether the OS pairing should be dropped on the way out."""
|
||||
super().__init__("quit")
|
||||
self.unpair = unpair
|
||||
|
||||
|
||||
async def _menu_round(
|
||||
ctx: AppContext,
|
||||
@@ -671,41 +761,11 @@ async def _menu_round(
|
||||
ran_over_menu = False
|
||||
try:
|
||||
selection = await menu.future
|
||||
if selection is CANCEL: # Esc at the top level
|
||||
selection = None
|
||||
# Both picking "quit" and pressing Esc ask to leave; confirm on a dialog floating
|
||||
# over the (still-pushed) menu so a stray key doesn't drop the user out. Cancel
|
||||
# (Esc) sits left of Quit (Enter); Quit starts highlighted so Enter commits it.
|
||||
if selection in (None, _QUIT_VALUE):
|
||||
# Offer to drop the OS pairing on the way out, but only when there is a live
|
||||
# Bluetooth bond to drop — never on serial, an open (PIN-less) companion, or a
|
||||
# platform we can't unpair. The extra button sits between Cancel and Quit and is
|
||||
# never the default, so it takes a deliberate choice, not a stray Enter.
|
||||
unpairable = await _can_unpair(ctx)
|
||||
buttons = [("Cancel", "cancel")]
|
||||
if unpairable:
|
||||
buttons.append(("Unpair & quit", "unpair"))
|
||||
buttons.append(("Quit", "quit"))
|
||||
choice = await session.button_dialog(
|
||||
"Are you sure you want to quit?",
|
||||
buttons,
|
||||
title="Quit MeshTerm",
|
||||
default=len(buttons) - 1, # highlight Quit
|
||||
# The hint stays the dialog default: Enter commits *the highlighted
|
||||
# button*, which is Quit only until ←→ moves it — and with a third
|
||||
# button in the row, naming the key that moves it is the point.
|
||||
prompt_style="warn",
|
||||
button_style="selected",
|
||||
button_idle_style="muted",
|
||||
border_style="warn",
|
||||
)
|
||||
# "Unpair & quit" forgets the OS bond as we leave; the disconnect must happen
|
||||
# first, so the actual unpair is deferred to teardown (see run_menu). The
|
||||
# remembered-device record is intentionally kept — the device just asks for its
|
||||
# PIN again. Cancel (button or Esc → None) ends the round and redraws the menu,
|
||||
# whose cursor never moved off the row they came from.
|
||||
if choice in ("unpair", "quit"):
|
||||
raise _Quit(unpair=choice == "unpair")
|
||||
# The Quit row asks first, on a dialog floating over the (still-pushed) menu; Cancel
|
||||
# ends the round and redraws the menu, whose cursor never moved off the row.
|
||||
if selection == _QUIT_VALUE:
|
||||
if await session.confirm_quit():
|
||||
raise _Quit()
|
||||
return
|
||||
# A popup tool runs while the menu is still pushed, so its prompts and
|
||||
# result float over it as modal dialogs instead of replacing the screen.
|
||||
@@ -1044,6 +1104,20 @@ async def _wait_for_disconnect(ctx: AppContext) -> None:
|
||||
if ctx.mock:
|
||||
await asyncio.Event().wait() # the simulator is never "unplugged"; wait forever
|
||||
return
|
||||
# A reboot we sent is a drop we already know about: race it against the poll so the
|
||||
# reconnect dialog is up the moment the command is out, not whenever (or if ever) a
|
||||
# poll happens to land inside the reboot.
|
||||
announced = asyncio.ensure_future(ctx.link_going_down())
|
||||
polled = asyncio.ensure_future(_poll_for_disconnect(ctx))
|
||||
try:
|
||||
await asyncio.wait({announced, polled}, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
await _cancel_and_wait(announced)
|
||||
await _cancel_and_wait(polled)
|
||||
|
||||
|
||||
async def _poll_for_disconnect(ctx: AppContext) -> None:
|
||||
"""Resolve once the liveness check sees the link gone, debounced against a blip."""
|
||||
while True:
|
||||
await asyncio.sleep(_LIVENESS_POLL_S)
|
||||
if not ctx.is_connected:
|
||||
@@ -1076,8 +1150,8 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool:
|
||||
# A drop the config editor announced (it just sent a reboot command) is expected, so
|
||||
# the dialog says what is actually happening instead of implying an unplug. The flag
|
||||
# is consumed here so a later, genuine disconnect goes back to the generic wording.
|
||||
if ctx.reboot_in_progress:
|
||||
ctx.reboot_in_progress = False
|
||||
rebooting = ctx.take_reboot()
|
||||
if rebooting:
|
||||
dialog = ReconnectDialog(
|
||||
"Rebooting — waiting for the device to come back…",
|
||||
title="Device rebooting",
|
||||
@@ -1096,7 +1170,9 @@ async def _handle_disconnect(ctx: AppContext, session: TuiSession) -> bool:
|
||||
session.push(base)
|
||||
session.push(dialog)
|
||||
animator = asyncio.ensure_future(_animate_dialog(session, dialog))
|
||||
reconnector = asyncio.ensure_future(_auto_reconnect(ctx, dialog))
|
||||
reconnector = asyncio.ensure_future(
|
||||
_auto_reconnect(ctx, dialog, settle_s=_REBOOT_SETTLE_S if rebooting else 0.0)
|
||||
)
|
||||
try:
|
||||
result = await dialog.future
|
||||
finally:
|
||||
@@ -1115,7 +1191,9 @@ async def _animate_dialog(session: TuiSession, dialog: ReconnectDialog) -> None:
|
||||
session.invalidate()
|
||||
|
||||
|
||||
async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
|
||||
async def _auto_reconnect(
|
||||
ctx: AppContext, dialog: ReconnectDialog, *, settle_s: float = 0.0
|
||||
) -> None:
|
||||
"""Poll for the device to return, reconnect when it does, then dismiss ``dialog``.
|
||||
|
||||
The dead link is released up front (:meth:`~meshterm.context.AppContext.release_link`) so
|
||||
@@ -1141,6 +1219,10 @@ async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
dialog: The reconnect dialog to dismiss once the link is back.
|
||||
settle_s: How long to leave the device alone after releasing the link, before the
|
||||
first attempt. Nonzero only for a reboot we sent: the watcher fires the instant
|
||||
the command is out, and a serial port that never went away would otherwise be
|
||||
reopened against a board that has not yet begun to restart.
|
||||
"""
|
||||
from ..core.connection import serial_port_present
|
||||
from ..core.discovery import find_ble_device
|
||||
@@ -1149,6 +1231,8 @@ async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
|
||||
# still nominally connected to does not advertise, so the scan below would wait out a
|
||||
# peripheral that is powered on and silent purely because we never let go of it.
|
||||
await ctx.release_link()
|
||||
if settle_s:
|
||||
await asyncio.sleep(settle_s)
|
||||
|
||||
while True:
|
||||
ble_device: object | None = None
|
||||
|
||||
@@ -281,14 +281,23 @@ def class_marks(entry: PacketEntry) -> tuple[str, str]:
|
||||
"""
|
||||
if entry.kind == "packet":
|
||||
raw = entry.raw if isinstance(entry.raw, dict) else {}
|
||||
typename = raw.get("payload_typename")
|
||||
if typename:
|
||||
emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON)
|
||||
return glyph(emoji), _PAYLOAD_GLOSS.get(typename, typename.lower())
|
||||
return glyph(KIND_ICONS["packet"]), "packet"
|
||||
return payload_marks(raw.get("payload_typename"))
|
||||
return kind_icon(entry.kind), entry.kind
|
||||
|
||||
|
||||
def payload_marks(typename: str | None) -> tuple[str, str]:
|
||||
"""A raw frame's icon and class label from its payload typename alone.
|
||||
|
||||
:func:`class_marks`'s half for a ``packet`` frame, for a surface that holds a tally by
|
||||
typename rather than a packet (the dashboard's traffic rows), so a class reads the same
|
||||
there as it does in the live feed's class lane. ``None`` is the class-less frame.
|
||||
"""
|
||||
if typename:
|
||||
emoji = PAYLOAD_ICONS.get(typename, DEFAULT_ICON)
|
||||
return glyph(emoji), _PAYLOAD_GLOSS.get(typename, typename.lower())
|
||||
return glyph(KIND_ICONS["packet"]), "packet"
|
||||
|
||||
|
||||
def class_chrome(entry: PacketEntry) -> tuple[str, str]:
|
||||
"""The class headline the viewer's card leads with — :func:`class_marks`, shouted.
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""The scope ring: the views a screen of recorded traffic can narrow to, and their words.
|
||||
|
||||
A flood is either unscoped or scoped into a region, and a screen that counts floods can
|
||||
show all of them or only one scope's. The dashboard and the Time Machine's mesh page both
|
||||
cycle those views on ``s`` (F2/F3 on the PicoCalc), so the ring's order, the title atom a
|
||||
view is named by, and the chip that names the next one are defined once, here.
|
||||
|
||||
A view is a :data:`ScopeKey` — ``("unscoped",)``, ``("region", name)`` or ``("unknown",)``
|
||||
— and ``None`` is the unnarrowed view, every packet heard. A direct frame has no scope, so
|
||||
it belongs to no view but that one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from ..core.regions import Scope
|
||||
from .menus import fit_cells
|
||||
|
||||
#: A scope view: ``("unscoped",)``, ``("region", name)`` or ``("unknown",)``.
|
||||
ScopeKey = tuple[str, ...]
|
||||
|
||||
|
||||
def scope_key(scope: Scope | None) -> ScopeKey | None:
|
||||
"""The view a frame counts in, or ``None`` for one with no scope to state.
|
||||
|
||||
A direct frame has none, and neither does one stored before its route type was kept;
|
||||
both are counted only in the unnarrowed view. Every scoped frame no known region name
|
||||
reproduces shares one ``unknown`` view: its code changes with each packet, so it
|
||||
cannot tell two unnamed regions apart.
|
||||
"""
|
||||
if scope is None:
|
||||
return None
|
||||
if not scope.scoped:
|
||||
return ("unscoped",)
|
||||
if scope.region:
|
||||
return ("region", scope.region)
|
||||
return ("unknown",)
|
||||
|
||||
|
||||
def _ring_order(key: ScopeKey) -> tuple[int, str]:
|
||||
"""Where a view sits in the cycle: unscoped, the regions A–Z, then unknown.
|
||||
|
||||
Alphabetical rather than busiest-first so the ring holds still while the counts move:
|
||||
a cycle whose order shifted under the reader's thumb would skip or repeat a view.
|
||||
"""
|
||||
if key[0] == "unscoped":
|
||||
return (0, "")
|
||||
if key[0] == "region":
|
||||
return (1, key[1].casefold())
|
||||
return (2, "")
|
||||
|
||||
|
||||
def ring(heard: Iterable[ScopeKey], current: ScopeKey | None) -> list[ScopeKey | None]:
|
||||
"""Every view on offer: all, then each scope heard, in cycle order.
|
||||
|
||||
The view on screen stays in the ring even once nothing in it is left to count (its
|
||||
last frame aged out, or a narrower window holds none), so the cycle never loses the
|
||||
reader's place; it simply leaves on the next press.
|
||||
"""
|
||||
keys = set(heard)
|
||||
if current is not None:
|
||||
keys.add(current)
|
||||
return [None, *sorted(keys, key=_ring_order)]
|
||||
|
||||
|
||||
def step(views: list[ScopeKey | None], current: ScopeKey | None) -> ScopeKey | None:
|
||||
"""The view after ``current``.
|
||||
|
||||
The cycle wraps: ``s`` is forward-only, with no reverse key of its own, so a ring
|
||||
that stopped at its end would strand the reader there.
|
||||
"""
|
||||
return views[(views.index(current) + 1) % len(views)]
|
||||
|
||||
|
||||
def scope_atom(key: ScopeKey, *, bare: bool = False) -> str:
|
||||
"""A view's title atom: ``scope yul``, ``unscoped`` or ``unknown scope``.
|
||||
|
||||
``bare`` drops the ``scope`` lead-in before a region's name, for a title too narrow
|
||||
to spend the word on (the lexicon's region is shown bare anyway).
|
||||
"""
|
||||
if key[0] == "region":
|
||||
return key[1] if bare else f"scope {key[1]}"
|
||||
return "unscoped" if key[0] == "unscoped" else "unknown scope"
|
||||
|
||||
|
||||
def scope_chip(key: ScopeKey | None) -> str:
|
||||
"""The F-lane chip naming the view a press goes *to*, within the lane's 6 cells."""
|
||||
word = "all" if key is None else key[-1]
|
||||
return "▸ " + fit_cells(word, 4).rstrip()
|
||||
@@ -15,6 +15,14 @@ the PicoCalc's ring stops at 30 d, its F3 chip cycling the same three spans):
|
||||
mesh-wide hour-of-day rhythm, the arrivals of the window (nodes heard for the first
|
||||
time ever, in aligned name/hash/first-heard lanes), and the all-time totals.
|
||||
|
||||
The whole-mesh page also cycles its **scope** on ``s`` (F2 on the PicoCalc): all, unscoped,
|
||||
each region heard in the window A–Z, unknown scope — the dashboard's ring over the recorded
|
||||
history. A narrowed page charts that scope's floods per day (or hour) and their rhythm, and
|
||||
its own ledger; the node counts and arrivals leave, being read off adverts, which carry no
|
||||
scope. Floods stored before MeshTerm kept their route count only under all. The node and
|
||||
own-node pages have no scope to narrow: a node's page is its adverts and telemetry, and
|
||||
what we send is traces, which go direct.
|
||||
|
||||
Charts read chronologically — oldest at the left, now at the right, the app-wide
|
||||
timeline direction — and draw through :mod:`~meshterm.ui.braillechart`, so the grey
|
||||
baseline always marks zero: the SNR band's readings hang below it or rise above it
|
||||
@@ -24,6 +32,7 @@ transmits.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime, timedelta
|
||||
from statistics import median
|
||||
@@ -45,6 +54,7 @@ from .braillechart import (
|
||||
)
|
||||
from .contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING, ContactListScreen, ContactRow
|
||||
from .menus import command_label, fit_cells, section_heading
|
||||
from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step
|
||||
from .theme import name_style, snr_style
|
||||
from .tui.render import render_lines
|
||||
from .tui.screen import CANCEL, Screen
|
||||
@@ -214,10 +224,18 @@ def _quarter_axis(frac: float) -> str:
|
||||
|
||||
|
||||
class TimeMachineScreen(Screen):
|
||||
"""One subject's history page: scrollable sections, ``w`` cycles the window."""
|
||||
"""One subject's history page: scrollable sections, ``w`` cycles the window.
|
||||
|
||||
A page whose history carries scope (the whole mesh's) also cycles it on ``s``.
|
||||
"""
|
||||
|
||||
floating = False
|
||||
footer_hint = "↑↓ PgUp/PgDn scroll · w window · Esc back"
|
||||
|
||||
@property
|
||||
def footer_hint(self) -> str: # type: ignore[override]
|
||||
"""The page's keys; ``s scope`` only where there is a scope to narrow to."""
|
||||
scope = " · s scope" if len(self._ring()) > 1 else ""
|
||||
return f"↑↓ PgUp/PgDn scroll · w window{scope} · Esc back"
|
||||
|
||||
@property
|
||||
def fkey_lane(self):
|
||||
@@ -241,6 +259,10 @@ class TimeMachineScreen(Screen):
|
||||
lane = list(default_lane(nav=self.content_overflows))
|
||||
nxt, _delta = _WINDOWS[(self._window_index + 1) % len(_WINDOWS)]
|
||||
lane[2] = FPair(f"▸ {'all' if nxt == 'all time' else nxt}", "window")
|
||||
# The scope cycle beside it, on the same terms: the chip names where a press goes.
|
||||
views = self._ring()
|
||||
if len(views) > 1:
|
||||
lane[1] = FPair(scope_chip(step(views, self._scope)), "scope")
|
||||
return lane
|
||||
|
||||
def __init__(
|
||||
@@ -248,28 +270,45 @@ class TimeMachineScreen(Screen):
|
||||
*,
|
||||
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
|
||||
label: str,
|
||||
build: Callable[[timedelta | None, int], list[RenderableType]],
|
||||
build: Callable[[timedelta | None, int, ScopeKey | None], list[RenderableType]],
|
||||
scopes: Callable[[timedelta | None], set[ScopeKey]] | None = None,
|
||||
) -> None:
|
||||
"""Create the page over its section builder.
|
||||
|
||||
Args:
|
||||
session: The running TUI session (for repaints on window switch).
|
||||
label: The subject's display name (titles the screen).
|
||||
build: Renders the sections for ``(window, width)``; called once per
|
||||
window/width combination and cached — the data is stored history,
|
||||
so nothing needs re-querying per repaint.
|
||||
build: Renders the sections for ``(window, width, scope)``; called once per
|
||||
combination and cached — the data is stored history, so nothing needs
|
||||
re-querying per repaint. ``scope`` is ``None`` for every packet.
|
||||
scopes: The scope views a window's history holds, for a page that can narrow
|
||||
to one; ``None`` offers no scope cycle.
|
||||
"""
|
||||
super().__init__()
|
||||
self._session = session
|
||||
self._label = label
|
||||
self._build = build
|
||||
self._scopes = scopes
|
||||
self._window_index = 1 # open on 7 d: enough depth to see shape, still fast
|
||||
self._cache: dict[tuple[int, int], list[str]] = {}
|
||||
#: The scope view on screen; ``None`` is every packet.
|
||||
self._scope: ScopeKey | None = None
|
||||
self._cache: dict[tuple[int, int, ScopeKey | None], list[str]] = {}
|
||||
self._set_title()
|
||||
|
||||
def _ring(self) -> list[ScopeKey | None]:
|
||||
"""The scope views on offer in the current window (just ``[None]`` if none)."""
|
||||
if self._scopes is None:
|
||||
return [None]
|
||||
_name, delta = _WINDOWS[self._window_index]
|
||||
return ring(self._scopes(delta), self._scope)
|
||||
|
||||
def _set_title(self) -> None:
|
||||
name, _delta = _WINDOWS[self._window_index]
|
||||
self.title = f"{self._label} · {name}"
|
||||
self.short_title = ""
|
||||
if self._scope is not None:
|
||||
self.title += f" · {scope_atom(self._scope)}"
|
||||
self.short_title = f"{self._label} · {name} · {scope_atom(self._scope, bare=True)}"
|
||||
|
||||
def handle(self, action: str, data: str = "") -> None:
|
||||
"""Scroll, cycle the window, or dismiss.
|
||||
@@ -295,16 +334,23 @@ class TimeMachineScreen(Screen):
|
||||
self._set_title()
|
||||
self.scroll_to_top()
|
||||
self._session.invalidate()
|
||||
elif action == "scope" or (action == "text" and data.lower() == "s"):
|
||||
views = self._ring()
|
||||
if len(views) > 1:
|
||||
self._scope = step(views, self._scope)
|
||||
self._set_title()
|
||||
self.scroll_to_top()
|
||||
self._session.invalidate()
|
||||
elif action == "escape":
|
||||
self.resolve(None)
|
||||
|
||||
def render_body(self, width: int) -> list[str]:
|
||||
"""Render (or reuse) the current window's sections."""
|
||||
key = (self._window_index, width)
|
||||
key = (self._window_index, width, self._scope)
|
||||
lines = self._cache.get(key)
|
||||
if lines is None:
|
||||
_name, delta = _WINDOWS[self._window_index]
|
||||
lines = render_lines(Group(*self._build(delta, width)), width)
|
||||
lines = render_lines(Group(*self._build(delta, width, self._scope)), width)
|
||||
self._cache[key] = lines
|
||||
self._scroll_total = max(1, len(lines))
|
||||
return lines
|
||||
@@ -1110,6 +1156,133 @@ def _mesh_sections(
|
||||
return out
|
||||
|
||||
|
||||
class _MeshFloods:
|
||||
"""The mesh page's stored floods per window, each with the scope view it counts in.
|
||||
|
||||
Naming a scoped flood's region recomputes its transport code under each known region's
|
||||
key (see :meth:`~meshterm.core.region_store.RegionStore.scope_of`), so a window's
|
||||
floods are read and resolved once and kept for the page's life: the ring asks which
|
||||
views a window holds, and a narrowed page asks for one view's timestamps.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: AppContext) -> None:
|
||||
"""Bind to the context's history and region names."""
|
||||
self._ctx = ctx
|
||||
self._by_window: dict[timedelta | None, list[tuple[ScopeKey, datetime]]] = {}
|
||||
|
||||
def _frames(self, window: timedelta | None) -> list[tuple[ScopeKey, datetime]]:
|
||||
if window not in self._by_window:
|
||||
store = getattr(self._ctx, "region_store", None)
|
||||
frames: list[tuple[ScopeKey, datetime]] = []
|
||||
if store is not None:
|
||||
since = utcnow() - window if window is not None else None
|
||||
for when, raw in self._ctx.repo.flood_frames(since=since):
|
||||
key = scope_key(store.scope_of(raw))
|
||||
if key is not None:
|
||||
frames.append((key, when))
|
||||
self._by_window[window] = frames
|
||||
return self._by_window[window]
|
||||
|
||||
def keys(self, window: timedelta | None) -> set[ScopeKey]:
|
||||
"""The scope views ``window`` holds any flood in."""
|
||||
return {key for key, _ in self._frames(window)}
|
||||
|
||||
def stamps(self, window: timedelta | None, scope: ScopeKey) -> list[datetime]:
|
||||
"""When each of the window's floods in ``scope`` was heard, oldest first."""
|
||||
return [when for key, when in self._frames(window) if key == scope]
|
||||
|
||||
|
||||
def _floods(count: int) -> str:
|
||||
"""``1 flood``, ``12 floods``."""
|
||||
return f"{count} flood{'' if count == 1 else 's'}"
|
||||
|
||||
|
||||
def _mesh_scope_sections(
|
||||
stamps: list[datetime], window: timedelta | None, width: int
|
||||
) -> list[RenderableType]:
|
||||
"""The whole-mesh page narrowed to one scope: its floods per day, their rhythm, a ledger.
|
||||
|
||||
The unnarrowed page's day (or hour) bars and rhythm, over the scope's floods instead of
|
||||
every observation. Its node counts and arrivals have no narrowed form — a node is known
|
||||
by its adverts, and an advert says nothing about the scope other traffic was sent in —
|
||||
so they leave rather than show the whole mesh's under a scope's title.
|
||||
"""
|
||||
now = utcnow()
|
||||
since = now - window if window is not None else None
|
||||
if not stamps:
|
||||
return [
|
||||
Text(),
|
||||
Text("Nothing recorded in this scope in this window.", style="muted"),
|
||||
Text("Press w to widen it, or s for another scope.", style="muted"),
|
||||
]
|
||||
hourly = since is not None and window is not None and window <= timedelta(days=1)
|
||||
# The same local-time keys the repository's day and hour series group by, so the fill
|
||||
# and tick machinery below reads them exactly as it reads the unnarrowed page's.
|
||||
per = Counter(
|
||||
when.astimezone().strftime("%Y-%m-%dT%H" if hourly else "%Y-%m-%d") for when in stamps
|
||||
)
|
||||
active = [(iso, count, 0) for iso, count in sorted(per.items())]
|
||||
series = _fill_hours(active, since, now) if hourly and since else _fill_days(active, since, now)
|
||||
|
||||
base_w = axis_label_w(max(d[1] for d in series), _CHART_ROWS)
|
||||
slots, slice_minutes, label_w = _fit_rhythm(
|
||||
lambda minutes: _rhythm_slots(stamps, minutes), width, base_w
|
||||
)
|
||||
chars = max(20, width - axis_chrome(label_w))
|
||||
shown = series[-chars * 2 :]
|
||||
floods = [d[1] for d in shown]
|
||||
out: list[RenderableType] = []
|
||||
out.append(
|
||||
body_heading(
|
||||
"Floods per hour" if hourly else "Floods per day",
|
||||
"local hours" if hourly else "local days",
|
||||
)
|
||||
)
|
||||
out.extend(
|
||||
axis_chart(
|
||||
timeline_rows(_day_columns(floods, chars), rows=_CHART_ROWS),
|
||||
max(floods),
|
||||
chars,
|
||||
label_w=label_w,
|
||||
ticks=_hour_ticks(shown, chars) if hourly else _day_ticks(shown, chars),
|
||||
)
|
||||
)
|
||||
|
||||
out.append(Text())
|
||||
out.append(
|
||||
body_heading("Rhythm", f"floods by local time of day · {_slice_note(slice_minutes)}")
|
||||
)
|
||||
out.extend(
|
||||
axis_chart(
|
||||
timeline_rows(slots, rows=_CHART_ROWS),
|
||||
max(slots),
|
||||
len(slots) // 2,
|
||||
_quarter_axis,
|
||||
label_w=label_w,
|
||||
)
|
||||
)
|
||||
|
||||
days = Counter(when.astimezone().strftime("%Y-%m-%d") for when in stamps)
|
||||
busiest = max(days.items(), key=lambda day: day[1])
|
||||
out.append(Text())
|
||||
out.append(body_heading("Ledger", "this window"))
|
||||
out.append(
|
||||
Text.assemble(
|
||||
("history ", "muted"),
|
||||
(_floods(len(stamps)), ""),
|
||||
(f" across {len(days)} active day{'' if len(days) == 1 else 's'}", "muted"),
|
||||
)
|
||||
)
|
||||
out.append(
|
||||
Text.assemble(
|
||||
("busiest ", "muted"),
|
||||
(busiest[0], "brand"),
|
||||
(f" {_floods(busiest[1])}", "muted"),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# --- the picker loop ---------------------------------------------------------------------
|
||||
|
||||
#: Widest the mesh page's arrivals name lane grows (longer names ellipsize so the lanes
|
||||
@@ -1336,24 +1509,29 @@ async def open_timemachine(ctx: AppContext) -> None:
|
||||
return
|
||||
if picked == SELF:
|
||||
label = self_name or "you"
|
||||
build = lambda window, width: _self_sections( # noqa: E731
|
||||
build = lambda window, width, _scope: _self_sections( # noqa: E731
|
||||
ctx, window, width
|
||||
)
|
||||
scopes = None
|
||||
elif picked == MESH:
|
||||
label = "the whole mesh"
|
||||
build = ( # noqa: E731
|
||||
lambda window, width, _pb=prefix_bytes: _mesh_sections(
|
||||
ctx, window, width, _pb, resolve, resolve_key
|
||||
)
|
||||
)
|
||||
floods = _MeshFloods(ctx)
|
||||
|
||||
def build(window, width, scope, _pb=prefix_bytes, _floods=floods): # noqa: ANN001, ANN202
|
||||
if scope is None:
|
||||
return _mesh_sections(ctx, window, width, _pb, resolve, resolve_key)
|
||||
return _mesh_scope_sections(_floods.stamps(window, scope), window, width)
|
||||
|
||||
scopes = floods.keys
|
||||
else:
|
||||
node_id, label = picked
|
||||
build = ( # noqa: E731 - a tiny binding closure beats a def here
|
||||
lambda window, width, _id=node_id, _lb=label: _node_sections(
|
||||
lambda window, width, _scope, _id=node_id, _lb=label: _node_sections(
|
||||
ctx, _id, _lb, window, width
|
||||
)
|
||||
)
|
||||
screen = TimeMachineScreen(session=session, label=label, build=build)
|
||||
scopes = None
|
||||
screen = TimeMachineScreen(session=session, label=label, build=build, scopes=scopes)
|
||||
await session.run_screen(screen)
|
||||
if {n.node for n in ctx.repo.heard_nodes() if n.node} != subjects:
|
||||
break # the mesh spoke while the page was open; relist
|
||||
@@ -1381,7 +1559,9 @@ async def open_timemachine_node(ctx: AppContext, node_id: str, label: str) -> No
|
||||
raise RuntimeError("the time machine is only available in the menu")
|
||||
session = ctx.ui.session
|
||||
build = ( # noqa: E731 - a tiny binding closure reads better than a def here
|
||||
lambda window, width, _id=node_id, _lb=label: _node_sections(ctx, _id, _lb, window, width)
|
||||
lambda window, width, _scope, _id=node_id, _lb=label: _node_sections(
|
||||
ctx, _id, _lb, window, width
|
||||
)
|
||||
)
|
||||
await session.run_screen(TimeMachineScreen(session=session, label=label, build=build))
|
||||
|
||||
@@ -1407,5 +1587,5 @@ async def open_timemachine_self(ctx: AppContext) -> None:
|
||||
session = ctx.ui.session
|
||||
self_name, _ = await _self_identity(ctx)
|
||||
label = self_name or "you"
|
||||
build = lambda window, width: _self_sections(ctx, window, width) # noqa: E731
|
||||
build = lambda window, width, _scope: _self_sections(ctx, window, width) # noqa: E731
|
||||
await session.run_screen(TimeMachineScreen(session=session, label=label, build=build))
|
||||
+42
-17
@@ -212,17 +212,36 @@ _BASE_BOX_CACHE: tuple[tuple, list[str]] | None = None
|
||||
#: The screen's own way out, lifted off the end of its footer hint. The grammar is
|
||||
#: mandated — "navigation keys, then action keys, **Esc last**" — so the last atom is the
|
||||
#: Esc clause wherever a screen has one, and its verb is the true one for that surface
|
||||
#: (``back`` on a screen, ``quit`` at the main menu, ``keep`` in a value picker).
|
||||
_ESC_ATOM = re.compile(r"(?:^|·\s*)(Esc\s+\S+)\s*$")
|
||||
#: (``back`` on a screen, ``keep`` in a value picker). The main menu is the one screen
|
||||
#: whose way out is not Esc, which is inert there: its hint ends on ``^Q quit?`` instead,
|
||||
#: until a typed filter puts ``Esc clear`` after it.
|
||||
_WAY_OUT_ATOM = re.compile(r"(?:^|·\s*)(Esc\s+\S+|\^[A-Z]\s+quit\??)\s*$")
|
||||
|
||||
#: Rule cells the title keeps on each side before the bar gives the Esc hint back. Below
|
||||
#: this the title is being crowded, which is the one thing the hint must not do.
|
||||
_MIN_RULE = 2
|
||||
|
||||
|
||||
def _esc_hint(hint: str) -> str:
|
||||
"""The ``Esc …`` atom a screen's footer hint ends on, or ``""`` where it has none."""
|
||||
found = _ESC_ATOM.search(hint or "")
|
||||
def fitted_title(screen: Screen, room: int) -> str:
|
||||
"""The heading to draw in ``room`` cells: the full title, or its short form where it won't fit.
|
||||
|
||||
A title that states figures (``Contacts · 198 known + 123 archived``) would otherwise be
|
||||
cut by the rule it sits in, and the cut takes the end — the last figure. A screen that
|
||||
declares a :attr:`~meshterm.ui.tui.screen.Screen.short_title` gets that instead, the same
|
||||
atoms said tighter; one with none keeps its title and the frame's usual clipping.
|
||||
|
||||
Args:
|
||||
screen: The screen whose heading is drawn.
|
||||
room: Cells the heading may take, not counting the space either side of it.
|
||||
"""
|
||||
if screen.short_title and cell_len(screen.title) > room:
|
||||
return screen.short_title
|
||||
return screen.title
|
||||
|
||||
|
||||
def _way_out(hint: str) -> str:
|
||||
"""The way-out atom a screen's footer hint ends on (``Esc …``, ``^Q quit?``), or ``""``."""
|
||||
found = _WAY_OUT_ATOM.search(hint or "")
|
||||
return found.group(1) if found else ""
|
||||
|
||||
|
||||
@@ -256,17 +275,21 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) ->
|
||||
hint line — the F-key lane stands where it would be, and the lane advertises only what
|
||||
its five slots do — so nothing on the screen said that Esc leaves, which is the one key
|
||||
every screen answers to and the reason no screen spends a row on a *Back* item. The
|
||||
verb is the screen's own (:func:`_esc_hint` lifts the atom off its footer hint, so a
|
||||
value picker says ``keep`` and the main menu says ``quit``); a screen whose hint names
|
||||
no Esc gets nothing. Compact by construction: where a long title would be crowded, the
|
||||
atom drops to a bare ``Esc`` and then out altogether — the title is what the reader
|
||||
came for, and the arrows are two cells nothing else can spend.
|
||||
verb is the screen's own (:func:`_way_out` lifts the atom off its footer hint, so a
|
||||
value picker says ``keep``, and the main menu, where Esc is inert, says ``^Q quit?``);
|
||||
a screen whose hint names no way out gets nothing. Compact by construction: where a
|
||||
long title would be crowded, the atom drops to its bare key and then out
|
||||
altogether — the title is what the reader came for, and the arrows are two cells
|
||||
nothing else can spend.
|
||||
"""
|
||||
border = "accent"
|
||||
head_span = 3 # the arrow pair, plus the space parting it from the rule
|
||||
label_w = cell_len(screen.title) + 2 if screen.title else 0 # a space either side
|
||||
atom = _esc_hint(screen.footer_hint)
|
||||
for esc in (atom, "Esc" if atom else "", ""):
|
||||
# The short form is chosen against the bar with no Esc tail at all: the title is what
|
||||
# the reader came for, so the tail gives way to it before it gives way to the tail.
|
||||
title = fitted_title(screen, cols - head_span - 2 - 2 * _MIN_RULE)
|
||||
label_w = cell_len(title) + 2 if title else 0 # a space either side
|
||||
atom = _way_out(screen.footer_hint)
|
||||
for esc in (atom, atom.split()[0] if atom else "", ""):
|
||||
tail_span = cell_len(esc) + 1 if esc else 0 # the space in front of the tail
|
||||
if not esc or cols - head_span - tail_span - label_w >= 2 * _MIN_RULE:
|
||||
break
|
||||
@@ -276,12 +299,12 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) ->
|
||||
bar.append("↑", style=border if more_above else "muted")
|
||||
bar.append("↓", style=border if more_below else "muted")
|
||||
bar.append(" ")
|
||||
if screen.title:
|
||||
if title:
|
||||
left = max(1, (rule_span - label_w) // 2)
|
||||
right = max(1, rule_span - label_w - left)
|
||||
bar.append("─" * left, style=border)
|
||||
bar.append(" ", style=None)
|
||||
bar.append(screen.title, style=title_style(border))
|
||||
bar.append(title, style=title_style(border))
|
||||
bar.append(" ", style=None)
|
||||
bar.append("─" * right, style=border)
|
||||
else:
|
||||
@@ -347,11 +370,13 @@ def compose_base(
|
||||
# a keystroke that need not change a single body line, and a memo without it would
|
||||
# keep serving the rule the reader has already grown out of.
|
||||
caption = base.bottom_caption
|
||||
key = (cols, rows, base.title, caption, footer_hint, more_above, more_below, *visible)
|
||||
# Rich sets the padded title into the rule's inner ``cols - 4`` cells.
|
||||
title = fitted_title(base, cols - 4 - 2)
|
||||
key = (cols, rows, title, caption, footer_hint, more_above, more_below, *visible)
|
||||
if footer_lane is None and _BASE_BOX_CACHE is not None and _BASE_BOX_CACHE[0] == key:
|
||||
body = _BASE_BOX_CACHE[1]
|
||||
else:
|
||||
panel = _panel_box(base.title, visible, more_above, more_below, "accent", caption)
|
||||
panel = _panel_box(title, visible, more_above, more_below, "accent", caption)
|
||||
body = render_lines(Group(panel, footer_row()), cols)
|
||||
if footer_lane is None:
|
||||
_BASE_BOX_CACHE = (key, body)
|
||||
|
||||
@@ -60,6 +60,11 @@ class Screen:
|
||||
|
||||
Attributes:
|
||||
title: Heading shown at the top of the screen/dialog.
|
||||
short_title: The abbreviated heading the frame draws instead of :attr:`title` where
|
||||
the full one would not fit the rule it sits in (see
|
||||
:func:`~meshterm.ui.tui.frame.fitted_title`) — the same atoms said tighter, so a
|
||||
narrow frame still carries every figure rather than a title cut off mid-word.
|
||||
Empty (the default) means the title has no shorter form.
|
||||
footer_hint: One-line key hint shown in the footer.
|
||||
scroll: Current vertical scroll offset into the body's rendered lines.
|
||||
future: Resolved with the screen's result (or :data:`CANCEL`) when it commits.
|
||||
@@ -104,6 +109,7 @@ class Screen:
|
||||
"""
|
||||
|
||||
title: str = ""
|
||||
short_title: str = ""
|
||||
footer_hint: str = "Esc back"
|
||||
bottom_caption: str = ""
|
||||
floating: bool = True
|
||||
|
||||
+93
-21
@@ -68,10 +68,12 @@ from .spinner import spinner_interval
|
||||
#: walk, because what it names on both is our own node.
|
||||
#:
|
||||
#: Two of these are app-wide rather than a screen's: ``to_menu`` (^W) unwinds the whole
|
||||
#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) leaves
|
||||
#: the app from anywhere. Neither is advertised in a footer hint or an F-key chip — the lane
|
||||
#: has three free slots per screen and a global verb would claim one on every screen forever,
|
||||
#: so both are stated once on the About page's key list instead (JP, 2026-08-30). ^W was
|
||||
#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) asks
|
||||
#: to leave the app from anywhere — a second press while it asks is the leaving (see
|
||||
#: :meth:`TuiSession.request_quit`). Neither is advertised in a footer hint or an F-key
|
||||
#: chip — the lane has three free slots per screen and a global verb would claim one on
|
||||
#: every screen forever (JP, 2026-08-30) — save ``^Q quit?`` on the main menu's own hint,
|
||||
#: where Esc is inert and the reader looks for the way out (issue #22). ^W was
|
||||
#: picked for the close-this-whole-thing reflex; both were verified deliverable on the
|
||||
#: Canadian Multilingual layout (^W ``U+0017``, ^Q ``U+0011``) and neither is eaten by the
|
||||
#: terminal — prompt_toolkit's raw mode clears ``IXON``/``IXOFF``, so ^Q is not XON here.
|
||||
@@ -515,6 +517,11 @@ class TuiSession:
|
||||
# the top rather than pointlessly rebuilding it — and what a popup floats over when
|
||||
# nothing else is pushed (see :meth:`_floated`). ``None`` until the menu declares it.
|
||||
self._root: Screen | None = None
|
||||
# The quit confirm ^Q raises from anywhere (see :meth:`request_quit`), declared by
|
||||
# the app with :meth:`set_quit_confirm`; and whether it is up right now, which is
|
||||
# what turns the next ^Q into the leaving itself.
|
||||
self._quit_confirm: Callable[[], Awaitable[bool]] | None = None
|
||||
self._quit_asking = False
|
||||
|
||||
# --- stack ---------------------------------------------------------------
|
||||
|
||||
@@ -596,6 +603,74 @@ class TuiSession:
|
||||
"""The declared navigation root (the main menu), or ``None`` before it is declared."""
|
||||
return self._root
|
||||
|
||||
def set_quit_confirm(self, ask: Callable[[], Awaitable[bool]] | None) -> None:
|
||||
"""Declare the question the quit chord asks before it leaves.
|
||||
|
||||
The app declares it once, because only the app knows what the question offers —
|
||||
the main menu's confirm adds *Unpair & quit* over a Bluetooth bond, which needs the
|
||||
application context the session never holds. ``ask`` floats its dialog and returns
|
||||
whether to leave; it owns any side effect of the answer (recording an unpair).
|
||||
|
||||
Args:
|
||||
ask: The confirm, or ``None`` to have the quit chord leave without asking.
|
||||
"""
|
||||
self._quit_confirm = ask
|
||||
|
||||
async def confirm_quit(self) -> bool:
|
||||
"""Ask the declared quit confirm, as the main menu's Quit row does.
|
||||
|
||||
The same question ^Q asks, and the same dialog: while it is up the chord's next
|
||||
press leaves outright (see :meth:`request_quit`), whichever door opened it.
|
||||
|
||||
Returns:
|
||||
Whether the reader chose to leave — ``True`` with no confirm declared.
|
||||
"""
|
||||
self._quit_asking = True
|
||||
return await self._asking_quit()
|
||||
|
||||
async def _asking_quit(self) -> bool:
|
||||
"""Run the declared confirm with :attr:`_quit_asking` already raised, then lower it."""
|
||||
try:
|
||||
return self._quit_confirm is None or bool(await self._quit_confirm())
|
||||
finally:
|
||||
self._quit_asking = False
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Answer the quit chord (^Q, and ^C beside it): ask first, leave on the second press.
|
||||
|
||||
^Q sits one key from ^W, the chord that climbs back to the menu, so a slip on the way
|
||||
to the menu used to close the app — and a running capture or a courier queue with
|
||||
it. It now floats the quit confirm over whatever is up, from anywhere (issue #22,
|
||||
option D). The confirm is a detached flow (:meth:`run_detached`): nothing under it
|
||||
is interrupted, a capture keeps capturing behind the box, and Esc puts the reader
|
||||
back exactly where they were.
|
||||
|
||||
While the confirm is up, the chord leaves at once, so the fastest way out is still
|
||||
two keys and still works when a screen is wedged or a device read hangs — the
|
||||
second press never waits on the dialog to paint or take a key. The flag is raised
|
||||
here, synchronously, so two presses arriving in one input batch are still a quit
|
||||
rather than two dialogs.
|
||||
|
||||
Leaving is clean either way: :meth:`run` cancels the coroutine driving the app and
|
||||
waits for its unwind, so the history and chat runs close, the services stop, and the
|
||||
exit watchdog is armed, the same as quitting from the menu.
|
||||
"""
|
||||
if self._quit_asking or self._quit_confirm is None:
|
||||
self._exit_app()
|
||||
return
|
||||
self._quit_asking = True
|
||||
|
||||
async def ask() -> None:
|
||||
if await self._asking_quit():
|
||||
self._exit_app()
|
||||
|
||||
self.run_detached(ask())
|
||||
|
||||
def _exit_app(self) -> None:
|
||||
"""Exit the running application, from under whatever flow is driving it."""
|
||||
if self._app is not None and self._app.is_running:
|
||||
self._app.exit()
|
||||
|
||||
def request_pop_all(self) -> bool:
|
||||
"""Arm the unwind to the navigation root (the ^W key). Returns whether it fired.
|
||||
|
||||
@@ -1603,7 +1678,8 @@ class TuiSession:
|
||||
# touches the screen. Errors from ``main`` still propagate via ``driver``/``box``.
|
||||
await self._app.run_async(pre_run=pre_run, set_exception_handler=False)
|
||||
# The app can also exit from *under* the driver: the quit chords (^Q/^C) call
|
||||
# ``Application.exit`` straight from the key handler, so ``run_async`` returns while
|
||||
# ``Application.exit`` from their detached confirm, or straight from the key handler
|
||||
# on the second press (see request_quit), so ``run_async`` returns while
|
||||
# ``main`` is still parked on whatever screen was up. Left alone, that coroutine is
|
||||
# simply abandoned mid-await and its ``finally`` never runs — which is where the
|
||||
# history and chat runs are closed, the background services stopped, and the exit
|
||||
@@ -1994,8 +2070,8 @@ class TuiSession:
|
||||
just the screen actions: right Ctrl-V pastes into a compose line instead of typing a
|
||||
``v``, right Ctrl-C quits.
|
||||
|
||||
The three session-level actions are answered here rather than forwarded — no screen
|
||||
ever sees ``to_menu``, ``quit`` or ``paste_clipboard``.
|
||||
The session-level actions are answered here rather than forwarded — no screen ever
|
||||
sees ``to_menu``, ``quit``, ``quit_now`` or ``paste_clipboard``.
|
||||
|
||||
The repaint keeps prompt_toolkit's fast differential paint; a frame carrying a glyph
|
||||
the terminal may draw narrower than pt reserves for it (an emoji) upgrades itself to a
|
||||
@@ -2030,20 +2106,16 @@ class TuiSession:
|
||||
self.invalidate()
|
||||
return
|
||||
if action == "quit":
|
||||
# Straight out (^Q/^C), with no confirmation — deliberately, and not an
|
||||
# oversight to be tidied up later. The quit dialog exists to catch *one Esc too
|
||||
# many* at the main menu, where a single reflexive keystroke on the way out of
|
||||
# something would otherwise end the session (JP, 2026-08-30). Neither of these
|
||||
# chords can be pressed by accident, so there is nothing for a confirm to catch
|
||||
# — and both are the escape hatch: a screen that is wedged, or a device read
|
||||
# that is hanging, is exactly when a confirm dialog (itself a screen, which has
|
||||
# to paint and take a key) would be the thing in the way. Leaving is still
|
||||
# clean: ``run`` cancels the coroutine driving the app and waits for its unwind,
|
||||
# so the history and chat runs close, the services stop, and the exit watchdog
|
||||
# is armed, the same as quitting from the menu. What this route does *not* offer
|
||||
# is the dialog's "Unpair & quit" — that stays a deliberate, menu-only choice.
|
||||
if self._app is not None:
|
||||
self._app.exit()
|
||||
# Asks first, from anywhere; a second press while it asks leaves (see
|
||||
# request_quit). "Unpair & quit" rides the confirm the menu declared.
|
||||
self.request_quit()
|
||||
self.invalidate()
|
||||
return
|
||||
if action == "quit_now":
|
||||
# Straight out, no question: the main menu's ``Quit!`` chip, on the Shift half
|
||||
# of the ``Quit?`` it sits behind — a deliberate two-key reach on the one screen
|
||||
# that offers it, never a chord a slip on the way to ^W could land on.
|
||||
self._exit_app()
|
||||
return
|
||||
if action == "paste_clipboard":
|
||||
# Some terminals deliver Ctrl-V as the literal control key — no bracketed-paste
|
||||
|
||||
@@ -50,8 +50,14 @@ class _FakeTransport:
|
||||
self.pin = pin
|
||||
self.link_open = False
|
||||
self.disconnect_calls = 0
|
||||
#: What the peripheral answers a write with: ``None`` accepts it, an exception refuses.
|
||||
self.write_answer: BaseException | None = None
|
||||
_FakeTransport.last = self
|
||||
|
||||
async def _write_locked(self, data) -> None:
|
||||
if self.write_answer is not None:
|
||||
raise self.write_answer
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self.disconnect_calls += 1
|
||||
self.link_open = False
|
||||
@@ -75,8 +81,10 @@ class _FakeMeshCore:
|
||||
|
||||
``outcomes`` drives each successive ``connect()``: an exception instance is raised (with
|
||||
the link already up, as the real GATT failure leaves it), ``"none"`` returns ``None``
|
||||
(transport up, no identity reply), ``"slow"`` hangs for a caller to cancel, and anything
|
||||
else is returned as a successful handshake.
|
||||
(transport up, no identity reply), ``"slow"`` hangs for a caller to cancel, a
|
||||
``("refused", exc)`` pair has the handshake's write refused with ``exc`` — swallowed, as
|
||||
meshcore's ``send`` does, leaving the handshake empty — and anything else is returned as
|
||||
a successful handshake.
|
||||
"""
|
||||
|
||||
#: Every instance built during a test, in order (a retry builds a second one).
|
||||
@@ -100,6 +108,13 @@ class _FakeMeshCore:
|
||||
# The GATT subscribe fails here — after the link is up and *before* the manager
|
||||
# records it. Nothing downstream will close it unless someone reaches the cx.
|
||||
raise outcome
|
||||
if isinstance(outcome, tuple) and outcome[0] == "refused":
|
||||
self.cx.write_answer = outcome[1]
|
||||
try: # meshcore's send: log the failure, return False, and carry on
|
||||
await self.cx._write_locked(b"")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if outcome == "slow":
|
||||
await asyncio.sleep(30) # a handshake the caller's wait_for will cancel
|
||||
self.connection_manager._is_connected = True
|
||||
@@ -204,8 +219,7 @@ def test_a_good_connect_hands_the_client_over_still_open() -> None:
|
||||
def test_the_connection_is_built_from_this_device_s_own_endpoint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Address, discovered ``BLEDevice`` and PIN all reach the connection we construct."""
|
||||
# Pinned off macOS, where the PIN is deliberately withheld (see the test below).
|
||||
"""Address and discovered ``BLEDevice`` reach the connection we construct; the PIN doesn't."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
_FakeMeshCore.reset("ok")
|
||||
sentinel = object()
|
||||
@@ -220,7 +234,7 @@ def test_the_connection_is_built_from_this_device_s_own_endpoint(
|
||||
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
||||
|
||||
cx = _FakeTransport.last
|
||||
assert (cx.address, cx.device, cx.pin) == (_ADDR, sentinel, "000000")
|
||||
assert (cx.address, cx.device, cx.pin) == (_ADDR, sentinel, None)
|
||||
client = _FakeMeshCore.built[0]
|
||||
# auto_reconnect stays off: MeshTerm drives reconnection itself.
|
||||
assert (client.default_timeout, client.auto_reconnect) == (7.5, False)
|
||||
@@ -261,6 +275,72 @@ def test_the_pin_is_withheld_from_bleak_on_linux(monkeypatch: pytest.MonkeyPatch
|
||||
assert dev._pin == "000000"
|
||||
|
||||
|
||||
def test_the_pin_is_withheld_from_bleak_on_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Bleak's WinRT ``pair()`` is Just Works only, so a PIN handed to it bonds *without* one.
|
||||
|
||||
After our own PIN pairing failed on a mistyped PIN, that fallback left Windows holding an
|
||||
unauthenticated bond, and the next attempt — right PIN — reused it and was refused.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
_FakeMeshCore.reset("ok")
|
||||
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
|
||||
|
||||
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
||||
|
||||
assert _FakeTransport.last.pin is None
|
||||
assert dev._pin == "000000"
|
||||
|
||||
|
||||
def test_a_refused_handshake_write_is_a_pairing_problem() -> None:
|
||||
"""A firmware that guards only the write (MeshOS, wadamesh on a T-Deck) is still paired.
|
||||
|
||||
The subscribe succeeds there, and the refusal lands on the handshake's first write, which
|
||||
meshcore's ``send`` swallows. The empty handshake must surface as that refusal — not as
|
||||
"not a companion" — so the pairing, the stale-bond repair and the macOS wait all run.
|
||||
"""
|
||||
refusal = _AuthError("GATT Protocol Error: Insufficient Encryption")
|
||||
_FakeMeshCore.reset(("refused", refusal))
|
||||
dev = _device()
|
||||
|
||||
with pytest.raises(_AuthError) as caught:
|
||||
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
||||
|
||||
assert caught.value is refusal
|
||||
assert _FakeMeshCore.built[0].cx.link_open is False # released before it was raised
|
||||
|
||||
|
||||
def test_a_write_failing_for_another_reason_still_reads_as_no_answer() -> None:
|
||||
"""Only a refusal for want of a bond is promoted; a dropped write stays "no answer"."""
|
||||
_FakeMeshCore.reset(("refused", OSError("device unreachable")))
|
||||
dev = _device()
|
||||
|
||||
assert asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) is None
|
||||
|
||||
|
||||
def test_a_refused_write_heals_through_the_pin_repair(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""End to end on Windows: refused write → unpair and pair with the PIN → connected.
|
||||
|
||||
The T-Deck case exactly: a Just Works bond is trusted on the fast path, the handshake's
|
||||
write is refused over it, and the repair that stock firmware reaches from the subscribe
|
||||
must be reached from the write too.
|
||||
"""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
forced: list[bool] = []
|
||||
|
||||
async def _pair(self, *, force: bool) -> bool:
|
||||
forced.append(force)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(MeshCoreDevice, "_pair_ble", _pair)
|
||||
_FakeMeshCore.reset(("refused", _AuthError("Insufficient Authentication")), "ok")
|
||||
dev = _device(pin="000000")
|
||||
|
||||
client = asyncio.run(dev._create_ble(_FakeMeshCore))
|
||||
|
||||
assert client is _FakeMeshCore.built[1]
|
||||
assert forced == [False, True] # the fast path, then the repair
|
||||
|
||||
|
||||
def test_a_cancelled_handshake_still_closes_the_link() -> None:
|
||||
"""A probe's ``wait_for`` expiring mid-handshake must not leak what it cancelled.
|
||||
|
||||
|
||||
+123
-15
@@ -80,7 +80,7 @@ def test_dashboard_renders_all_three_sections() -> None:
|
||||
screen = _screen(
|
||||
window=[_obs(), _obs(node="3d63", node_type=2, snr=-2.0)],
|
||||
histogram=[3] + [0] * (ACTIVITY_BUCKETS - 1),
|
||||
kinds={"advert": 5, "ack": 1},
|
||||
kinds={"packet:ADVERT": 5, "packet:ACK": 1},
|
||||
)
|
||||
body = _plain(screen.render_body(100))
|
||||
assert "Activity" in body and "Traffic" in body and "RF health" in body
|
||||
@@ -140,28 +140,41 @@ def test_dashboard_pulse_drops_heard_only_when_it_wont_fit() -> None:
|
||||
|
||||
|
||||
def test_dashboard_traffic_breaks_packets_out_by_payload_class() -> None:
|
||||
"""The raw ``packet`` bucket meters per payload class, glossed and iconed."""
|
||||
"""Each frame class meters on its own row, glossed and iconed as the live feed does."""
|
||||
screen = _screen(
|
||||
window=[_obs()],
|
||||
kinds={"advert": 4, "packet:GRP_TXT": 3, "packet:TRACE": 2, "packet": 1},
|
||||
kinds={"packet:GRP_TXT": 3, "packet:TRACE": 2, "packet": 1},
|
||||
)
|
||||
body = _plain(screen.render_body(100))
|
||||
assert "chan text" in body and "trace" in body # glossed, not raw typenames
|
||||
assert "GRP_TXT" not in body
|
||||
# The class-less remainder keeps a plain "packet" row alongside the classed ones.
|
||||
assert "packet" in body
|
||||
# Decoded families before the raw classes, class-less packet closing the block.
|
||||
assert body.index("advert") < body.index("chan text") < body.index("trace")
|
||||
# The class-less remainder keeps a plain "packet" row after the classed ones.
|
||||
assert body.index("chan text") < body.index("trace") < body.rindex("packet")
|
||||
|
||||
|
||||
def test_dashboard_traffic_disambiguates_raw_advert_and_ack_rows() -> None:
|
||||
"""An overheard ADVERT/ACK frame's row reads "raw …" so no two meters match."""
|
||||
screen = _screen(
|
||||
window=[_obs()],
|
||||
kinds={"advert": 4, "packet:ADVERT": 2, "ack": 3, "packet:ACK": 1},
|
||||
)
|
||||
body = _plain(screen.render_body(100))
|
||||
assert "raw advert" in body and "raw ack" in body
|
||||
def test_dashboard_traffic_counts_each_frame_once_under_the_feeds_names() -> None:
|
||||
"""The device's own reports stay out: an advert is its ADVERT frame, counted once.
|
||||
|
||||
The monitor tallies the advert *event* and the RX-logged frame it arrived in, and a
|
||||
decoded message beside its ``chan text`` frame; counting both lists one packet twice.
|
||||
The frame's row takes the live feed's name — ``advert``, never a "raw" variant.
|
||||
"""
|
||||
from meshterm.ui.packet_viewer import payload_marks
|
||||
|
||||
kinds = {
|
||||
"advert": 40,
|
||||
"telemetry": 9,
|
||||
"message": 7,
|
||||
"ack": 30,
|
||||
"packet:ADVERT": 2,
|
||||
"packet:ACK": 1,
|
||||
}
|
||||
lines = _plain(_screen(window=[_obs()], kinds=kinds).render_body(100)).splitlines()
|
||||
start = next(i for i, ln in enumerate(lines) if ln.startswith("Traffic"))
|
||||
rows = lines[start + 1 : lines.index("", start)]
|
||||
assert [r.split()[1:3] for r in rows] == [["advert", "2"], ["ack", "1"]]
|
||||
assert not any("raw" in r or "telemetry" in r or "message" in r for r in rows)
|
||||
assert payload_marks("ADVERT")[1] == "advert" and payload_marks(None)[1] == "packet"
|
||||
|
||||
|
||||
def test_dashboard_live_observations_land_in_the_window() -> None:
|
||||
@@ -405,3 +418,98 @@ def test_the_chart_is_redrawn_when_the_terminal_width_changes() -> None:
|
||||
narrow = screen._chart_memo
|
||||
screen.render_body(100)
|
||||
assert screen._chart_memo is not narrow
|
||||
|
||||
|
||||
def _flood(region: str | None, typename: str = "GRP_TXT", snr: float = 4.0) -> Observation:
|
||||
"""A packet frame whose raw payload names its scope the way ``_fake_scope_of`` reads it.
|
||||
|
||||
``region`` is a region name, ``""`` for an unscoped flood, ``"?"`` for a scope no known
|
||||
name reproduces, and ``None`` for a direct frame, which has no scope at all.
|
||||
"""
|
||||
raw = {"payload_typename": typename, "fake_scope": region}
|
||||
return _obs(node=None, kind="packet", snr=snr, raw=raw)
|
||||
|
||||
|
||||
def _fake_scope_of(raw): # noqa: ANN001, ANN202 - the RegionStore.scope_of shape
|
||||
from meshterm.core.regions import UNSCOPED, Scope
|
||||
|
||||
region = (raw or {}).get("fake_scope")
|
||||
if region is None:
|
||||
return None
|
||||
if region == "":
|
||||
return UNSCOPED
|
||||
if region == "?":
|
||||
return Scope("unknown", None, "3fa1")
|
||||
return Scope("scoped", region, "beef")
|
||||
|
||||
|
||||
def _scoped_screen(window) -> DashboardScreen:
|
||||
screen = DashboardScreen(
|
||||
session=_FakeSession(),
|
||||
resolve=lambda h: "",
|
||||
window=list(window),
|
||||
activity=lambda: (0,) * ACTIVITY_BUCKETS,
|
||||
activity_flags=lambda: (True,) * ACTIVITY_BUCKETS,
|
||||
kind_counts=lambda: {"packet:GRP_TXT": 50, "packet:ACK": 9},
|
||||
scope_of=_fake_scope_of,
|
||||
)
|
||||
screen.note_viewport(48)
|
||||
return screen
|
||||
|
||||
|
||||
def test_the_scope_cycle_steps_through_every_scope_heard_and_back_to_all() -> None:
|
||||
"""``s`` cycles all → unscoped → regions A–Z → unknown → all, retitling each view.
|
||||
|
||||
Direct frames add no view: they have no scope, so they are counted only under all.
|
||||
"""
|
||||
screen = _scoped_screen(
|
||||
[_flood("yul"), _flood(""), _flood("?"), _flood("mtl"), _flood(None, "TEXT_MSG")]
|
||||
)
|
||||
assert screen.title == "Dashboard — mesh overview" and "s scope" in screen.footer_hint
|
||||
seen = []
|
||||
for _ in range(5):
|
||||
screen.handle("text", "s")
|
||||
seen.append(screen.title.removeprefix("Dashboard — mesh overview"))
|
||||
assert seen == [
|
||||
" · unscoped",
|
||||
" · scope mtl",
|
||||
" · scope yul",
|
||||
" · unknown scope",
|
||||
"",
|
||||
]
|
||||
screen.handle("scope") # the F3 chip's action is the same cycle
|
||||
assert screen.short_title == "Dashboard · unscoped"
|
||||
|
||||
|
||||
def test_a_scope_view_narrows_traffic_and_reception_to_its_floods() -> None:
|
||||
"""Traffic and RF read the view's frames; the all view keeps the stored tallies."""
|
||||
screen = _scoped_screen(
|
||||
[
|
||||
_flood("yul", "GRP_TXT", snr=8.0),
|
||||
_flood("yul", "TRACE", snr=8.0),
|
||||
_flood("mtl", "GRP_TXT", snr=-9.0),
|
||||
_flood(None, "ACK", snr=-9.0),
|
||||
]
|
||||
)
|
||||
screen.handle("text", "s") # mtl (no unscoped floods in this window)
|
||||
screen.handle("text", "s") # yul
|
||||
assert screen.title.endswith("scope yul")
|
||||
body = _plain(screen.render_body(100))
|
||||
assert "floods in the last 2 h" in body
|
||||
traffic = body[body.index("Traffic") : body.index("RF health")]
|
||||
assert "chan text" in traffic and "trace" in traffic and " ack " not in traffic
|
||||
assert "+8.0 dB median" in body # mtl's -9 and the direct ACK's stay out
|
||||
assert "nodes" not in body and "busiest" not in body # adverts carry no scope
|
||||
|
||||
screen.handle("text", "s") # back to all
|
||||
body = _plain(screen.render_body(100))
|
||||
assert "stored history + live" in body and "50" in body
|
||||
|
||||
|
||||
def test_no_scope_view_is_offered_without_a_flood_to_narrow_to() -> None:
|
||||
"""A window of direct frames only offers nothing to cycle: no hint atom, no chip."""
|
||||
screen = _scoped_screen([_flood(None, "TEXT_MSG")])
|
||||
assert "scope" not in screen.footer_hint
|
||||
assert screen.fkey_lane[2] is None or not getattr(screen.fkey_lane[2], "label", "")
|
||||
screen.handle("text", "s")
|
||||
assert screen.title == "Dashboard — mesh overview"
|
||||
@@ -1074,6 +1074,32 @@ def test_the_archived_row_appears_only_when_something_is_archived() -> None:
|
||||
assert _ARCHIVE not in values and values[-1] == _ARCHIVED
|
||||
|
||||
|
||||
def test_contacts_title_counts_the_archived_and_abbreviates_where_narrow() -> None:
|
||||
"""The title carries both tallies, and drops the words before it would drop a figure.
|
||||
|
||||
Nothing archived keeps the plain ``… known`` title with no short form; with some, the
|
||||
full ``198 known + 123 archived`` draws wherever it fits and ``198 + 123`` where the
|
||||
rule is too narrow — on both frames, the bordered panel and the one-row title bar.
|
||||
"""
|
||||
from meshterm.core.models import Contact
|
||||
from meshterm.ui.contacts_screen import ContactsScreen
|
||||
from meshterm.ui.tui.frame import _title_bar, fitted_title
|
||||
|
||||
contacts = [Contact(name=f"n{i}", public_key=f"{i:02x}" * 32) for i in range(198)]
|
||||
|
||||
plain = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort())
|
||||
assert plain.title == "Contacts · 198 known" and not plain.short_title
|
||||
|
||||
both = ContactsScreen("Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), archived=123)
|
||||
assert both.title == "Contacts · 198 known + 123 archived"
|
||||
assert both.short_title == "Contacts · 198 + 123"
|
||||
assert fitted_title(both, 72) == both.title
|
||||
assert fitted_title(both, 30) == both.short_title
|
||||
|
||||
assert "198 known + 123 archived" in _title_bar(both, 53, False, False).plain
|
||||
assert "Contacts · 198 + 123 " in _title_bar(both, 40, False, False).plain
|
||||
|
||||
|
||||
async def test_archiving_one_contact_takes_it_off_the_device_and_keeps_it() -> None:
|
||||
"""The single-contact archive: an amber confirm, off the radio, stamped in the store.
|
||||
|
||||
|
||||
@@ -1038,6 +1038,72 @@ async def test_wait_for_disconnect_ignores_the_simulator(tmp_path: Path) -> None
|
||||
ctx.repo.close()
|
||||
|
||||
|
||||
async def test_wait_for_disconnect_fires_on_an_announced_reboot(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A reboot we sent fires the watcher at once, though the port never goes away.
|
||||
|
||||
A board behind a USB-UART bridge keeps its port through a reboot, so the poll alone
|
||||
would never see the drop; the announcement is the evidence.
|
||||
"""
|
||||
ctx = _make_ctx(tmp_path)
|
||||
ctx.mock = False
|
||||
ctx._device = _FakeSerialDevice("COM_TEST")
|
||||
ctx._active_transport = "serial"
|
||||
ctx._active_port = "COM_TEST"
|
||||
monkeypatch.setattr(connection, "serial_port_present", lambda port: True) # never leaves
|
||||
try:
|
||||
watcher = asyncio.ensure_future(menu._wait_for_disconnect(ctx))
|
||||
await asyncio.sleep(0)
|
||||
assert not watcher.done()
|
||||
ctx.announce_reboot()
|
||||
await asyncio.wait_for(watcher, timeout=0.5) # well inside one liveness poll
|
||||
assert ctx.take_reboot() is True
|
||||
assert ctx.take_reboot() is False # consumed: the next drop reads as an unplug
|
||||
with pytest.raises(asyncio.TimeoutError): # and the next watcher waits for one
|
||||
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=0.2)
|
||||
finally:
|
||||
ctx.repo.close()
|
||||
|
||||
|
||||
async def test_reboot_does_not_wait_for_an_acknowledgement_that_never_comes() -> None:
|
||||
"""A reboot write the board never acknowledges returns at once, not at link loss.
|
||||
|
||||
Over Bluetooth the write is a write-with-response, and a board that restarts on the
|
||||
command can drop the link before acknowledging it; waiting that out held the reboot
|
||||
dialog back by the supervision timeout.
|
||||
"""
|
||||
ended = asyncio.Event()
|
||||
|
||||
async def never_acknowledged() -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
ended.set()
|
||||
|
||||
device = connection.MeshCoreDevice(port="mock")
|
||||
device._mc = SimpleNamespace(commands=SimpleNamespace(reboot=never_acknowledged))
|
||||
await asyncio.wait_for(device.reboot(), timeout=1.0)
|
||||
assert len(connection._REBOOT_WRITES) == 1 # still pending, and still referenced
|
||||
for write in list(connection._REBOOT_WRITES):
|
||||
write.cancel() # the teardown that follows a reboot
|
||||
await asyncio.wait_for(ended.wait(), timeout=1.0)
|
||||
await asyncio.sleep(0)
|
||||
assert not connection._REBOOT_WRITES
|
||||
|
||||
|
||||
async def test_reboot_still_raises_a_write_that_fails_outright() -> None:
|
||||
"""Returning early is for a write left hanging; one that fails at once still raises."""
|
||||
|
||||
async def refused() -> None:
|
||||
raise RuntimeError("not connected")
|
||||
|
||||
device = connection.MeshCoreDevice(port="mock")
|
||||
device._mc = SimpleNamespace(commands=SimpleNamespace(reboot=refused))
|
||||
with pytest.raises(RuntimeError):
|
||||
await device.reboot()
|
||||
|
||||
|
||||
class _FakeBleDevice:
|
||||
"""A minimal stand-in for a connected BLE :class:`Device` in liveness tests.
|
||||
|
||||
|
||||
+58
-2
@@ -177,10 +177,38 @@ def _dashboard(cols: int, rows: int) -> Screen:
|
||||
],
|
||||
activity=lambda: (2.0,) * ACTIVITY_BUCKETS,
|
||||
activity_flags=lambda: (True,) * ACTIVITY_BUCKETS,
|
||||
kind_counts=lambda: {"advert": 5, "ack": 2, "packet": 3},
|
||||
kind_counts=lambda: {"packet:ADVERT": 5, "packet:ACK": 2, "packet": 3},
|
||||
)
|
||||
|
||||
|
||||
def _dashboard_scoped(cols: int, rows: int) -> Screen:
|
||||
"""The dashboard narrowed to one region: its title, its F3 chip, its scoped sections.
|
||||
|
||||
The region is named long on purpose, so the title needs its short form on the console.
|
||||
"""
|
||||
from meshterm.core.regions import Scope
|
||||
|
||||
def scope_of(raw): # noqa: ANN001, ANN202 - the RegionStore.scope_of shape
|
||||
return Scope("scoped", "laurentides-nord", "beef") if raw else None
|
||||
|
||||
frame = {"payload_typename": "GRP_TXT", "route_typename": "TC_FLOOD"}
|
||||
screen = DashboardScreen(
|
||||
session=_GallerySession(cols, rows),
|
||||
resolve=lambda h: "",
|
||||
window=[
|
||||
Observation(
|
||||
node=None, kind="packet", snr=3.5, rssi=-98.0, observed_at=utcnow(), raw=dict(frame)
|
||||
)
|
||||
],
|
||||
activity=lambda: (2.0,) * ACTIVITY_BUCKETS,
|
||||
activity_flags=lambda: (True,) * ACTIVITY_BUCKETS,
|
||||
kind_counts=lambda: {"packet:GRP_TXT": 5},
|
||||
scope_of=scope_of,
|
||||
)
|
||||
screen.handle("scope")
|
||||
return screen
|
||||
|
||||
|
||||
def _contacts(cols: int, rows: int) -> Screen:
|
||||
sort = ContactsSort.from_name("name", SORT_COLUMNS, SORT_OPENS_ASCENDING)
|
||||
contacts = [
|
||||
@@ -784,6 +812,32 @@ def _walk_topo() -> tuple[MeshTopology, dict[str, Contact]]:
|
||||
return topo, {hub_id: hub, far_id: far}
|
||||
|
||||
|
||||
def _timemachine_scoped(cols: int, rows: int) -> Screen:
|
||||
"""The whole-mesh page narrowed to one scope: its title, its F2 chip, its charts.
|
||||
|
||||
The region is named long on purpose, so the title needs its short form on the console.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from meshterm.ui.timemachine_screen import _mesh_scope_sections
|
||||
|
||||
now = utcnow()
|
||||
stamps = [now - timedelta(hours=h) for h in (150, 90, 60, 30, 20, 5, 2)]
|
||||
|
||||
def build(window, width, scope): # noqa: ANN001, ANN202
|
||||
return _mesh_scope_sections(stamps, window, width) if scope else [Text("page")]
|
||||
|
||||
screen = TimeMachineScreen(
|
||||
session=_GallerySession(cols, rows),
|
||||
label="the whole mesh",
|
||||
build=build,
|
||||
scopes=lambda _window: {("unscoped",), ("region", "laurentides-nord")},
|
||||
)
|
||||
screen.handle("scope")
|
||||
screen.handle("scope")
|
||||
return screen
|
||||
|
||||
|
||||
def _walk(cols: int, rows: int) -> Screen:
|
||||
topo, contacts = _walk_topo()
|
||||
return WalkScreen(
|
||||
@@ -795,7 +849,7 @@ def _walk(cols: int, rows: int) -> Screen:
|
||||
|
||||
|
||||
def _timemachine(cols: int, rows: int) -> Screen:
|
||||
def build(window, width): # noqa: ANN001
|
||||
def build(window, width, scope): # noqa: ANN001
|
||||
return [Text("Hilltop-Repeater 5m ago advert"), Text("Alice 12m ago packet")]
|
||||
|
||||
return TimeMachineScreen(
|
||||
@@ -1338,6 +1392,7 @@ def _share_qr(cols: int, rows: int) -> Screen:
|
||||
|
||||
_ENTRIES: list[_Entry] = [
|
||||
_Entry("dashboard", _dashboard),
|
||||
_Entry("dashboard-scoped", _dashboard_scoped),
|
||||
_Entry("contacts", _contacts),
|
||||
_Entry("archive_ladder", _archive_ladder),
|
||||
_Entry("archive_preview", _archive_preview),
|
||||
@@ -1359,6 +1414,7 @@ _ENTRIES: list[_Entry] = [
|
||||
_Entry("livefeed_scopes", _livefeed_scopes),
|
||||
_Entry("walk", _walk),
|
||||
_Entry("timemachine", _timemachine),
|
||||
_Entry("timemachine-scoped", _timemachine_scoped),
|
||||
_Entry("message_paths", _message_paths),
|
||||
_Entry("message_paths_scoped", _message_paths_scoped),
|
||||
_Entry("message_paths_unknown_scope", _message_paths_unknown_scope),
|
||||
|
||||
+17
-16
@@ -62,11 +62,15 @@ def test_returning_to_dim_restates_the_colour_in_the_dim_bank() -> None:
|
||||
assert "\x1b[22;31m" in out, out.replace("\x1b", "ESC")
|
||||
|
||||
|
||||
def test_a_reset_takes_the_standing_colour_with_it() -> None:
|
||||
"""After `0m` there is no colour in force, so a later `1m` has nothing to restate."""
|
||||
out = _state_intensity("\x1b[0;36mcyan\x1b[0m\x1b[1mplain")
|
||||
tail = out.split("plain")[0].split("\x1b[0m")[-1]
|
||||
assert _brights(tail) == 0, out.replace("\x1b", "ESC")
|
||||
def test_bright_after_a_reset_is_white() -> None:
|
||||
"""After `0m` the colour in force is the console's default grey, so `1m` means white.
|
||||
|
||||
The splash's letters say white exactly this way (`0m` then `1m`); read as "no colour to
|
||||
restate", the run fell to the terminal's own default and a white row drew grey.
|
||||
"""
|
||||
out = _state_intensity("\x1b[0;36mcyan\x1b[0m\x1b[1mwhite")
|
||||
tail = out.split("white")[0].split("\x1b[0m")[-1]
|
||||
assert "\x1b[97m" in tail, out.replace("\x1b", "ESC")
|
||||
|
||||
|
||||
def test_a_named_foreground_is_still_rewritten_in_place() -> None:
|
||||
@@ -88,25 +92,22 @@ def test_no_splash_row_asks_a_terminal_for_bold() -> None:
|
||||
|
||||
|
||||
def test_no_bright_run_in_the_art_is_silently_dropped() -> None:
|
||||
"""A `1m` with a colour standing must leave a bright-bank foreground behind.
|
||||
"""Every `1m` must leave a bright-bank foreground behind.
|
||||
|
||||
A colour is always standing: the one last named, or after a reset the console's default
|
||||
grey — which a bare `1m` lifts to white, the case the splash's lettering is drawn in.
|
||||
|
||||
Counting brights across a file cannot catch this -- one `1m` brightens every later
|
||||
sequence, so the totals stay comfortably high while individual runs go missing. The
|
||||
invariant is per sequence: walk the source keeping track of whether a colour is in
|
||||
force, and every sequence that turns brightness on while one is must produce a
|
||||
bright-bank emission in the rewritten row.
|
||||
invariant is per sequence: every sequence in the source that turns brightness on must
|
||||
produce a bright-bank emission in the rewritten row.
|
||||
"""
|
||||
for art in sorted(_SPLASH.glob("*.ans")):
|
||||
raw = art.read_bytes().decode("cp437", errors="replace")
|
||||
for number, line in enumerate(raw.split("\n"), start=1):
|
||||
standing, owed = None, 0
|
||||
owed = 0
|
||||
for parts in _params(line):
|
||||
for part in parts:
|
||||
if part in ("", "0"):
|
||||
standing = None
|
||||
elif re.fullmatch(r"3[0-7]", part):
|
||||
standing = part
|
||||
if "1" in parts and standing is not None:
|
||||
if "1" in parts:
|
||||
owed += 1
|
||||
if owed:
|
||||
assert _brights(_state_intensity(line)) >= owed, (
|
||||
|
||||
+151
-2
@@ -866,6 +866,150 @@ async def test_quit_from_a_nested_screen_still_runs_the_apps_teardown() -> None:
|
||||
assert torn_down == ["services closed"]
|
||||
|
||||
|
||||
def _asking_session(inp, asked: list[str], answer: str | None = None):
|
||||
"""A session whose quit chord floats a Cancel/Quit confirm, the way the menu declares it.
|
||||
|
||||
``answer`` resolves the dialog as soon as it is pushed (``None`` leaves it up for the
|
||||
test to drive); ``asked`` records each time the question is put.
|
||||
"""
|
||||
session = _session(inp)
|
||||
|
||||
async def ask() -> bool:
|
||||
asked.append("asked")
|
||||
dialog = ButtonDialog("Quit?", [("Cancel", "cancel"), ("Quit", "quit")], default=1)
|
||||
if answer is not None:
|
||||
asyncio.get_running_loop().call_soon(dialog.resolve, answer)
|
||||
return await session.run_dialog(dialog) == "quit"
|
||||
|
||||
session.set_quit_confirm(ask)
|
||||
return session
|
||||
|
||||
|
||||
async def test_quit_asks_first_and_cancelling_leaves_the_flow_where_it_was() -> None:
|
||||
"""One ^Q floats the confirm over whatever is up; Cancel puts the reader back.
|
||||
|
||||
The screen under it is never resolved or popped — a capture behind the box keeps
|
||||
capturing — which is the whole difference from the old straight-out chord (issue #22).
|
||||
"""
|
||||
with create_pipe_input() as inp:
|
||||
asked: list[str] = []
|
||||
session = _asking_session(inp, asked, answer="cancel")
|
||||
screen = ScrollScreen("a capture in progress")
|
||||
|
||||
async def main() -> None:
|
||||
session.push(screen)
|
||||
session._dispatch("quit")
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
assert asked == ["asked"]
|
||||
assert session.top is screen, "Cancel lands back on the screen it floated over"
|
||||
assert not session._quit_asking
|
||||
session.pop(screen)
|
||||
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
assert screen.future is None or not screen.future.done()
|
||||
|
||||
|
||||
async def test_a_second_quit_while_the_confirm_is_up_leaves_at_once() -> None:
|
||||
"""^Q ^Q is the fast way out, and it never waits on the dialog to take a key."""
|
||||
with create_pipe_input() as inp:
|
||||
asked: list[str] = []
|
||||
session = _asking_session(inp, asked)
|
||||
torn_down: list[str] = []
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
await session.run_screen(ScrollScreen("deep"))
|
||||
finally:
|
||||
torn_down.append("services closed")
|
||||
|
||||
# Both presses in one batch: the flag is raised synchronously, so the second is
|
||||
# the leaving rather than a second dialog.
|
||||
inp.send_text(QUIT_KEY + QUIT_KEY)
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
assert torn_down == ["services closed"]
|
||||
assert len(asked) <= 1
|
||||
|
||||
|
||||
async def test_quit_confirmed_from_anywhere_exits_the_app() -> None:
|
||||
"""Choosing Quit in the floated confirm exits, and the flow's teardown still runs."""
|
||||
with create_pipe_input() as inp:
|
||||
asked: list[str] = []
|
||||
session = _asking_session(inp, asked, answer="quit")
|
||||
torn_down: list[str] = []
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
await session.run_screen(ScrollScreen("deep"))
|
||||
finally:
|
||||
torn_down.append("services closed")
|
||||
|
||||
inp.send_text(QUIT_KEY)
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
assert asked == ["asked"]
|
||||
assert torn_down == ["services closed"]
|
||||
|
||||
|
||||
def test_esc_at_the_main_menu_does_nothing_but_peel_a_filter() -> None:
|
||||
"""Esc-mashing up to the menu settles there instead of opening the quit confirm."""
|
||||
from meshterm.ui.menu import _MainMenu
|
||||
|
||||
menu = _MainMenu(
|
||||
"menu",
|
||||
[Choice("alpha", 1), Choice("beta", 2)],
|
||||
footer_hint="↑↓ move · type to filter · Enter select · ^Q quit?",
|
||||
)
|
||||
menu.future = asyncio.new_event_loop().create_future()
|
||||
menu.handle("escape")
|
||||
assert not menu.future.done(), "a bare Esc at the menu resolves nothing"
|
||||
assert not menu.footer_hint.endswith("Esc clear")
|
||||
menu.handle("text", "b")
|
||||
assert menu.footer_hint.endswith("^Q quit? · Esc clear")
|
||||
menu.handle("escape")
|
||||
assert menu._filter == "" and not menu.future.done()
|
||||
assert menu.footer_hint.endswith("^Q quit?")
|
||||
|
||||
|
||||
def test_the_main_menu_lane_puts_the_way_out_on_f3() -> None:
|
||||
"""``Quit?`` on F3 asks; its Shift half, F8, is ``Quit!``, which doesn't."""
|
||||
from meshterm.ui.menu import _MainMenu
|
||||
from meshterm.ui.menus import section_heading
|
||||
from meshterm.ui.tui.fkeys import action_for
|
||||
|
||||
menu = _MainMenu(
|
||||
"menu",
|
||||
[section_heading("A"), Choice("alpha", 1), section_heading("B"), Choice("beta", 2)],
|
||||
)
|
||||
lane = menu.fkey_lane
|
||||
assert (lane[2].label, lane[2].opp_label) == ("Quit?", "Quit!")
|
||||
assert action_for(lane, 3) == "quit"
|
||||
assert action_for(lane, 8) == "quit_now"
|
||||
assert lane[0].label == "Sect ↑", "the section jumps keep F1/F2"
|
||||
|
||||
|
||||
async def test_quit_now_leaves_without_asking() -> None:
|
||||
"""F8's ``quit_now`` exits even with a confirm declared, and the teardown still runs."""
|
||||
with create_pipe_input() as inp:
|
||||
asked: list[str] = []
|
||||
session = _asking_session(inp, asked)
|
||||
torn_down: list[str] = []
|
||||
|
||||
async def main() -> None:
|
||||
try:
|
||||
await session.run_screen(ScrollScreen("menu"))
|
||||
finally:
|
||||
torn_down.append("services closed")
|
||||
|
||||
async def press() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
session._dispatch("quit_now")
|
||||
|
||||
asyncio.ensure_future(press())
|
||||
await asyncio.wait_for(session.run(main()), timeout=5)
|
||||
assert asked == []
|
||||
assert torn_down == ["services closed"]
|
||||
|
||||
|
||||
# --- the bindings themselves -------------------------------------------------
|
||||
|
||||
|
||||
@@ -885,10 +1029,12 @@ def test_both_global_chords_are_bound_on_both_ctrl_keys() -> None:
|
||||
|
||||
|
||||
def test_neither_global_chord_is_advertised_anywhere() -> None:
|
||||
"""Both are deliberately undiscoverable in the UI — the About page states them instead.
|
||||
"""Both are deliberately undiscoverable in the UI, bar the menu's own way out.
|
||||
|
||||
A global verb has no screen to belong to, so a footer atom or an F-key chip would have to
|
||||
ride on *every* screen; the lane only has three free slots per screen to begin with.
|
||||
ride on *every* screen; the lane only has three free slots per screen to begin with. The
|
||||
one exception is the main menu's hint, which names ^Q because Esc is inert there and the
|
||||
menu is where a reader looks for the way out (issue #22) — exactly one string, there.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
@@ -919,6 +1065,9 @@ def test_neither_global_chord_is_advertised_anywhere() -> None:
|
||||
and chord.search(node.value)
|
||||
):
|
||||
offenders.append(f"{path.name}:{node.lineno}: {node.value.strip()[:70]}")
|
||||
menu_hint = [o for o in offenders if o.startswith("menu.py:") and o.endswith("· ^Q quit?")]
|
||||
assert len(menu_hint) == 1, "the main menu's hint must name the quit chord, once"
|
||||
offenders.remove(menu_hint[0])
|
||||
assert not offenders, "the global chords must not appear in any hint or chip:\n" + "\n".join(
|
||||
offenders
|
||||
)
|
||||
|
||||
@@ -131,12 +131,28 @@ def test_the_clip_arrows_are_always_drawn_and_say_it_in_colour() -> None:
|
||||
|
||||
def test_the_bar_speaks_the_screen_s_own_esc_verb() -> None:
|
||||
"""Lifted off the screen's own footer hint, so each surface keeps its true verb."""
|
||||
assert _bar("MeshTerm", "↑↓ move · Enter open · Esc quit").endswith("Esc quit")
|
||||
assert _bar("Contacts", "↑↓ move · Enter open · Esc back").endswith("Esc back")
|
||||
assert _bar("Path width", "↑↓ move · Enter set · Esc keep").endswith("Esc keep")
|
||||
# A hint that names no way out advertises none — the lane's rule, one row up.
|
||||
assert "Esc" not in _bar("Working", "↑↓ move")
|
||||
|
||||
|
||||
def test_the_main_menu_s_way_out_is_the_quit_chord_until_a_filter_stands() -> None:
|
||||
"""Esc is inert at the menu, so the bar carries ^Q there — and Esc clear over it.
|
||||
|
||||
The menu's hint ends on ``^Q quit?`` rather than an Esc clause (issue #22); the tail
|
||||
lifts whichever way out the hint ends on, so a typed filter's ``Esc clear`` wins while
|
||||
it stands, because that is the key that does something then.
|
||||
"""
|
||||
title = "What would you like to do?"
|
||||
idle = "↑↓ move · type to filter · Enter select · ^Q quit?"
|
||||
assert _bar(title, idle).endswith(" ^Q quit?")
|
||||
assert _bar(title, f"{idle} · Esc clear").endswith(" Esc clear")
|
||||
# Crowded, it gives way the way Esc does: its bare key first, never a stray "Esc".
|
||||
verbless = _bar("Trace — Hilltop-Repeater over a spec", "↑↓ move · ^Q quit?")
|
||||
assert verbless.rstrip().endswith(" ^Q") and "Esc" not in verbless
|
||||
|
||||
|
||||
def test_the_esc_hint_gives_way_before_it_crowds_the_title() -> None:
|
||||
"""Compact by construction: the verb goes first, then the atom, and the title stays.
|
||||
|
||||
|
||||
@@ -411,8 +411,9 @@ def _channel_header(scopes: dict[int, str]) -> str:
|
||||
|
||||
def test_the_channel_list_draws_scope_only_when_a_channel_has_one() -> None:
|
||||
"""A column of blanks says nothing; one scoped channel brings the lane back."""
|
||||
assert _channel_header({}).split() == ["CHANNEL", "UNREAD", "LAST", "MSGS", "ACTIVITY"]
|
||||
assert _channel_header({}).split() == ["SLOT", "CHANNEL", "UNREAD", "LAST", "MSGS", "ACTIVITY"]
|
||||
assert _channel_header({1: "harbour"}).split() == [
|
||||
"SLOT",
|
||||
"CHANNEL",
|
||||
"SCOPE",
|
||||
"UNREAD",
|
||||
|
||||
+132
-2
@@ -13,6 +13,8 @@ from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.core.models import Observation, utcnow
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.ui.braillechart import GAP
|
||||
@@ -954,7 +956,7 @@ def test_screen_cycles_windows_and_caches(tmp_path: Path) -> None:
|
||||
"""`w` moves to the next window (retitling) and each window renders once."""
|
||||
calls: list = []
|
||||
|
||||
def build(window, width):
|
||||
def build(window, width, scope):
|
||||
calls.append(window)
|
||||
from rich.text import Text
|
||||
|
||||
@@ -988,7 +990,9 @@ def test_picocalc_window_ring_stops_at_30_days() -> None:
|
||||
from meshterm.platforms import PICOCALC, set_platform
|
||||
|
||||
set_platform(PICOCALC)
|
||||
screen = TimeMachineScreen(session=_FakeSession(), label="Hub", build=lambda window, width: [])
|
||||
screen = TimeMachineScreen(
|
||||
session=_FakeSession(), label="Hub", build=lambda window, width, scope: []
|
||||
)
|
||||
assert "7 d" in screen.title # opens on 7 d, as everywhere
|
||||
screen.handle("text", "w")
|
||||
assert "30 d" in screen.title
|
||||
@@ -1212,3 +1216,129 @@ def test_picker_hash_lane_shows_a_captured_full_key_without_a_device() -> None:
|
||||
screen = _picker([(node, "Rep")], width=100)
|
||||
row = next(c for c in screen._choices() if c.value == (stored, "Rep"))
|
||||
assert stored + "cd" * 10 in row.label.plain
|
||||
|
||||
|
||||
# -- the scope cycle ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _flood_repo(tmp_path: Path) -> Repository:
|
||||
"""Stored floods under three scopes, plus a direct frame that has none.
|
||||
|
||||
Two ``yul`` floods, one unscoped, one scoped to a region nobody here knows (``qc``, left
|
||||
out of :func:`_scope_ctx`'s names), and a direct message — each a real frame whose code
|
||||
the repository must carry through storage for its region to be named again.
|
||||
"""
|
||||
from tests.test_scope_display import _DIRECT, _FLOOD, _scoped
|
||||
|
||||
repo = Repository(tmp_path / "floods.db")
|
||||
run = repo.start_run("monitor", {}, None)
|
||||
now = utcnow()
|
||||
for hours_ago, raw in (
|
||||
(30, _scoped("yul")),
|
||||
(5, _scoped("yul")),
|
||||
(4, _FLOOD),
|
||||
(3, _scoped("qc")),
|
||||
(2, _DIRECT),
|
||||
):
|
||||
repo.record_observation(
|
||||
run,
|
||||
Observation(
|
||||
node=None,
|
||||
kind="packet",
|
||||
path="",
|
||||
observed_at=now - timedelta(hours=hours_ago),
|
||||
raw=dict(raw),
|
||||
),
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
def _scope_ctx(repo: Repository) -> SimpleNamespace:
|
||||
from tests.test_scope_display import _knows
|
||||
|
||||
return SimpleNamespace(repo=repo, region_store=SimpleNamespace(scope_of=_knows("yul", "mtl")))
|
||||
|
||||
|
||||
def test_flood_frames_come_back_with_what_their_scope_is_read_from(tmp_path: Path) -> None:
|
||||
"""Floods only, oldest first, each still resolvable to its region after storage."""
|
||||
from tests.test_scope_display import _knows
|
||||
|
||||
repo = _flood_repo(tmp_path)
|
||||
frames = repo.flood_frames()
|
||||
assert [raw["route_typename"] for _when, raw in frames] == [
|
||||
"TC_FLOOD",
|
||||
"TC_FLOOD",
|
||||
"FLOOD",
|
||||
"TC_FLOOD",
|
||||
]
|
||||
assert _knows("yul")(frames[0][1]).region == "yul"
|
||||
assert len(repo.flood_frames(since=utcnow() - timedelta(hours=24))) == 3
|
||||
repo.close()
|
||||
|
||||
|
||||
def test_the_mesh_page_offers_each_scope_its_window_holds(tmp_path: Path) -> None:
|
||||
"""The ring is the window's: a 24 h window holds one yul flood, not the 30 h old one."""
|
||||
from meshterm.ui.timemachine_screen import _MeshFloods
|
||||
|
||||
floods = _MeshFloods(_scope_ctx(_flood_repo(tmp_path)))
|
||||
assert floods.keys(timedelta(days=1)) == {("unscoped",), ("region", "yul"), ("unknown",)}
|
||||
assert len(floods.stamps(timedelta(days=7), ("region", "yul"))) == 2
|
||||
assert len(floods.stamps(timedelta(days=1), ("region", "yul"))) == 1
|
||||
|
||||
|
||||
def test_s_cycles_the_mesh_pages_scope_and_retitles_it(tmp_path: Path) -> None:
|
||||
"""All → unscoped → yul → unknown → all, each view rendered for its own scope."""
|
||||
from meshterm.ui.timemachine_screen import _MeshFloods
|
||||
|
||||
floods = _MeshFloods(_scope_ctx(_flood_repo(tmp_path)))
|
||||
built: list = []
|
||||
|
||||
def build(window, width, scope): # noqa: ANN001, ANN202
|
||||
built.append(scope)
|
||||
return [Text("page")]
|
||||
|
||||
screen = TimeMachineScreen(
|
||||
session=_FakeSession(), label="the whole mesh", build=build, scopes=floods.keys
|
||||
)
|
||||
assert "s scope" in screen.footer_hint
|
||||
titles = []
|
||||
for _ in range(4):
|
||||
screen.handle("text", "s")
|
||||
screen.render_body(80)
|
||||
titles.append(screen.title.removeprefix("the whole mesh · 7 d"))
|
||||
assert titles == [" · unscoped", " · scope yul", " · unknown scope", ""]
|
||||
assert built == [("unscoped",), ("region", "yul"), ("unknown",), None]
|
||||
screen.handle("scope") # the F2 chip's action is the same cycle
|
||||
assert screen.short_title == "the whole mesh · 7 d · unscoped"
|
||||
|
||||
|
||||
def test_a_page_with_no_scope_offers_no_cycle() -> None:
|
||||
"""A node's page is its adverts, which carry no scope: no hint, no chip, no cycle."""
|
||||
screen = TimeMachineScreen(
|
||||
session=_FakeSession(), label="Hub", build=lambda window, width, scope: []
|
||||
)
|
||||
assert "scope" not in screen.footer_hint
|
||||
screen.handle("text", "s")
|
||||
assert screen.title == "Hub · 7 d"
|
||||
|
||||
|
||||
def test_a_narrowed_mesh_page_charts_the_scopes_floods_and_drops_the_node_counts(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Floods per day, their rhythm, and a ledger of the scope's own; no nodes, no arrivals."""
|
||||
from meshterm.ui.timemachine_screen import _mesh_scope_sections, _MeshFloods
|
||||
|
||||
floods = _MeshFloods(_scope_ctx(_flood_repo(tmp_path)))
|
||||
week = timedelta(days=7)
|
||||
text = _plain(_mesh_scope_sections(floods.stamps(week, ("region", "yul")), week, 80), 80)
|
||||
assert "Floods per day" in text and "Rhythm" in text and "2 floods" in text
|
||||
assert "Nodes per day" not in text and "Arrivals" not in text
|
||||
day = _plain(
|
||||
_mesh_scope_sections(
|
||||
floods.stamps(timedelta(days=1), ("unscoped",)), timedelta(days=1), 80
|
||||
),
|
||||
80,
|
||||
)
|
||||
assert "Floods per hour" in day and "1 flood " in day
|
||||
empty = _plain(_mesh_scope_sections([], week, 80), 80)
|
||||
assert "Nothing recorded in this scope" in empty
|
||||
@@ -2327,6 +2327,7 @@ def test_right_ctrl_rescue_covers_the_sessions_own_chords(monkeypatch) -> None:
|
||||
class FakeApp:
|
||||
def __init__(self) -> None:
|
||||
self.exited = False
|
||||
self.is_running = True
|
||||
|
||||
def exit(self) -> None:
|
||||
self.exited = True
|
||||
|
||||
Reference in new issue
Block a user