23 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5.5 6049e989fc MeshTerm 0.10.2
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 02:09:04 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c1d0772baf Lint and format the last few commits
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 02:09:04 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2d6b8ada6e The main menu's quit hint reads ^Q quit?, since it asks first
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:52:49 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 dd64ea8e87 The PicoCalc's main menu shows its way out, with Quit? on F3
With Esc inert at the main menu, the PicoCalc had nowhere to say how to
leave. Its title bar now ends on ^Q quit there (Esc clear while a filter is
typed), and the menu's F-key lane puts Quit? on F3, which asks first, with
Quit! behind it on F8 to leave without asking. Follows up issue #22.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:46:06 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7071214660 ^Q asks before it quits, and Esc at the main menu stays put
Esc-mashing back to the menu used to land on the quit confirm, and ^Q, one
key from ^W, quit with no question at all. Now Esc at the main menu only
clears a typed filter, and ^Q floats the same quit confirm the Quit row
opens, from anywhere and without stopping what runs underneath; a second
^Q while it asks leaves at once. The main menu's hint names ^Q quit, the
only place the chord is shown. Option D from issue #22.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:31:03 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0e9f41fb2a A reboot no longer waits for an acknowledgement the board never sends
Over Bluetooth every command is a write-with-response, and a companion that
restarts on the reboot command can drop the link before acknowledging the
write. The write then hung until the link supervision timeout, holding the
reboot dialog back by a second or more. The reboot now returns once the
write has had a quarter second to fail, and leaves the rest to the teardown
that follows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:20:05 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 cdea534fa0 A reboot sent from Device config raises the reconnect dialog at once
The reboot flow used to send the command and then wait for a liveness poll
to see the link drop. A quick reboot behind a port that never goes away
(a USB-UART bridge) was never seen, so the app went on sending commands to
a board mid-restart. Sending the reboot now declares the link down itself:
the session watcher wakes on the announcement, the Device rebooting dialog
takes over, and the first reconnect attempt waits a moment for the board to
actually begin restarting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:09:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 3f73716ff5 The Channels list leads with each channel's slot number
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:38:23 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 854b7fd765 A T-Deck on MeshOS or wadamesh pairs over Bluetooth like any companion
Those firmwares guard only the UART write characteristic, so the
refusal arrives on the handshake's first write, which meshcore's send
swallows. The empty handshake read as "not a MeshCore companion", and
the PIN pairing, the stale-bond repair and the macOS Passkey wait never
ran. The write is now wrapped to keep its refusal, and an empty
handshake behind one raises it, so both firmware shapes take the same
pairing path.

bleak no longer gets the PIN on Windows either: its WinRT pair() is
Just Works only, and after a mistyped PIN it left Windows holding an
unauthenticated bond that the next, correct attempt reused and was
refused over.

Verified against a T-Deck on MeshOS 1.3.0: a planted Just Works bond
heals in one run, and an unbonded connect without a PIN now says the
device needs one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:26:14 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 307e767570 The Time Machine's whole-mesh page cycles through each scope, as the dashboard does
s (F2 on the PicoCalc) steps all → unscoped → each region heard in the window
A–Z → unknown scope, beside w's window cycle. A narrowed page charts that
scope's floods per day (per hour at 24 h), their time-of-day rhythm, and its
own ledger; nodes per day and arrivals leave, being read off adverts, which
carry no scope. A window's stored floods are read once (Repository.flood_frames)
and resolved against the known region names for the page's life.

The node and own-node pages offer no cycle: a node's page is its adverts and
telemetry, and what we send is traces, which go direct.

The ring's order, words and chip move to ui/scopering.py, shared with the
dashboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 02:35:31 -04:00
Jean-Pierre Martineau ca194fbd30 Updated splash screen (ANSI art, CP437) 2026-09-28 02:25:24 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 247c9bf8c7 Splash letters said white after a reset and drew grey
An art editor spells white as a reset then a bare bold (ESC[0m ESC[1m): the
console's default grey, brightened. The loader forgot the colour on a reset,
so it dropped the bold with nothing restated and the run fell to the
terminal's own default grey. After a reset the grey is now the colour in
force, and a bold lifts it to bright white like any other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 02:20:36 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1596f121a3 The dashboard cycles through each scope heard, as the Time Machine does windows
s (F3 on the PicoCalc) steps all → unscoped → each region A–Z → unknown
scope → all, and the whole screen follows: the activity chart, the pulse
rate, the traffic tallies and the SNR/RSSI medians narrow to that scope's
floods, while the radio's own numbers stay whole. A narrowed view reads the
screen's 2-hour window, since naming a frame's region is a per-frame check
the database can't run; the headings say so, and a direct frame, which has
no scope, counts only under all. The title names the view, falling back to
"Dashboard · scope …" where a long region name won't fit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 00:28:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 97d4d231c7 The dashboard's traffic counts every frame once, under the live feed's names
Traffic listed what the device reported (advert, telemetry, message, ack)
beside the frames those reports arrived in, so one advert was counted twice
and its frame row had to be renamed "raw advert" to tell the two apart. It
now counts frames heard on the air only, named by the same function as the
live feed's class lane (payload_marks), and every bar takes the colour the
feed draws a frame's class in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 00:22:51 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 3fccfd7971 The Contacts title counts the archived too, abbreviating where it's narrow
"Contacts · 198 known + 123 archived", falling back to "Contacts · 198 + 123"
where the frame's rule can't hold the words. The fallback is a new
Screen.short_title the frame picks through fitted_title, on both the bordered
panel and the PicoCalc title bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 23:41:53 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 53de3c9fb4 Donation links as clean as they can be, on GitHub and on the Support page
Patreon becomes GitHub's built-in `patreon:` entry, so the sidebar shows
patreon.com/MeshTerm with its icon like Buy Me a Coffee, and the Support
page carries the same short URL (a smaller QR code too). PayPal stays the
hosted donate button, only without its scheme in FUNDING.yml: a paypal.me
link would let a donor pay as goods and services and claim a refund for an
item that never arrived, which the donate button's donation label avoids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:21:36 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 00ea4d5e14 The release skill ends with a Discord announcement for JP to post
A short, plain copy for #announcements, shown last in a code block so it's
easy to copy. JP posts it himself; the skill never does. Also puts back the
notes-repair sentence that had come loose after a code block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:09:48 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a9e650c07d MeshTerm 0.10.1
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:05:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b09d97f8a3 The release skill always asks which bump, and asks twice for a major
"Bump minor" meant "a small bump" and nearly shipped 0.11.0. Every release
now starts with a three-way choice showing the number each one produces,
patch recommended, and a major needs a second, separate yes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:05:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0cd40bbd6c Fix three small CLI rough edges: records --width, the simulator's label, two help pages
records --width now refuses anything but 1, 2, or 4 with a usage error, the
way --category already refused an unknown discipline. A width that doesn't
exist used to come back as exit 5, indistinguishable from an empty database,
and --width 0 was falsy and so listed every width. (#15)

A device with no port is labelled by its name alone. The simulator fell
through to the serial branch and came out as "… (nothing transmits) ()". (#14)

platform and specimen pass their own one-line help, so Typer stops printing
their docstrings — notes for whoever reads cli.py, Sphinx roles and source
paths included — and their summaries fit in meshterm --help. A new test runs
--help on every registered command and refuses those markers anywhere. (#16)

Built from the issues alone; the pull requests filed against them were
closed unreviewed and their code was never read.

Closes #14, closes #15, closes #16.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 10:59:17 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1949d5b1a3 The uConsole guide says the bridge move-over is for setups from 0.9.0 and earlier
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:47:16 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 700cbb57b7 Contributing: say hello on Discord first, disclose AI use, and no bots
Three pull requests against toy issues arrived from agents that farm open
issues for contribution stats. None had said a word to the project, and
none disclosed that AI wrote them.

CONTRIBUTING.md now says, in plain words, how MeshTerm was made and who
it wants: people who use it, AI-assisted or not. Before writing code, a
contributor says hello in the new #contributing channel on Discord and
waits for a yes; the pull request carries their Discord username and a
link to that yes. AI use has to be disclosed in the commits and the pull
request; undisclosed or unattended agent output is closed without review.

The PR template, the README's two notes on pull requests, and the docs
index all said small fixes could skip the conversation, and now match.
The release skill's commit carries the AI trailer its seven release
commits went out without.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 09:45:20 -04:00
Jean-Pierre Martineau f02b794861 The release skill never recommends more than a patch bump 2026-09-25 22:16:41 -04:00
46 changed files with 2050 additions and 334 deletions

No files matched your search

+61 -7
View File
@@ -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
@@ -48,8 +48,27 @@ A release does not go out on a red suite. If something fails, stop and say so.
## 2. Decide the version
Patch unless JP says otherwise. Say the number you arrived at before you write it anywhere,
so a wrong assumption costs a sentence instead of a tag.
**Always ask, as a multiple choice**, whenever JP asks for a release — even when his
request names a bump, because words like "minor" have meant "a small one" before (he
asked to "bump minor" and meant the patch). One AskUserQuestion, three choices, each
showing the actual number it produces:
- **Patch — X.Y.(Z+1)** (Recommended)
- **Minor — X.(Y+1).0**
- **Major — (X+1).0.0**
**Major asks twice.** If he picks it, ask a second, separate question before writing the
number anywhere — "Release (X+1).0.0? A major version is a promise about compatibility
that can't be taken back." — with *Yes, (X+1).0.0* and *No, go back to patch* as its
choices. Only a second yes makes it major.
Then say the number you arrived at before you write it anywhere, so a wrong assumption
costs a sentence instead of a tag.
**Don't recommend a bigger bump**, however many features or changed defaults the release
carries — not even on the strength of the changelog's SemVer note. 0.10.0 went out that way
on a recommendation, and JP would have preferred 0.9.1. A minor or major bump happens only
when he names it.
## 3. Write the entry
@@ -116,10 +135,16 @@ field, which is the live `{version}` placeholder and must show the new one.
## 5. Commit, tag, push
```
git commit -am "MeshTerm X.Y.Z"
git commit -a -m "MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this skill> <noreply@anthropic.com>"
git tag -a vX.Y.Z -m "MeshTerm X.Y.Z"
```
The trailer is not optional: the changelog entry in this commit is prose you wrote, and
CONTRIBUTING.md asks every contributor to say when an AI tool wrote part of a change —
the project's own commits say it first. Name the model actually running, not a
hardcoded one. Seven release commits (0.3.1 through 0.10.0) went out without it because
this line used to omit it.
Commit on `main` — never a topic branch. Then **confirm with JP before pushing**, because
the push is the act that builds and publishes the release:
@@ -154,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
View File
@@ -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"
+16 -4
View File
@@ -2,15 +2,27 @@
<!-- The why matters more than the what — the diff already says the what. -->
## Related issue
## Discord
<!-- Small fixes can come straight to a PR. For anything bigger, CONTRIBUTING asks for an
issue first and a yes on it, so a large PR with no issue behind it will get a
conversation before it gets a review. Link it here. -->
<!-- Your Discord username on the MeshTerm server, and a link to the message in
#contributing where you got a yes. A pull request without them is closed without
a review. -->
Username:
Link to the yes:
## AI use
<!-- Required. Did an AI tool write any part of this change? If yes: which tool, what it
did, and what you checked yourself. Each commit it helped with also needs a line at
the end naming it, like `Co-Authored-By:` or `Assisted-by:`. If you used AI and don't
say so, this pull request is treated as a bot's and closed. -->
## Before you mark it ready
- [ ] I've read [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) and agree its licensing terms apply to this contribution
- [ ] I've put my Discord username and a link to the yes above
- [ ] If I used AI, I said so above and in the commits it helped with
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
- [ ] `ruff check .` and `ruff format --check .` are clean
- [ ] New screens, rows, and hints go through the helpers in `ui/menus.py`, and follow the
+68 -1
View File
@@ -9,6 +9,71 @@ 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
of looking like an empty result, the simulator's row in `devices` loses a stray `()`, and
two help pages read like the rest.
### Fixed
- **`meshterm records --width` refuses a width that doesn't exist.** Hash widths are 1, 2,
or 4. Any other number used to look like an empty result, so a typo and an empty
database gave the same answer, and `--width 0` was ignored and listed every width. Now
it's a usage error that names the three choices, like `--category` already was.
- **The simulator's row in `meshterm --mock devices` no longer ends in an empty `()`.**
The brackets hold a serial port, and the simulator doesn't have one.
- **`meshterm platform --help` and `meshterm specimen --help` read like the others.** They
used to print notes meant for someone reading the source code, markup and all, and cut
their summaries short in `meshterm --help`. Now each has one plain line.
## [0.10.0] — 2026-09-25
**Regions, a uConsole with no bridge, and Bluetooth that works on Linux.** MeshTerm now
@@ -188,6 +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.0...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
+17 -6
View File
@@ -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.
+60 -2
View File
@@ -25,8 +25,66 @@ this repository.
seen, etc.), UX standards for screens/dialogs/menus, and where the reusable building
blocks live in code (`ui/menus.py`, `ui/markdown.py`, `ui/widgets.py`, `ui/theme.py`).
New code should read like it always belonged here.
- Small fixes can go straight to a pull request. For anything bigger, open an issue
first and wait for a yes before writing a lot of code — saves both of us a rewrite.
- Tell us before you start working on something (see below). For anything bigger than a
small fix, expect to talk it through first. That saves everyone a rewrite.
## Who can contribute
If you use MeshTerm, you're welcome here. Maybe something broke for you, or you wished
it could do one more thing, or a screen looked wrong on your radio. Those are exactly
the changes we want.
### How MeshTerm was made
Some people in the MeshCore community don't trust code written by AI. After some of
what has happened lately, that's easy to understand, and it deserves a straight answer.
Jean-Pierre Martineau, who started MeshTerm, sees it differently. AI was a big help on
this project. Honestly, MeshTerm couldn't have been built without it. But the choices
were his. He decided what MeshTerm should be, what it should do, and how it should
look. He uses it every day. It's handmade, with AI's help.
The project wants to stay that way. That's what the rules below are for.
### Come say hello first
Using AI tools to help you work is fine. What matters is that a real person who cares
about MeshTerm is behind the change.
The MeshTerm community lives on [Discord](https://discord.gg/AZwe5Uvb3S), and everyone
is welcome to join. But please join before you change things. Before you start writing
code, say hello in the **#contributing** channel and tell us what you'd like to work on.
Then wait until we say yes. This goes for small fixes too.
When you open your pull request, include two things:
- **Your Discord username**, the one you use on the MeshTerm server.
- **A link to the message where we said yes.** In Discord, right-click the message and
choose "Copy Message Link."
If you haven't joined the Discord, or nobody said yes to your idea, your pull request
will be closed without a review.
### No bots
Some accounts use AI to find open issues, write a fix, and open a pull request, all on
their own. There's no person behind it who read the change or can answer questions
about it. People call this "AI slop." We close those pull requests without a review,
even if the code looks fine. The same goes for issues and bug reports written that way.
### Tell us if you used AI
This part is required. If an AI tool wrote any part of your change, say so in two
places:
- **In each commit it helped with.** Add a line at the end of the commit message naming
the tool, like `Co-Authored-By: Claude <noreply@anthropic.com>` or
`Assisted-by: GitHub Copilot`.
- **In your pull request.** Write a sentence about what the tool did and what you
checked yourself.
If you used AI and didn't say so, we'll treat your pull request as a bot's. Saying so
costs you nothing. Either way, you're the author, so be ready to explain your change.
## Making a change
+9 -7
View File
@@ -23,8 +23,8 @@ your mesh's history is worth.
<a href="https://discord.gg/AZwe5Uvb3S"><img alt="Discord" src="https://img.shields.io/badge/chat-Discord-5865F2"></a>
</p>
> MeshTerm is a side project, run by one person. Bug reports are very welcome. Small fixes
> can go straight to a pull request; for anything bigger, please open an issue first.
> MeshTerm is a side project, run by one person. Bug reports are very welcome. Want to
> change something? Say hello on [Discord](https://discord.gg/AZwe5Uvb3S) first.
> [More on how it's run](#how-this-project-is-run).
https://github.com/user-attachments/assets/f91a6695-14d5-4ae0-8fdc-e65492d0955d
@@ -297,11 +297,13 @@ fair bit, and that's on purpose: how well a problem is described really does dec
I can do anything with it. If I can reproduce it, I'll usually chase it. If I can't, I'm
mostly guessing.
**Ask before you write a big pull request.** Small fixes can go straight to a pull
request. For anything bigger, open an issue first and wait for a yes. I'm not being
precious — MeshTerm has firm house rules about how screens get built (they're in
[CLAUDE.md](CLAUDE.md)), and I'd hate for you to spend a weekend on something I then ask
you to rewrite. A quick conversation first saves us both.
**Ask before you write a pull request.** Join the [Discord](https://discord.gg/AZwe5Uvb3S)
and say hello in `#contributing`. Tell us what you'd like to change. Then wait for a yes.
That goes for small fixes too. I'm not being precious. MeshTerm has firm house rules
about how screens get built (they're in [CLAUDE.md](CLAUDE.md)), and I'd hate for you to
spend a weekend on something I then ask you to rewrite. A quick conversation first saves
us both. AI tools are fine, as long as you say you used them.
[CONTRIBUTING.md](CONTRIBUTING.md) has the details.
**I can't promise timelines.** Some things get fixed the same night. Some sit for a month
because life happened. If your issue goes quiet, please give it a nudge. That helps me.
+1 -1
View File
@@ -32,7 +32,7 @@ USB, Bluetooth, or Wi-Fi needs none of this.
| --- | --- |
| [How the code is laid out](architecture.md) | The layering, and where a new feature goes. |
| [CLAUDE.md](../CLAUDE.md) | The actual style guide — terminology, UX standards, and the reusable building blocks. Written for AI assistants, and the house rules for everyone. |
| [CONTRIBUTING.md](../CONTRIBUTING.md) | The dev install, the three gates a change has to pass, and what happens to your contribution's licensing. |
| [CONTRIBUTING.md](../CONTRIBUTING.md) | How to propose a change, the rules on AI use, the dev install, the three checks a change must pass, and what happens to your contribution's licensing. |
## Historical record
+1 -1
View File
@@ -1556,7 +1556,7 @@ each link; the scripted face prints the link alone.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.10.0","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 |
+10 -5
View File
@@ -33,7 +33,7 @@ That's [further down](#always-on-the-bridge) — most people want the section ab
- [Step 2 — Install the radio library](#step-2--install-the-radio-library)
- [Step 3 — Install MeshTerm](#step-3--install-meshterm)
- [Step 4 — Connect](#step-4--connect)
- [Moving over from the bridge](#moving-over-from-the-bridge)
- [Moving over from the bridge (0.9.0 and earlier)](#moving-over-from-the-bridge-090-and-earlier)
- [Board wiring](#board-wiring)
- [Troubleshooting](#troubleshooting)
- [Always on: the bridge](#always-on-the-bridge)
@@ -215,11 +215,16 @@ The AIO's wiring is the default, so this profile needs nothing beyond `transport
---
## Moving over from the bridge
## Moving over from the bridge (0.9.0 and earlier)
Already running the bridge? The first time you connect with `--spi` (or a profile, or the
picker row), MeshTerm carries the bridge's node over — **copying**, never moving, so the
bridge still works afterwards if you go back to it:
> **Only if you set up the bridge with MeshTerm 0.9.0 or earlier.** Before 0.10.0 the bridge
> was the only way to use the uConsole's radio, so an older setup is almost certainly
> running one. On a fresh install of 0.10.0 or newer there is nothing to move over — skip
> ahead to [Board wiring](#board-wiring).
Upgraded from 0.9.0 or earlier with the bridge still installed? The first time you connect
with `--spi` (or a profile, or the picker row), MeshTerm carries the bridge's node over —
**copying**, never moving, so the bridge still works afterwards if you go back to it:
- the identity key — from the `meshcore-console` GUI's copy first
(`~/.local/share/meshcore-uconsole/identity.key`), the way the bridge itself looks for
+1 -1
View File
@@ -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.0"
__version__ = "0.10.2"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+2 -2
View File
@@ -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.
+8 -2
View File
@@ -603,7 +603,10 @@ def _report_no_windows_console(console: Console) -> None:
console.print("try [accent]meshterm --help[/accent].")
@app.command(name="platform")
@app.command(
name="platform",
help="Show which platform MeshTerm runs as, and why",
)
def platform_command() -> None:
"""Print the resolved platform and every input the resolution looked at.
@@ -650,7 +653,10 @@ def platform_command() -> None:
)
@app.command(name="specimen")
@app.command(
name="specimen",
help="Print every mark, icon, and colour MeshTerm draws",
)
def specimen_command() -> None:
"""Print the visual-language specimen for the active platform.
+37 -4
View File
@@ -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
View File
@@ -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())
+2
View File
@@ -273,6 +273,8 @@ class DiscoveredDevice:
if self.is_spi:
return f"{self.name or 'SPI radio'} ({self.port})"
name = self.product or self.description or self.vendor_label or "Serial device"
if not self.port: # the simulator has nothing to point at, so no "()"
return name
suffix = f"({self.port})"
if name.endswith(suffix): # avoid "… (COM11) (COM11)"
name = name[: -len(suffix)].rstrip()
+38
View File
@@ -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.
+4
View File
@@ -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":
+10 -1
View File
@@ -26,6 +26,9 @@ from ..services.records import CATEGORIES, CATEGORY_BY_ID, Category
from ..ui.script import NONE
from .base import Tool, ToolResult, register
#: The hash widths a record can be set at: MeshCore's path hashes are 1, 2 or 4 bytes.
WIDTHS = (1, 2, 4)
if TYPE_CHECKING: # pragma: no cover - typing only
from ..persistence.repository import DiscoveredPath
from ..ui.fields import NodeRef
@@ -183,7 +186,13 @@ class TrophyCaseTool(Tool):
f"--category must be one of: {choices} (got {category!r})"
)
tool_params["category"] = category
if width:
if width is not None:
# The same closed set as --category, for the same reason: `--width 3`
# found nothing and exited 5, indistinguishable from an empty database,
# and `--width 0` was falsy and so quietly listed every width.
if width not in WIDTHS:
choices = ", ".join(map(str, WIDTHS))
raise typer.BadParameter(f"--width must be one of: {choices} (got {width})")
tool_params["width"] = width
run_tool_command(self, tool_params)
+35 -18
View File
@@ -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"))
+19 -27
View File
@@ -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
+14 -4
View File
@@ -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
View File
@@ -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)
+10
View File
@@ -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
View File
@@ -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
+14 -5
View File
@@ -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.
+91
View File
@@ -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()
+199 -19
View File
@@ -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
View File
@@ -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)
+6
View File
@@ -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
View File
@@ -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
+85 -5
View File
@@ -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.
+55 -1
View File
@@ -566,8 +566,19 @@ def test_a_destructive_command_without_yes_is_a_silent_usage_error(run, args) ->
[
(("contacts", "--sort", "bogus"), "--sort"),
(("records", "--category", "long_hual"), "--category"),
(("records", "--width", "3"), "--width"),
(("records", "--width", "0"), "--width"),
(("records", "--width", "-1"), "--width"),
(("records", "--json", "--width", "3"), "--width"),
],
ids=[
"contacts-sort",
"records-category",
"records-width-3",
"records-width-0",
"records-width-negative",
"records-width-3-json",
],
ids=["contacts-sort", "records-category"],
)
def test_a_value_outside_a_closed_set_is_refused_rather_than_ignored(run, args, flag) -> None: # noqa: ANN001
"""A typo in a closed-set option used to be answered with a different question.
@@ -754,6 +765,49 @@ def test_a_closing_message_goes_to_stderr_too(run) -> None: # noqa: ANN001
assert "records stored" in result.stderr
@pytest.mark.parametrize("width", ["1", "2", "4"])
def test_every_real_hash_width_is_accepted(run, width) -> None: # noqa: ANN001
"""Refusing the widths that don't exist must not refuse the three that do."""
result = run("records", "--width", width)
assert result.exit_code == exitcodes.NO_RESULT, result.stderr
assert "records stored" in result.stderr
def test_the_simulator_is_listed_without_empty_brackets(run) -> None: # noqa: ANN001
"""``--mock devices`` named the simulator ``… (nothing transmits) ()``: a port it lacks."""
result = run("devices")
assert result.exit_code == exitcodes.OK, result.stderr
assert "the built-in simulator (nothing transmits)" in result.stdout
assert "()" not in result.stdout
#: What a docstring written for the source leaks into ``--help`` when Typer is left to
#: use it: a Sphinx role, reST's double backticks, or a path into the package.
_SOURCE_NOTES = re.compile(r":(func|mod|class|meth|attr|data):`|``|meshterm/[\w/]+\.py")
@pytest.mark.parametrize("leaf", _leaf_commands(), ids=lambda leaf: "-".join(leaf))
def test_every_command_help_is_written_for_the_terminal(run, leaf) -> None: # noqa: ANN001
"""A help page is for someone at a prompt asking what a command does.
``platform`` and ``specimen`` printed their whole docstrings — notes for whoever reads
``cli.py``, Sphinx roles and source paths included — and the same markup cut their
one-line summaries short in ``meshterm --help``.
"""
result = run(*leaf, "--help")
assert result.exit_code == exitcodes.OK, result.output
assert not _SOURCE_NOTES.search(result.output), result.output
def test_the_command_list_has_no_source_notes(run) -> None: # noqa: ANN001
"""The one-line summaries in ``meshterm --help`` come from the same help text."""
result = run("--help")
assert not _SOURCE_NOTES.search(result.output), result.output
for line in result.output.splitlines():
if line.split()[:1] in (["platform"], ["specimen"]):
assert not line.rstrip().endswith("..."), f"summary cut short: {line!r}"
# -- the simulator's own history ------------------------------------------------------
+123 -15
View File
@@ -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"
+26
View File
@@ -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.
+66
View File
@@ -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.
+16 -1
View File
@@ -14,7 +14,7 @@ import pytest
from meshterm.core.config import DeviceProfile
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice, discover_devices
from meshterm.core.discovery import TRANSPORT_MOCK, DiscoveredDevice, discover_devices
from meshterm.core.selection import DeviceSelectionError, resolve_device
@@ -112,6 +112,21 @@ def test_label_does_not_repeat_the_port() -> None:
assert DiscoveredDevice("COM5", product="Wio SX1262").label == "Wio SX1262 (COM5)"
def test_a_device_with_no_port_gets_no_empty_brackets() -> None:
"""The simulator has nothing to point at, so its label ends at its name, not in "()".
The serial branch is also where every transport without one of its own lands, and it
appended ``({port})`` whether or not there was a port.
"""
sim = DiscoveredDevice(
transport=TRANSPORT_MOCK,
description="the built-in simulator (nothing transmits)",
name="simulator",
)
assert sim.label == "the built-in simulator (nothing transmits)"
assert DiscoveredDevice(product="Wio SX1262").label == "Wio SX1262"
def test_stable_id_precedence() -> None:
"""stable_id prefers serial number, then vid:pid, then the port name."""
assert DiscoveredDevice("COM5", serial_number="SN1", vid=1, pid=2).stable_id == "sn:SN1"
+58 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
)
+17 -1
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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