70 Commits
Author SHA1 Message Date
rich 233dfc813c Merge upstream MeshTerm 0.11.5
CI / lint (push) Waiting to run
CI / test macos-latest py3.14 (push) Waiting to run
CI / test ubuntu-24.04 py3.10 (push) Waiting to run
CI / test ubuntu-latest py3.11 (push) Waiting to run
CI / test ubuntu-latest py3.12 (push) Waiting to run
CI / test ubuntu-latest py3.13 (push) Waiting to run
CI / test ubuntu-latest py3.14 (push) Waiting to run
CI / test windows-latest py3.10 (push) Waiting to run
CI / test windows-latest py3.14 (push) Waiting to run
CI / build (push) Waiting to run
Issue trailers / check (push) Waiting to run
Bring in the 65 upstream commits since 0.11.1, with ^K lock and the self chip.
Both sides kept their features. React gives way to Lock in two places. On the
PicoCalc lane, Lock keeps the Shift half of F2 and React is ^E only. In the
hint, ^E react is the first atom to go when ^K lock has no room.

The long-message code now refills the compose line with MentionEditor, which
replaced LineEditor upstream.
2026-10-22 16:53:37 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0d09a364cd MeshTerm 0.11.5
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 03:15:55 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 544156968d The Courier outbox filters as you type again, and a wide footer keeps Esc
Typing narrows the outbox again, and its footer says so. With the scroll
atom, the footer of an entry row is wider than 72 cells, which JP
accepted. The footer line no longer cuts a hint that is too wide at the
edge, which took Esc with it: it drops atoms instead, as the splash
already did. The outbox drops "Del delete" first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 01:09:55 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 53ffb984cb The Courier outbox shows each message and pans instead of cutting
Each row now carries a third column, MESSAGE, after TARGET and TRIGGER
(or OUTCOME). Nothing is shortened to fit: the rows and their headers pan
together with ←→, and the footer says so while the table runs past the
edge. The outbox no longer filters as you type, which keeps the footer
within 72 cells.

A column header that pans now pins with its section heading, as a
header that does not pan always did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 00:52:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0b931e7cc4 The Courier outbox is a TARGET and TRIGGER table, and Enter opens the message
Each section of the outbox now has column headers: TARGET and TRIGGER for
waiting entries, TARGET and OUTCOME for finished ones. The rows no longer
show the message. Enter on a waiting entry opens it to edit (the courier
holds the entry while the prompt is open, so the old text cannot go out
meanwhile), and Enter on a finished one shows it. Del deletes an entry
after a red confirm, and ^R sends a waiting entry now; on the PicoCalc
they ride F3 as Send, with Delete behind it. On a narrow screen a long
name gives cells to the trigger and breaks off on its crack.

The message field has the colours of the chat's compose line: a white
prompt mark and white text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 00:22:05 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1b30f353c6 A cut in the text beside a chip ends on an ellipsis, not on a stray crack
When a row was a chip followed by text (a Courier entry: the recipient,
then what it waits for) and the text ran past the screen, the cut looked
back for a chip fill, found the chip far to the left, and drew a half
block in its colour at the right edge. The crack now forms only inside
the path that the row stamps, so a cut in plain text gets the ellipsis.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 00:22:04 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 284c2718c6 MeshTerm 0.11.4
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 00:24:12 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 8f3f07276b Rewrap a docstring line that ruff found too long
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 00:01:45 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 8dff5bbb29 The selected chat message whitens its author chip and its unknown mentions
The author chip of the selected message's group now turns its label white,
as the message's text does, and goes back when the selection leaves the
group. A mention of an unknown node turns white too: its grey fill already
says the node is unknown, and white reads better on it than black.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:59:45 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 7264a71104 The mention chips of a selected chat message go white with its text
In a channel, a DM, or a room, the selected message's text is white, and
now its mention chips follow: the label turns white and the fill stays, so
the chips still tell one node from the next. Arrow mode whitens the label
the same way. A mention that no key can place keeps its grey, as on every
selected row.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:48:59 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 59273a6b29 A locked author's sender chip carries the padlock, as ours carries the star
In a channel, a DM, or a room, the chip of a sender that is a locked
contact reads 🔒 Name (⚿ on the PicoCalc), in the place of the ★ on our
own chip. The transcript cache now compares the locks, so a ^K or a lock
from the node page redraws the chips on the next paint.

The padlock is one constant, marks.LOCK_ICON, shared with the Contacts list.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:40:32 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 793bc26436 ^K on the Contacts list locks or unlocks the highlighted contact
The same key as in a conversation. The row gains or loses its padlock in
place, with no confirm, and the next ^K reverses it. On the handhelds, Lock
(or Unlock) rides Shift+F2, as in a channel and a DM.

The hint names ^K only where it fits without dropping another atom. That
rule now lives in one helper, menus.splice_if_room, which the archive
preview uses too.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:27:05 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 daa26be5df ^K in a conversation locks the author of the selected message
In a channel, a DM, or a room, ^K toggles the lock of the contact that wrote
the selected message, so the archive sweep never takes it. The hint says
^K lock or ^K unlock, but only where it fits without dropping another atom,
and the status line confirms what the key did.

On the handhelds, Lock (or Unlock) rides Shift+F2 in a channel and a DM. A
room keeps Log in there, so the Author page that a conversation opens now
carries the lock row, and no other management verb.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:18:57 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 60a2df727a Shift+Delete in the archive preview locks a contact as well as sparing it
Delete keeps a contact out of this sweep only. Shift+Delete removes the row
the same way and stages a lock, so no later sweep takes the contact. The
locks are written on Apply with the archive; Esc and Back ask before they
discard them. A preview left with only locks applies them without the amber
confirm.

The footer names ⇧Del lock only when the hint still fits 72 cells, and a
contact row now says Enter open, since Enter opens its node page. On the
handhelds, Lock rides Shift+F3 behind the Delete chip.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 18:14:39 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 87bf929ac4 The Cardputer's battery gauge stays on through the Cap's noisy bus
On battery with the Cap on, 79% of the gauge reads failed, in groups of
up to 110 s. The poll read 3 times in 11 s and then waited 20 s, so it
missed the good periods, and after 7 failed polls the gauge went away.

- After a failed read, the next read comes after 5.5 s, not 20 s.
- The last good reading stays for 10 minutes instead of 7 polls.
- A change of the charging flag waits for a second read that agrees,
  because a garbled read said Charging while the pack discharged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 03:36:55 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bdc60481ec A serial link that dies while its port stays listed opens the reconnect dialog
When a laptop wakes from sleep, the kernel can reset the USB device and hang
up the open port, but keep the same device file. The next write fails with
EIO, and the serial transport closes itself. The serial liveness check read
only the OS port list, so it said the link was up: no reconnect dialog
opened, and each command failed with no_event_received (issue #32).

The serial check now also reads the meshcore client's is_connected flag,
which the client clears when its transport is lost, as the BLE and TCP
checks already did.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 00:42:19 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 9d7c5340b5 MeshTerm 0.11.3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 01:57:10 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1d983013d7 No log keeps a password or a secret: the log file, the run log, or the remote history
Passwords, channel secrets, pairing PINs, and private keys reached the DEBUG log and
the runs table in plain text: every chat run stored its channel secret inside the
Conversation repr, and --password, --secret, channel links, and config set device_pin
were stored as typed. meshterm.core.secrets now hides each one as <secret> by its
shape (a secret-named key, record, set operation, or dataclass field; bytes; a Secret;
a remote `password`/`set guest.password` command; name=value in text), and by value
for each secret MeshTerm has read or sent. Both log handlers and the runs table go
through it, always. The remote CLI history keeps `password ` without its value. Schema
v20 hides the secrets that older versions stored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 00:20:22 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 9e579424b4 The Anonymize preference masks the home folder and addresses in the log and diagnostics
Off by default. Each masked value keeps a tidbit (j*********u, DA:xx:xx:xx:7C:7C,
192.xxx.x.180, m******e.local), so two radios stay two radios. The log masks each
line as it is written; diagnostics take --anonymize/--no-anonymize, and the page
toggles with `a` (Shift+F3 on the PicoCalc). Refs #31.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 21:30:26 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 de5c833550 The README plays the demo video with the regions of the real mesh
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:32:03 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fc58c58c09 The demo mesh uses the regions of the real mesh: na, can, onqc, qc, and yul
Each region is in the one before it, as on the real mesh: na > can > onqc > qc > yul. Every repeater of Montréal, Laval, and the south shore carries the five, and Mont-St-Hilaire, outside the city, has yul but does not relay it. The #montreal channel sends in yul instead of mtl. The invented regions (mtl, mtl-west, laval, south-shore, and the others) are gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:23:30 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1239242bb4 An Instagram video for each page of MeshTerm
scripts/reel_tours.py holds 21 tours, one for each page of the menu except the four written pages: chat, channels, rooms, courier, contacts, the dashboard, the live feed, the watchtower, the time machine, the map, the mesh walk, both traces, the trophy case, device info and config, send advert, repeater admin, TX optimize, preferences, and diagnostics. Each tour opens its page off camera, so the video starts on the feature, and has its own subtitles and post caption.

scripts/reels.py now takes several names or "all", keeps each recording in a folder with --recordings, plays the background traffic for the pages that show it, and gives the recording process the size of its terminal, so Diagnostics shows 72x38.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 17:01:01 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b278ccfdc1 In the demo, the TX power of a repeater changes how well it is heard
TX optimize had nothing to find in the demo: the demo device answered each trace from the world's links and ignored the power that the sweep set on a repeater. Now a relay that TX optimize tuned is heard less well on the link that it transmits into, on the simulator's curve with its peak at 19 dBm, and a reading under the decode floor loses the trace. The repeaters of the world read 22 dBm, so the sweep finds a lower power with a better signal. A relay that nobody tuned keeps the readings of the world.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 17:01:01 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 e47f7b7ea8 The terminal of the Instagram videos fills the height of the phone
The terminal now has 38 rows instead of 28, and the series line, the terminal, and the subtitles fill the frame from y = 150 to y = 1530. Only the bottom 20% stays free for the name of the account and the caption of the post. The chat shows more of the conversation, and the QR code is larger.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 15:28:22 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 e766c1018b The Instagram videos say MeshTerm for MeshCore
The series line above the terminal is now "MeshTerm for MeshCore · Chat", so a viewer who does not know the name sees what MeshTerm is for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 13:10:36 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 fc7fd17554 The videos for Instagram come from a script
scripts/reels.py makes one vertical video of MeshTerm for each feature, from the demo mesh: 1080×1920, with subtitles in the picture and no sound, then a card with the wordmark and the website. The terminal has 72 columns, so its text is as large as a phone lets it be. It also writes a cover for the Reel and a caption for the post. The first video is the chat: the conversations, a live message, the paths of a message, a mention and its answer, and a link as a QR code.

screenshots.py now takes the grid of the terminal set as an argument, and its painter draws a full-width character that no font has (the "#" of a hashtag channel) as its ASCII form, instead of a box.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 13:03:36 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 37aa4ed5be The README's video is uploaded by a script
scripts/video.py --readme uploads the demo video to GitHub as an attachment of the repository and puts the new URL on the README's video line. It uses the endpoint that the web page uses when a file is dropped into a comment, with the gh token, so no issue and no browser are necessary. The README now plays the demo video, and its caption says so.

The website-media skill runs it once the video is checked, and the release commit stages README.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 10:37:43 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 daf16505d7 Each release makes the demo video again, with the screenshots
A new website-media skill makes the screenshots and the website's demo video, says what to look at, and commits them: the screenshots in MeshTerm, the video in the site checkout. The release skill runs it after the bump and pushes the site before the tag, so the website job builds the page with the new video. The release preflight also reports the site checkout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 10:06:27 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 507e3bd999 In the demo, a node that we mention answers, and our node uses three-byte paths
A channel message of our node that mentions Dave gets his answer a few seconds later, and the answer mentions our node. The answers are a table in the demo script. Our node uses three bytes for each hop, as most of the mesh does, so a trace uses two.

The video's tour now selects the two paths of a message in turn, types a question to Dave so that the mention becomes a chip, and shows his answer with our star. The trace shows its new records, and the map zooms from the city to the streets around our node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 09:51:56 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 979e31e6fd The website's demo video comes from a script
scripts/video.py runs the demo mesh headless in the terminal set of the screenshots, types a tour of less than two minutes, and records the screen 30 times a second. It paints each different screen in parallel, encodes the video with ffmpeg, and writes demo.mp4, its poster, and its play card into the website checkout's src/. The camera stops while the tour waits for something slow, so the video jumps over the wait.

screenshots.py gives the video its app runner, its environment, and an RGB painter. The terminal screenshots stay pixel-identical. The glossary gains "video frame".

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 01:29:30 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 37b17d8ba4 The demo's message paths look like a real mesh
Measured against a real Montreal database: most messages arrive twice,
through the two repeaters a companion hears, on paths that share their
start and split near the end, with 3-byte hashes. The demo did five or
six short copies through five neighbours.

Our node now hears only two new rooftop repeaters, Villeray and
Rosemont (the terrain hides the big ones), companions lose 7 dB at street
level, and each sender has its own hash width (mostly 3 bytes). Now 91%
of messages arrive twice and 83% of paths use 3-byte hashes. Noor moves
to Côte-des-Neiges to stay in reach, and the traces leave through
Villeray or Rosemont.

The capture finds the Message paths message by its text (a key press
can be lost while the transcript draws) and selects the second path,
since the first is often a straight line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 04:30:43 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 5d2648d652 MeshTerm 0.11.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 03:46:15 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7b8033d3ea Format the neutral greys test
One line was 102 columns. ruff format breaks it, and ruff check passes again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 03:45:59 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 e1c0e031f8 Each release makes the screenshots again
The release skill runs scripts/screenshots.py after the version bump,
so the README, the website's three sets, and its splash show the new
version, and the images ride in the release commit and its tag. The
release commit now stages by name, because other sessions share the
tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 03:41:01 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 f8a9ce1cb8 The capture also draws the two wordmarks of the splash
scripts/screenshots.py draws logo.71.ans and logo.53.ans, through the
splash's own loader and the emulator's Terminus raster, in the Campbell
colours, into docs/screenshots/splash/logo.<width>.png at one image
pixel per font pixel. The website takes its splash from there, so it
always shows the art that the app shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 03:35:43 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 68280533a7 The README shows the demo's screenshots
The four captures in the README are now the terminal set from the demo
mesh: the main menu, the chat, the paths of a message, and the map. The
old dashboard, map, trace, and path images, which showed real nodes,
are gone. docs/README.md says where the set folders come from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 03:28:56 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 51c8b69f49 Screenshots for the website, the same at each run
scripts/screenshots.py runs MeshTerm --demo in the emulator's session,
with no window, and types its way to the main menu, the chat on Public,
the paths of a message, and the map. It saves each screen for the
terminal (80x24, drawn in Cascadia Mono with its own box, braille, and
Powerline shapes), the Cardputer Zero, and the PicoCalc (the emulator's
own raster, at 3x), into docs/screenshots/<set>/.

Two runs give the same pixels: the clock is pinned to 11:42 on Thursday
15 October 2026 in Montréal (time-machine and TZ), and the demo device
plays no background traffic during a capture (PLAY_BACKGROUND). Gabriel
in Beloeil joins the demo, so the message shown in Message paths
arrives on five paths through Mont-St-Hilaire, and the map opens at z10.
Pillow, fontTools, and time-machine join the dev extras.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 03:18:32 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 058effe42d The demo talks like anyone, in English
The demo's conversations are plain English about ordinary things: the
mesh, the meetup, a bike ride, the weather, a family dinner. The props
are gone: no poutine, smoked meat, or tuque, and Poutine Patrol and
Bixi Bandit are now Steve K. and Nico R. Our node is Alex Base. The
room, the sensors, two channels (#cycling, Family), and the regions
(south-shore, west-island, mtl-west...) have English names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:45:14 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 4397bd58ec The demo's now is 11:42
The virtual clock of the demo script stands for 11:42 on a weekday, not
19:30. Today's lines move into the morning: Mathilde arrives at 10:05,
the smoked meat run for noon is planned at 11:20, and the dinner on
Famille moves to yesterday. A test checks that each line is sent while
its sender is heard on the mesh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 01:25:07 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 d81f3c8618 A demo mesh over Montréal, for screenshots that stay the same
meshterm --demo runs the simulator in a synthetic Montréal mesh: 14
repeaters on the high points (Mont-Royal to Mont-St-Hilaire), rooms,
sensors, and about twenty companions, all invented. A log-distance
link model decides who hears whom, so floods arrive on several real
paths and traces walk real routes.

At each start the demo writes its own directory (<config>/demo) again:
300 days of adverts and a dense week of traffic, bilingual channel,
direct, and room conversations whose packets decrypt, traces with
their trophy-case records, neighbour tables, a joined room, admin
passwords, regions, and Watchtower alerts. Every value comes from a
fixed seed and an age before now, so each run gives the same data.
The device plays the live traffic from the same seeds.

The repository writers that stamp now take an optional time, and
record_observations stores a batch in one commit. The test world of
--mock is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 00:38:47 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 eb2d314551 Section headings carry no icon
Chat's Channels, Rooms, and Direct headings and the trophy case's
discipline headings drop their emoji, for a cleaner look. The trophy
marks stay on the delete list's rows and the record page's header.
section_heading no longer strips icons, since no heading has one.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 23:56:36 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 820740c9a0 A blank line before each section is one preference, app-wide
"Section spacing" (section_spacing_min_rows, in the Display group)
replaces "Author spacing" and "Spacing from". It is the smallest
terminal that gets the line: any height, 20, 24, 30, or 40 rows, or
never (default 24). Each section heading of a list gets the line, and
the chat keeps it between authors and before a new day.

The frame records the terminal rows on the screen before the body
renders (note_terminal_rows), and Screen.section_spacing is the one
test. section_heading marks its row as spaced, and the select list
adds the line, never at the top, after a blank line, or under a
heading. The hand spacers before headings are gone, except the seam
between the region table and its actions, which is two lists.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 22:28:23 -04:00
rich c5a2fb3746 Merge branch 'long-messages'
CI / lint (push) Canceled after 0s
CI / test macos-latest py3.14 (push) Canceled after 0s
CI / test ubuntu-24.04 py3.10 (push) Canceled after 0s
CI / test ubuntu-latest py3.11 (push) Canceled after 0s
CI / test ubuntu-latest py3.12 (push) Canceled after 0s
CI / test ubuntu-latest py3.13 (push) Canceled after 0s
CI / test ubuntu-latest py3.14 (push) Canceled after 0s
CI / test windows-latest py3.10 (push) Canceled after 0s
CI / test windows-latest py3.14 (push) Canceled after 0s
CI / build (push) Canceled after 0s
Issue trailers / check (push) Canceled after 0s
# Conflicts:
#	docs/development/writing-style.md
#	docs/guide/features.md
#	meshterm/core/preferences.py
#	meshterm/ui/chat.py
2026-10-15 21:30:10 -04:00
richandClaude Opus 5.5 d9426166c8 Search every emoji by name in the reaction dialog
CI / lint (push) Canceled after 0s
CI / test macos-latest py3.14 (push) Canceled after 0s
CI / test ubuntu-24.04 py3.10 (push) Canceled after 0s
CI / test ubuntu-latest py3.11 (push) Canceled after 0s
CI / test ubuntu-latest py3.12 (push) Canceled after 0s
CI / test ubuntu-latest py3.13 (push) Canceled after 0s
CI / test ubuntu-latest py3.14 (push) Canceled after 0s
CI / test windows-latest py3.10 (push) Canceled after 0s
CI / test windows-latest py3.14 (push) Canceled after 0s
CI / build (push) Canceled after 0s
Issue trailers / check (push) Canceled after 0s
^E still lists the reaction_emoji preference first, under "Your reactions". Under
"All emoji" it now lists each other emoji with its Unicode name, and the list
filters as you type: "fire" finds 🔥, "heart" finds the hearts. The catalog comes
from the Unicode database of the Python that runs MeshTerm and from Rich's table of
text-default emoji, so it needs no data file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00
richandClaude Opus 5.5 f3aa8e5aef Allow channel sender names of up to 31 characters
The firmware keeps a node name in 32 bytes, but the sender prefix and the @[Name]
mention matched only 20 characters. A longer name stayed in the body: the transcript
showed no sender for it, and its reactions showed as new messages. Found on hardware
with "Hazelwood r0b3.Anduril" (22 characters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00
richandClaude Haiku 5.5 a323342971 Add emoji reactions in the wire format of MeshCore One
Select a message in a channel or a DM and press ^E (Shift+F2 on the handhelds) to
react with an emoji. A reaction is a usual message on the wire: MeshTerm writes the
format of MeshCore One and reads it and the two formats of meshcore-open. The
transcript folds each reaction under the message that it names, with a count, or
the names of the nodes while that message is selected. A reaction does not raise
the unread badge, and the Messages list previews it as "reacted 👍".

A reaction names its target by the sender's timestamp. Thus MeshTerm now gives the
device the timestamp of each message it sends and stores exactly that second. A
retry of a DM keeps the timestamp and counts the try number, as the protocol
specifies. The emoji that ^E offers are the reaction_emoji preference. Rooms have
no reactions.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00
Jean-Pierre Martineau b54a93e5c4 Revert "Section headings are a creamy yellow"
This reverts commit 963cb2ec7d.
2026-10-15 03:06:13 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 963cb2ec7d Section headings are a creamy yellow
So they pop a bit more: #f5e5a8, pale and low in chroma, which keeps it
well clear of warn's amber (0.12 apart in OKLab) and title.warn's (0.085).
The PicoCalc draws its nearest slot, the bright yellow that warn uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 02:53:15 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 e0e8607639 Section headings take the frame title's colour
A section heading, in a grouped list or as a chat's day divider, was the
dark grey of faint. It now wears title.border, the colour of the title
in the frame around it: bold #d4d4d4, and white on the PicoCalc. It is
still never the border's own grey.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:43:53 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 30329e62a1 The border is #8f8f8f
#7c7c7c went too far. The border now sits halfway in lightness between
that and the old #a2a2a2, and on the PicoCalc it is back on the light
grey slot, the nearest one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:40:35 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 eeafbb18f6 Node marks are white, and the border is darker
Every node-type mark is white now: ● ▲ ■ ◉ were pink, violet, white,
and orange, and the shape already says the type. Each keeps its own
type.* style, so one can take a colour back in one place; the unknown ○
keeps its grey. On the map a crowd in one cell used to glow toward
white, which a white marker cannot do, so that signal is gone.

The border grey goes down to #7c7c7c, which JP picked on a ladder. That
sits almost on faint, so the title bar's live clip arrow is now lit like
the title instead of taking the border's grey. On the PicoCalc the
border is the dark grey slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:30:12 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 45697db856 Borders are grey
A plain border, the one with no caution tier, was the indigo accent: one
more colour on every screen, and it meant nothing. It is now its own
theme style, border, in the grey of muted, with title.border and
hint.border beside it; the amber and red tiers keep their colours. That
covers the base frame, every dialog, the handhelds' title bar, the tab
strip, and the CLI's panels. The title bar's clip arrow for a direction
with nothing more now dims to faint, since the border and muted are the
same grey. On the PicoCalc the border takes the light grey slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 23:21:24 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 05428bd007 Every grey is neutral
The Tailwind slate greys were cold. Each one is now r=g=b at its own
OKLab lightness, so every contrast stays where it was: the theme's
chrome, the path chips, the map's roads, labels and buildings, the heat
tail, and the emulator window. test_neutral_greys fails any #rrggbb
literal that reads as a grey and has a tint.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-14 23:08:27 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b5fc1f8930 The docs name both Cardputer Zero models and warn against hot-swapping a Cap
MeshTerm runs the same on the Cardputer Zero (C154) and the Cardputer Zero
Lite (C155): the Lite only leaves out a camera, motion sensors, and the
microSD card, none of which MeshTerm uses. The device manual, the README,
the manuals index, and the About page now say that both models are supported,
and the manual says that the Lite needs a card with M5Stack's system first.

M5Stack does not support hot-swapping a Cap on the Cardputer Zero, the Cap
LoRa-1262 included, and says that it may cause the device to malfunction.
The manual now puts that caution before its steps for the Cap, with M5Stack's
other caution (the antenna goes on before the power), and the steps turn the
handheld off first. It also says that MeshTerm switching the header off when
it quits does not make a Cap safe to remove. The About page says the same in
its own voice, and gains a section on the Cardputer Zero next to the uConsole
and the PicoCalc.

The README shows the Cardputer Zero's start screen (the store's capture,
three times its size) under the PicoCalc photo, and its quick start shows the
emulator command. The pip commands with the spi extra install from GitHub,
because mesh-term is not on PyPI yet. The platform comment and CLAUDE.md no
longer say that the hardware is not in hand: only a flag selects the platform
because the device-tree model is the CM0's generic string.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 02:59:46 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 9c1ca0c412 A release includes the Cardputer Zero app store
MeshTerm is in M5Stack's app store since 0.11.0 (CardputerZero/packages#176).
The release skill now treats the store PR as part of the release: it is not
done until that PR merges, and the Discord announcement tells Cardputer Zero
owners to update from the store. The device guide no longer says that the
store may not have MeshTerm yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 02:32:06 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7964f20889 Revert "A faded chip keeps its node's colours, with less chroma"
This reverts commit bd6df0b.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:55:11 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 bd6df0b9fa A faded chip keeps its node's colours, with less chroma
The part of a route that the user does not compose (a mirrored return
leg, the return home) drew every hop as the same dark slate chip. Each
faded chip now has the colours of its own node at FADE_CHROMA of their
chroma, at the same lightness, so it still says which node it is. The
PicoCalc has no colour of less chroma: there a faded hop keeps its label
colour and drops its fill. Arrows keep faint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:44:17 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 796ff94dad Our star is white
The star of our node had the hue of our key, set from the self-info at run
time. It is now the white of "you" on each surface: the map, the routes,
the lists, the legends, and the chat. The run-time colour, the device-state
hook, and the style generation of the render cache are removed. A faded
star keeps its grey.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:18:59 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 feea5a89db In a chat, our chip is white on our fill and unknown names are black
Our chip had our hue as a light fill under dark text. It now has the fill
that each known sender gets, with a white label, and the PicoCalc can draw
that too: white on the dim twin of the hue. The hop flag is white_ink now.

A chat chip with no key keeps its grey fill, and its label is black. On the
PicoCalc it is black on the light grey of the keyless chip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:06:28 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b680b172d7 Our star takes the hue of our key, and unknown chat names go grey
The star of our node was the yellow of the map. SELF_MARK now names a
theme style, you.mark, and the theme writes our key's hue into it when the
device-state cache reads the self-info. The context makes that connection,
so the services still do not import the UI. The map, the routes, the lists,
the legends, and the chat all draw it, and the render cache keys on a style
generation so no old bytes stay. A faded star keeps its grey.

In a chat, a sender or a mention with no key had the light blue keyless
chip. It is now quiet: the grey of plain text on that grey darkened by the
chip rule. Routes keep the keyless chip, arrow mode keeps node.unknown, and
the PicoCalc keeps the keyless pair.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 23:46:39 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 3fdc638812 In a chat, a star in our hue marks a message that names us
The point of our chip did not read well. The mark at the left of the time
is now the star, in the hue of our key: the colour of the mention chip in
the body. It is the same on each platform, with chips or without them. The
Messages list uses the same star at the left of the age.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 23:23:06 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 691170d342 In a chat, the point of our chip alone marks a message that names us
The notch and the point together were too heavy. The mark is now one cell,
the point of our chip in its fill, then a space before the time. Without
chips, it is ">" in our hue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 23:05:12 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 68d5d03324 In a chat, the ends of our chip mark a message that names us
The star at the left of the time becomes two cells in the fill of our
chip: the notch, then the point, with no label between them. Thus the mark
has the colour of the mention chip in the body. Without chips, it is ">>"
in our hue. The Messages list puts the same mark at the left of the age.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:42:28 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 427bee836b In a chat, our sender chip has its star inside again
Our messages go under "★ Name" on the turned-over chip, as before the last
change. A mention of us keeps its chip without a star, and its message
keeps the star at the left of the time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 22:28:47 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 8043881d3c In a chat, the star leaves the chip and a mention is a chip as you type
Our sender chip is our name, turned over, with the star on the page to its
right. A mention of us has the turned-over chip and no star: the message
that names us gets the star at the left of its time, so every time moves
two cells right, and the Messages list puts it at the left of the row's
age. A mention chip drops its @ where chips are drawn.

The compose line draws each complete @[Name] as its chip and treats it as
one atom: the arrows step over it, and Backspace or Delete removes all of
it. A reply inserts its mention at the cursor, so one message can name
several nodes. The prompt and the typed text are white.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:07:06 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 94f676d741 A release asks for a GitHub star in its Discord post too
The Discord announcement now carries a star ask under the download
link, with the reason. The release page's ask opens on a heart and
closes on a thank-you.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:04:41 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0c3a8b5b5c The space between authors in a chat is a preference
Two preferences in the Display group: "Author spacing" (auto, on, off;
default auto) and "Spacing from" (the smallest terminal, in rows, that gets
the blank line with auto; default 24). The test reads the real rows of the
terminal before each paint, so a small desktop window drops the line as the
Cardputer Zero does, and a change applies at the next paint. The transcript
cache stores the spacing, so a change draws the lines again. The CLI
manual's sample of `preferences show` is the real output again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:16:15 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1c678d43f3 An imported seed stays a seed on the uConsole and the Cardputer Zero
The key parsers now return the key in the form that the user gave. Each
device takes the form that it keeps: a firmware companion expands a seed
to the 64 bytes that it accepts, and the node of an SPI radio saves the
seed as it is, the same form as a key that it makes itself. A seed is the
more complete form, and the radio library signs with it through PyNaCl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 18:10:40 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 03b3c0926b In a chat, a blank line comes after the messages of each author
The transcript asked for a gap between sender groups, but an empty Text
renders as no line at all, so the gap never showed. A blank line now
closes each author's messages, also before a new day's divider, where
the platform has 20 rows or more. The 14 rows of the Cardputer Zero keep
the transcript dense. A cached slice that starts with the gap pins its
day divider one line down.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 02:00:31 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a19eadd81b In a chat, our messages go under our name
A channel, a direct chat, a room, and the Messages list show our messages
under "★ Name" instead of the bare star. The chip has our key's hue turned
over: dark ink on a light fill, where each other sender is light on dark.
A mention of our name takes the same chip, as "★ @Name". The PicoCalc
cannot draw a light background, so there the chip keeps the normal
colours and the star marks it. Path lines keep the bare star.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 01:56:13 -04:00
139 changed files with 14285 additions and 1212 deletions

No files matched your search

+55 -7
View File
@@ -135,10 +135,36 @@ alias to the gather step instead.
What *does* still carry the number: `docs/cli/README.md` has a sample document with a `"version"`
field, which is the live `{version}` placeholder and must show the new one.
### The screenshots and the video — made again, after the bump
Every release refreshes the screenshots (JP, 2026-10-16: "make it so the images are
automatically updated when we release") and the website's demo video (JP, 2026-10-17).
Both show the version in their header, so they run **after** `__version__` changes and
before the commit: follow the **`website-media` skill in release mode**. It runs
`scripts/screenshots.py` (~1 min) then `scripts/video.py` (~4 min), and says what to look
at before anything is committed.
The two land in different repositories:
- The screenshots (`docs/screenshots/`) ride in the release commit, so the tag carries
them and the `website` job builds meshterm.net from them. So does `README.md`, whose
video line `scripts/video.py --readme` points at the new upload.
- The video, its poster, and its play card go to the site checkout beside this one
(`../meshterm.net/src/`) and get a commit there ("The demo video of MeshTerm X.Y.Z"). The
`website` job builds from the site's `main`, so that commit is **pushed before the tag**
(step 5).
If either fails, stop and fix it, or ask JP whether to release with last release's
images and video — don't commit a half-written set.
## 5. Commit, tag, push
Stage by name, not with `-a`: other sessions work in the same tree, and `-a` would sweep
their unfinished edits into the release.
```
git commit -a -m "MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this skill> <noreply@anthropic.com>"
git add CHANGELOG.md meshterm/__init__.py docs/cli/README.md docs/screenshots README.md
git commit -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"
```
@@ -149,12 +175,17 @@ hardcoded one. Seven release commits (0.3.1 through 0.10.0) went out without it
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:
the push is the act that builds and publishes the release. The site goes first, so the
`website` job finds the new video on its `main`:
```
git -C ../meshterm.net push origin main
git push origin main && git push origin vX.Y.Z
```
The one confirmation covers both pushes: say that the site push carries the new video
(and any other site commits that are ahead of origin, by subject) when you ask.
This is the one workflow where pushing is expected. It is still asked for, once, in that
moment — the standing rule against unasked pushes is not suspended by the skill, it is
satisfied by the answer.
@@ -167,20 +198,29 @@ gh release view vX.Y.Z
```
Five platform builds, the release job, and `website` — which rebuilds meshterm.net from the
tag so the site shows the new version — so it takes a few minutes. If only `website` fails,
tag so the site shows the new version, the screenshots, and the video of step 4 — so it
takes a few minutes. Once `website` has pushed its "MeshTerm X.Y.Z" commit, bring the
local site up to date: `git -C ../meshterm.net pull --ff-only`. `website` runs the site repo's own `build.py`, so a change to the site's sources
must be pushed to `meshterm.net` before the tag, or the job builds with the old ones. If only `website` fails,
the release itself is fine (it runs after publishing); the usual cause is the
`SITE_DEPLOY_KEY` secret, and once that is fixed `gh run rerun <run-id> --failed` finishes
it.
`store` also runs after publishing, so its failure leaves the release fine too. It opens a
pull request on `CardputerZero/packages` (from JP's fork), and the store merges an update
by itself once its own check passes — no video after the first release. Confirm the PR
exists and its check passed:
by itself once its own check passes — no video after the first release. **The store is
part of every release** (JP, 2026-10-14: "when we release, include the Cardputer Zero App
Store"), so a release is not done until that PR is merged. Confirm the PR exists, its
check passed, and it merged:
```
gh pr list -R CardputerZero/packages --author jpmartineau --state all --limit 3
```
If it is still open, wait for the store's check (`gh pr checks <n> -R
CardputerZero/packages --watch`). A failed check is a release problem to fix, not a note
to leave for later.
If `store` failed, the usual causes are the `CARDPUTER_STORE_TOKEN` secret (missing, or the
token revoked) or a change in czdev: the job pins AppBuilder to one commit because
`scripts/cardputer-zero/store-publish.py` corrects two of its functions, and the script
@@ -232,9 +272,15 @@ the server, in plain words (they use MeshTerm; they don't read the diff):
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);
- one line for Cardputer Zero owners: the update is in M5Stack's app store on the device.
Say so only once the store PR has merged; while it is open, say it reaches the store
soon;
- right under the download link, a kind ask for a star on GitHub, opening on ⭐, with
the reason in a few words: stars give the project credibility and visibility, so more
MeshCore users find it;
- 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.
no emoji beyond one at the start if it fits and the star line's ⭐. Well under
Discord's 2000 characters — it should fit on a phone screen.
```
**MeshTerm 0.10.1 is out**: three small command-line fixes.
@@ -244,6 +290,8 @@ the server, in plain words (they use MeshTerm; they don't read the diff):
- `platform --help` and `specimen --help` read like the rest
Download: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
Cardputer Zero: update MeshTerm from the app store on the device.
⭐ If you enjoy MeshTerm, please star it on GitHub: https://github.com/jpmartineau/MeshTerm. Every star gives the project more credibility and helps other MeshCore users find it. Thank you!
```
Say who it was written for, then show it last in the reply, so it's the easiest thing to
+26 -6
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""Preflight for a MeshTerm release, and the answer to "what's in the next one?".
Reports the six things worth knowing before a version number is written down, and says
Reports the seven things worth knowing before a version number is written down, and says
nothing else: the current version and the tag it belongs to, the commits since, whether
there is an `[Unreleased]` section to rename, which changelog link refs are missing, and
whether the working tree is clean and level with `origin/main`.
there is an `[Unreleased]` section to rename, which changelog link refs are missing,
whether the working tree is clean and level with `origin/main`, and the same for the site
checkout beside it, where the release's demo video goes (the `website-media` skill).
It changes nothing. Every finding is a line a person acts on, not a gate that blocks.
"""
@@ -19,13 +20,14 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
INIT = ROOT / "meshterm" / "__init__.py"
CHANGELOG = ROOT / "CHANGELOG.md"
SITE = ROOT.parent / "meshterm.net"
OK, WARN, INFO = "ok ", "warn", " "
def git(*args: str) -> str:
"""Run a git command in the repo, returning stripped stdout ('' on failure)."""
done = subprocess.run(["git", "-C", str(ROOT), *args], capture_output=True, text=True)
def git(*args: str, repo: Path = ROOT) -> str:
"""Run a git command in ``repo``, returning stripped stdout ('' on failure)."""
done = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True)
return done.stdout.strip() if done.returncode == 0 else ""
@@ -115,6 +117,24 @@ def main() -> int:
behind, ahead = counts.split()
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
print(f"\nSite ({SITE.name}, where the demo video goes)")
if not (SITE / ".git").exists():
say(WARN, f"no checkout at {SITE}: the video cannot be made")
else:
dirty = git("status", "--porcelain", "--", "src", repo=SITE)
count = len(dirty.splitlines())
say(OK if not dirty else WARN, "src/ clean" if not dirty else f"src/: {count} change(s)")
git("fetch", "--quiet", "origin", repo=SITE)
counts = git("rev-list", "--left-right", "--count", "origin/main...HEAD", repo=SITE)
if counts:
behind, ahead = counts.split()
say(OK if behind == "0" else WARN, f"{ahead} ahead / {behind} behind origin/main")
if behind != "0":
say(INFO, "pull --ff-only before making the video")
# These go out with the release's site push, before the tag.
for subject in git("log", "--format=%s", "origin/main..HEAD", repo=SITE).splitlines():
say(INFO, f"ahead: {subject}")
print("\nRun the gates before tagging:")
print(" python -m pytest -q && ruff check . && ruff format --check .\n")
return 0
+182
View File
@@ -0,0 +1,182 @@
---
name: website-media
description: Make the website's demo video and screenshots again from the demo mesh — the terminal video on meshterm.net and in MeshTerm's README (scripts/video.py) and the twelve screenshots of the README and the website (scripts/screenshots.py) — check them, upload the video for the README, and commit them in both repositories. Use when JP asks to make, redo, refresh, or update the video, the demo recording, the screenshots, the website's images, or the website after a visible change; the release skill also runs it after each version bump.
---
# Making the website's video and screenshots
The two always go together (JP, 2026-10-17: "include it with the screenshot updates when we
update the website, manually or automatically when we release"). Both come from the demo
mesh (`meshterm --demo`), headless, and both show the version in their header, so a set
made before a bump is already stale.
| What | Script | Where it goes | Time |
|---|---|---|---|
| 12 screenshots + 2 splash wordmarks | `scripts/screenshots.py` | MeshTerm `docs/screenshots/` | ~1 min |
| The demo video, its poster, its play card | `scripts/video.py` | meshterm.net `src/demo.mp4`, `src/demo-poster.png`, `src/demo-play.png` | ~4 min |
| The README's copy of the video | `scripts/video.py --readme` | a GitHub attachment, and its URL in MeshTerm `README.md` | ~10 s |
**The video is terminal-only, on purpose** (JP, 2026-10-17: "it's marketing after all").
Don't offer handheld videos again.
**Why the video lives in the site repository and not in the tag:** it is ~4 MB, and it
changes at every release (the version is on screen). In MeshTerm, that would grow every
clone of the code by 4 MB a release. The site repository already keeps the copy it serves.
The cost is one more push at release time (see *Release mode*).
## Before you start
- The site checkout must sit beside this one, at `../meshterm.net`. The video script
writes there by default and stops if the folder is missing.
- The site must be in sync and clean where the video goes:
```
git -C ../meshterm.net fetch -q
git -C ../meshterm.net status -sb
```
If it is behind (the release workflow's `website` job pushes a "MeshTerm X.Y.Z" commit
each release), `git -C ../meshterm.net pull --ff-only`. If `src/demo*` has uncommitted
changes you didn't make, stop and ask.
- Needs: the `dev` extras (Pillow, fontTools, time-machine), the emulator's Terminus font
(`meshterm emulate --fetch-fonts`, once), Windows' Cascadia Mono and Segoe UI fonts, the
network for the map's tiles the first time, and ffmpeg with libx264 on the `PATH`.
## 1. Run both, one after the other, in the background
```
.venv/Scripts/python scripts/screenshots.py
.venv/Scripts/python scripts/video.py
```
One background command for both, in this order. **Never in parallel**, and don't run the
test suite beside them: the video's tour runs on the real clock and its painter uses every
core. Both are driven by waits on screen text, so a slow machine only makes them slower.
The video prints its length and size at the end (`recorded 118.9 s, 189 screens`, then
`demo.mp4 (4001 KB)`).
## 2. Look at what you made
A green run is not a correct picture. Check both:
- **Screenshots:** `git status --short docs/screenshots`, then open one image of each set.
Only the version in the header should change, plus whatever this change set out to
change on those screens. Anything else is a bug to understand, not a picture to ship.
- **Video length:** under **2:00**. The site's caption says "Under two minutes". If it
went over, shorten holds in `_tour` (`p.hold(...)`), never the scenes JP asked for.
- **Video content:** make a contact sheet and look at it:
```
ffmpeg -v error -y -i ../meshterm.net/src/demo.mp4 -vf "fps=1/4,scale=400:-1,tile=6x5" -frames:v 1 <scratchpad>/sheet.png
```
The tour, in order (JP chose each part; keep them all):
1. the main menu;
2. Chat → Public → the paths of Gabriel's message from Beloeil, selected 1, 2, 1, 2;
3. Chloé's message arrives live; we type "Hey, I wonder if @[Dave-Lachine] is coming
Saturday?" — the mention turns into a chip when the `]` is typed — and Dave answers
with a mention of us, so his message carries our white ★;
4. Trace target → Brossard, 2 bytes per hop (our node uses 3; a trace collapses it to
2), the "5 new records" dialog, then the per-hop table;
5. the map: pan, ^L to our node, zoom 10 → 16 until street names and buildings show;
6. Repeater admin on Mont-Royal: Read settings (the video jumps over most of it), then
Send advert;
7. the live feed, one packet opened, and back to a clean main menu.
Also open `src/demo-poster.png`: it must show the paths dialog with the **second** path
selected.
## 2b. Upload it for the README — only once the video is right
MeshTerm's README plays its own copy of the video: GitHub plays a video in a README only
when the file is one of its own attachments. Once the video passed step 2:
```
.venv/Scripts/python scripts/video.py --readme
git diff README.md
```
It uploads `../meshterm.net/src/demo.mp4` through GitHub's undocumented attachment endpoint
(`uploads.github.com/user-attachments/assets`, with the `gh` token: no issue, no browser),
and replaces the one `https://github.com/user-attachments/assets/…` line of `README.md`.
The diff must be that one line. Each upload is public and permanent (GitHub cannot delete
an attachment, and the old ones stay, unlinked), so **never run it on a video you haven't
checked**, and never in a loop.
If GitHub refuses it (the endpoint is undocumented: it can change or go away), the README
keeps the previous video, which is wrong but not broken. Tell JP. The fallback is the web
page: he drops `src/demo.mp4` into a comment box on the repository and gives you the URL to
put on that line.
## 3. When it fails
`video.py` prints `the recording failed (1)` after a traceback. Read the traceback's last
line: a `TimeoutError` carries the whole screen as it was.
- **A wait never matched** (`'…' did not appear`): a UX string the tour waits for changed,
or a dialog the tour doesn't expect is in the way. The video's own history: the trace
once got a records dialog when the path width changed, and a menu filter "live" matched
Courier first. Fix the tour in `scripts/video.py`, not the app — unless the screen shows
an app bug, which then gets fixed and reported to JP.
- **The trace says "no reply":** the demo's route to that target includes a link the
world doesn't have. Brossard is the target because its route always works; see the
`_tour` comment before changing it.
- **To iterate on painting or encoding** without recording again, pass
`--recording <scratchpad>/rec.pickle`: the first run keeps the recording there, a later
run with the same path only paints and encodes. Never keep a recording across a change
to the app.
If you cannot make it work, stop and ask JP whether to keep last time's video. Never ship
half a set.
## 4. Commit
MeshTerm, by name (other sessions share the tree):
```
git add docs/screenshots README.md
```
The site, the three source files only:
```
git -C ../meshterm.net add src/demo.mp4 src/demo-poster.png src/demo-play.png
git -C ../meshterm.net commit -m "The demo video of MeshTerm X.Y.Z" -m "Co-Authored-By: <the model running this> <noreply@anthropic.com>"
```
Then one of the two modes.
### Manual mode — JP asked for the website now
Commit the screenshots and the README's new video URL in MeshTerm ("The screenshots
show …", what changed).
Then rebuild the site so its `docs/` serves the new files:
```
cd ../meshterm.net && ../MeshTerm/.venv/Scripts/python build.py
```
`build.py` writes seven derived images again with new bytes even when nothing changed.
Unless the splash art changed (`docs/screenshots/splash/` in MeshTerm), restore them:
```
git -C ../meshterm.net checkout -- docs/assets/apple-touch-icon.png docs/assets/logo-96.png docs/assets/og.png docs/assets/social-preview.png docs/assets/splash-narrow.png docs/assets/splash.png docs/favicon.ico
```
Commit the built `docs/` with the source files (one commit). **Don't push**: tell JP the
site is committed and ahead of origin. The live site changes only when he pushes it. Note
that the screenshots the site shows come from the MeshTerm checkout, so they are the
working tree's, not a release's.
### Release mode — called from the release skill, after the bump
Don't build the site locally: the release workflow's `website` job builds it from the tag
and from the site's `main`. Commit the site's three source files as above, and the
screenshots and the README ride in the release commit (the release skill stages
`docs/screenshots` and `README.md`).
**The site's commit must be pushed before the tag**, or the job builds with last
release's video. The release skill's push step does it, in the same confirmed push. After
the job has run, `git -C ../meshterm.net pull --ff-only` picks up its "MeshTerm X.Y.Z"
commit.
+7
View File
@@ -22,6 +22,13 @@ body:
The **Diagnostics** page in the menu saves the same file next to the log. Press `s`,
or F3 on the PicoCalc.
The file and the log can show your account name (in the path of your home folder)
and the address of your radio. To mask them, add `--anonymize` to the command, or
press `a` on the page. To mask them in the log too, turn on **Anonymize** on the
Preferences page (`meshterm preferences set anonymize on`) before you make the
problem occur. Each masked value keeps a few characters, so that I can still tell
two radios apart.
The rest of this form asks only for the information that the file cannot know.
- type: textarea
+1 -1
View File
@@ -208,7 +208,7 @@ jobs:
---
If MeshTerm is useful to you, please give [the repository](https://github.com/jpmartineau/MeshTerm) a ⭐. It helps other MeshCore users find it, because GitHub ranks projects by stars in its search and topic pages.
You ❤️ MeshTerm? Please give [the repository](https://github.com/jpmartineau/MeshTerm) a ⭐. It helps other MeshCore users find it, because GitHub ranks projects by stars in its search and topic pages. 🙏
STAR_MD
cat install.md notes.md star.md > body.md
echo "--- body ---"; cat body.md
+203 -1
View File
@@ -9,6 +9,204 @@ can change how things behave. It does not only add new things.
## [Unreleased]
## [0.11.5] - 2026-10-22
**The Courier outbox is a table, and Enter opens the message of an entry.** Each section of
the outbox has column headers, and each row shows its full message. A table that is wider
than the screen pans with ←→, and does not cut its rows. A footer hint that is too wide
keeps its Esc.
### Changed
- **The Courier outbox is a table with column headers.** A waiting entry shows TARGET,
TRIGGER, and MESSAGE. A finished entry shows TARGET, OUTCOME, and MESSAGE. Nothing is
shortened to fit. When the table is wider than the screen, ←→ pan the rows and their
headers together, and the footer says so. The column headers stay at the top with their
section heading while the section scrolls. Typing still narrows the outbox.
- **Enter on a Courier entry opens its message.** On a waiting entry, Enter opens the
message so that you can edit it. The courier holds the entry while the prompt is open, so
the old text cannot go out while you edit. On a finished entry, Enter shows the message.
The menu of an entry is gone: ^R sends a waiting entry now, and Del deletes an entry after
a red confirm. On the PicoCalc and the Cardputer Zero, `Send` is on F3, with `Delete`
behind it on Shift+F3.
- The message field of the Courier has the colours of the chat's compose line: a white
prompt mark and white text.
### Fixed
- **A footer hint that is too wide keeps its Esc.** The footer line cut a hint that was too
wide at the right edge, and Esc, the last atom of each hint, went with the cut. Now the
footer removes atoms in front of Esc, as the splash already did.
- **A cut in the text next to a chip ends on an ellipsis.** When a row had a chip and then
text, and the text ran past the screen, the cut drew a half block in the colour of the
chip at the right edge. Now the half block is only for a cut inside a path, and a cut in
plain text ends on `…`.
## [0.11.4] - 2026-10-20
**You can lock a contact from more places, and a serial radio that stops when your laptop
wakes from sleep opens the reconnect dialog.** In the archive preview, ⇧Del keeps a contact
out of each sweep that follows. In a conversation and on the Contacts list, ^K locks a
contact or unlocks it, and a locked sender has a padlock on its chip. The battery gauge of
the Cardputer Zero stays on with the Cap.
### Added
- **In the archive preview, Shift+Delete keeps a contact out of the sweep and locks it.**
Delete keeps a contact out of this sweep only. ⇧Del also locks the contact, so that no
sweep that follows can take it. MeshTerm writes the lock when you select Apply, with the
archive. Esc and Back ask before they discard a lock that is not written yet. If you lock
each contact in the preview, Apply only writes the locks. On the PicoCalc and the
Cardputer Zero, `Lock` is on Shift+F3, behind `Delete`.
- **^K locks the author of a message in a channel, a direct chat, and a room.** Select a
message and press ^K. Press it again to unlock the author. The status line tells you what
the key did. On the PicoCalc and the Cardputer Zero, `Lock` is on Shift+F2 in a channel
and in a direct chat. In a room, Shift+F2 is `Log in`, so there the page of the author
(Shift+F3) has `Lock contact` or `Unlock contact`.
- **^K on the Contacts list locks the highlighted contact or unlocks it.** The padlock of
the row changes at once. On the handhelds, `Lock` is on Shift+F2.
- **A locked sender has a padlock on its chip in a chat.** The chip reads `🔒 Name`
(`⚿ Name` on the PicoCalc), in the place where our own chip has its `★`.
### Changed
- **The selected message of a chat makes its chips white.** The text of the selected
message was already white. Now the mention chips in it, and the chip of its author, have
a white label too. Each chip keeps its fill, so you can still tell the nodes apart. A
mention of an unknown node is white on its grey fill.
- In the archive preview, the footer says `Enter open` on a contact, because Enter opens
its node page.
### Fixed
- **A serial radio that stops when your laptop wakes from sleep opens the reconnect
dialog.** When a laptop wakes, the kernel can reset the USB device and close the open
port, but keep the same device file. MeshTerm still saw the port in the list of ports, so
it thought that the link was up. No reconnect dialog opened, and each command failed with
`no_event_received` (issue #32). Now the check also reads the connection flag of the
client, as the Bluetooth and TCP checks already did.
- **The battery gauge of the Cardputer Zero stays on with the Cap.** On battery with the
Cap, most reads of the gauge failed, sometimes for almost two minutes. The gauge read
three times in 11 seconds and then waited 20 seconds, so it missed the good periods.
After seven failed reads, the gauge went away. Now MeshTerm tries again 5.5 seconds after
a failed read, and the last good reading stays for 10 minutes. A change between charging
and discharging must be read two times, because a bad read said "Charging" while the
battery discharged.
## [0.11.3] - 2026-10-18
**No log keeps a password or a secret, and a bug report can hide your home folder and the
address of your radio.** Passwords, channel secrets, and pairing PINs went into the log and
the run log as plain text, and this release removes them, also from the old records. The new
**Anonymize** preference masks your personal details in the log and in the diagnostics. The
demo mesh now looks more like a real one.
### Added
- **The Anonymize preference masks your personal details in the log and in the
diagnostics.** It is in the Diagnostics group of the Preferences page, and it is off by
default. When it is on, MeshTerm masks the account name in your home folder, the
Bluetooth address of your radio, IP addresses, and the name of a network companion. Each
masked value keeps a few characters, in the way that a card number prints as
`51xx xxxx xxxx 4571`: `C:\Users\j*********u`, `DA:xx:xx:xx:7C:7C`, `192.xxx.x.180`, and
`m******e.local`. Thus a maintainer can still tell two radios apart. The log masks each
line as MeshTerm writes it, and a change applies at once. A line that MeshTerm wrote
before you turned the preference on stays as it is.
- **The diagnostics can be anonymized for one report.** `meshterm diagnostics --anonymize`
masks the report, and `--no-anonymize` shows it in full when the preference is on. On the
Diagnostics page, press `a` (Shift+F3 on the PicoCalc) to change between the two forms,
and `s` saves the form that you see. A new fact, `anonymized`, says which form a report
is.
### Changed
- **The demo mesh uses the regions of the real mesh.** The regions are `na`, `can`,
`onqc`, `qc`, and `yul`, each one inside the one before it. The `#montreal` channel sends
in `yul`. The invented regions are gone.
- **In the demo, messages arrive as they do on a real mesh.** Most messages arrive two
times, through the two repeaters that our node hears, and most paths use three bytes for
each hop. A node that our node mentions in a channel answers a few seconds later. TX
optimize now has something to find: the power of a repeater changes how well the next
node hears it.
### Fixed
- **The log and the run log no longer keep passwords or secrets.** At the DEBUG level, the
log file showed the parameters of each command as you typed them, and the run log in the
database kept them at each level. Thus they held room and admin passwords, channel
secrets, channel links, and the pairing PIN of `config set device_pin`. Each chat run also
stored the secret of its channel. Now MeshTerm writes `<secret>` in the place of each
one, in the log file, on the console, and in the run log, always. The rule finds a secret
by its name, by its form (a remote `password` command, or `secret=` in a link), and by
its value, when MeshTerm has read or sent that secret before. The first start of this
version removes the secrets that earlier versions stored in the run log.
- **The remote command line no longer stores a new password in its history.** The history
keeps `password` and `set guest.password` without the value, so ↑ still recalls the
start of the command.
- An error about the device PIN no longer repeats the PIN that you typed.
## [0.11.2] - 2026-10-16
**In a chat, your messages show your name, MeshTerm has a demo mesh, and the colours are
calmer.** Your messages and each mention of you are now easy to find in a chat.
`meshterm --demo` runs a whole mesh over Montréal with no radio. The greys have no blue in
them, the borders are grey, and the node marks are white.
### Added
- **`meshterm --demo` runs MeshTerm in a demo mesh over Montréal.** No radio is
necessary. The demo has approximately forty invented nodes: repeaters on the high points
of the city, room servers, sensors, and the people of the mesh. It has a week of traffic,
conversations on the channels, in a room, and in direct messages, and traces with their
records in the trophy case. Each run starts from the same state, in a directory of its
own, so your history never gets a node of the demo. The screenshots in the README and on
the website come from it.
### Changed
- **In a chat, your messages show your name.** A channel, a direct chat, and a room put
your messages under `★` and your node's name, and the Messages list shows them in the
same way. Your name is white, on the same colour of box that your key gives each chip,
where each other sender has the colour of its key as the text. A mention of your name
has the same white text, and the message that has it gets a white `★` at the left of
its time. Thus the messages that name you are easy to find down the left side of the
chat. Before, your messages showed only the `★`.
- **Your node's star is white.** The `★` that marks your node on the map, in routes, in
lists, and in a chat is white, the same white as your name in the lists. Before, it was
yellow. A star in a faded part of a route stays grey.
- **The greys are neutral, and the borders are grey.** Each grey of MeshTerm is now a
neutral grey, with no blue in it, at the same lightness as before. A plain border is
grey, where it was indigo. The amber and red borders of a caution keep their colours.
- **Each node mark is white.** The marks of a companion, a repeater, a room server, and a
sensor (`●`, `▲`, `■`, `◉`) are white, because the shape already shows the type. Before,
they had four colours. The mark of an unknown node (`○`) stays grey.
- A section heading has the colour of the title of its frame, and it has no icon.
- **In a chat, a name that MeshTerm cannot match to a node is black on grey.** The box
around a sender or a mention with no known node has black text on a grey box. Thus it
does not stand out among the coloured names. Before, it was light blue on slate.
- **A mention has no `@` in front of it.** The coloured box around the name already says
that it is a mention. On a terminal that cannot draw the boxes, a mention keeps its `@`.
- **The line where you write a message shows each mention as a box.** When you type the
`]` of `@[Name]`, or reply to a message, the mention changes into the same coloured box
that the chat shows after you send it. The arrow keys move over the box in one step, and
one Backspace removes all of it. A reply now puts its mention where the cursor is, so you
can name more than one person in one message. The text that you type is white.
- **On the uConsole and the Cardputer Zero, an imported seed stays a seed.** MeshTerm saves
a 32-byte seed in the node's `identity.key` as you give it. This is the same form that
the node uses when it makes its own key. A firmware companion still gets the full 64-byte
key, because it accepts only that form.
- **A blank line comes before each section.** In each list that has sections (the main
menu, the Messages list, the Preferences page, Device config, and the others), the line
comes before each section heading. In a chat, it comes after the messages of each author
and before each new day. Thus the parts of a screen are easier to find, and a
conversation is easier to follow when many people write. By default, the line shows on a
terminal of 24 rows or more. A smaller terminal, and the 14 rows of the Cardputer Zero,
keep each row for content. The new **Section spacing** preference in the Display group
sets the smallest terminal that gets the line: any height, 20, 24, 30, or 40 rows, or
never.
- The About page has a section on the Cardputer Zero. It says that MeshTerm runs on the
Cardputer Zero and on the Cardputer Zero Lite.
## [0.11.1] - 2026-10-13
**Show private key shows the key again, and the uConsole and the Cardputer Zero can import
@@ -540,7 +738,11 @@ commands.
MeshTerm is free and open source, under the Apache 2.0 licence. The licence does not cover
the name and the logo (refer to `NOTICE`). A fork is welcome under its own name.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.11.1...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.11.5...HEAD
[0.11.5]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.5
[0.11.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.4
[0.11.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.3
[0.11.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.2
[0.11.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.1
[0.11.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.11.0
[0.10.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.3
+100 -19
View File
@@ -269,19 +269,32 @@ deliberately, one at a time, and say why in the code.
screen's results were once windowed under its actions: the page came out exactly the
screen's height, so ↓ on the last action did nothing while `↓ n more` sat under it.
- Grouped-list section headings use `section_heading("Label")` → `── Label ──` in the
bold `heading` grey, never the frame's accent: a rule in the border's colour reads as
part of the frame. A chat's day divider wears the same dress. That is also what makes a heading *sticky* (it pins to the top row while its section
bold `heading` style — the frame title's colour, `title.border` (JP, 2026-10-15) —
never the `border` grey: a rule in the border's colour reads as
part of the frame. A heading is words only, never an icon — icons live in rows (JP,
2026-10-15: Chat's `📡 Channels` and the trophy case's marks came off for a cleaner
look). A chat's day divider wears the same dress. That is also what makes a heading *sticky* (it pins to the top row while its section
scrolls, and the ^PgUp/^PgDn jumps step by it), so build them through it — a hand-rolled
`Separator` is no landmark. Prose written *directly under* a heading (a description of
what the section holds) is its preamble and pins with it, in order, as each row scrolls
off; prose after the section's first row labels nothing and never pins.
A heading is also *spaced*: a blank line goes before it wherever the terminal is at
least `section_spacing_min_rows` tall (one preference, read through
`Screen.section_spacing`, which the chat's author and day breaks read too) — never at
the top, after a blank line, or under another heading or a column header. So don't
hand-place a `Separator(" ")` before a heading for space: it would show where the
preference says no. A blank separator there is only ever the seam between two
*different* lists, each with its own icon column (the region editor's table and its
actions); the heading then adds no second line.
- Label + description command rows go through `menu_rows` (two cell-aligned lanes,
description muted). Editor rows (setting/value/description) go through `lane_row`.
- A row that opens further prompts ends with `…`; a row that acts immediately doesn't.
- **A bare-letter shortcut only where nothing is typed.** A screen that filters, finds or
composes as you type has no free letters, so its verbs are Ctrl chords — one table,
`session._CTRL_LETTER_CHORDS`, each letter the mnemonic of its action where it can be
(`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page; `^G` logs in to a room,
(`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page, `^K` toggle the lock of
the author or of the Contacts list's highlighted contact, `Lock` on Shift+F2 in both —
JP, 2026-10-19; `^G` logs in to a room,
JP's pick, 2026-10-11). One verb per letter across screens is the preference, not the
rule (JP, 2026-10-10): a screen that has no use for a letter's verb may give the
letter a verb of its own, so long as nothing between the keyboard and the app takes it —
@@ -404,7 +417,9 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- Modal popups over the pushed backdrop (never full-screen replacements).
- Buttons are reverse-video chips (` Label `, `selected` style): safe way out on the
left, the committing verb on the right **and default**, so Enter commits and Esc backs
out. Two escalating caution tiers theme the frame: `danger=True` (amber) for a
out. A plain border is the `border` grey (`title.border`/`hint.border` its title and
hint) — never a hue: a border is chrome (JP, 2026-10-14; it was the indigo `accent`).
Two escalating caution tiers theme the frame: `danger=True` (amber) for a
disruptive choice — discard edits, reboot, show private key — and `destructive=True`
(the reserved red) for irreversible **data loss**, so a delete confirm reads red like
its `typed_confirm` sibling. Bulk or irreversible deletions gate behind `typed_confirm`
@@ -417,8 +432,17 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- Status marks (single-width, themed): `✓` ok · `✗` err · `⚠`/`?` warn · `●` unread/
unacked (err) · `○` acked/empty (muted). Never `✔`, `✖`, `✅`, `❌` as status.
- Node types (shared with the map): `★` you (yellow) · `●` companion · `▲` repeater ·
`■` room server · `◉` sensor · `○` unknown.
- Node types (shared with the map): `★` you (white) · `●` companion · `▲` repeater ·
`■` room server · `◉` sensor · `○` unknown. **Every type mark is white** (JP,
2026-10-14; they were pink, violet, white, orange): the shape is the type. Each keeps its
own `type.*` style so one can regain a colour in one place. `○` keeps `node.unknown`
grey, which says "unidentified". On the map a crowd in one cell no longer glows — a white
marker has nowhere brighter to go, and JP chose to drop the signal.
- **Our `★` is white, everywhere** (JP, 2026-10-14): map, routes, lists, legends, chat.
`marks.SELF_MARK` carries the theme name `you` — the white of our name — so the console
picks its own white, and a raster resolves it through `mark_rgb`. It was the map's
yellow, then briefly our key's hue (set from the self-info at run time), and both were
dropped. A star something *mutes* keeps its grey — a faded hop, a dimmed return leg.
- Path cuts: a path line drawn as **chips** that gets cut — a lane that ran out, a row
scrolled past its edge — breaks the chip off on a half block in that chip's own fill
(`▐` ends a line, `▌` opens one; `pathline.cut_mark`/`cut_to`), so half the cell is
@@ -429,7 +453,7 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
origin and destination, never the first and last *relay*. Our own end is always the
app-wide `★` (`pathline.SELF_GLYPH`, taken straight from `marks.SELF_MARK`), never our
name: the one node the reader never has to be told, and the cells belong to the hops
that differ from row to row. As a chip it is the map's yellow star on neutral dark
that differ from row to row. As a chip it is the map's white star on neutral dark
grey, padded like every other chip.
- **A path chip is the node's name colour on a darker ground of itself.** The label is the
hue `node_style` gives the name on every other surface, so a node is one colour in a
@@ -466,7 +490,9 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
- **A lone chip can stand at one end of a route it doesn't draw** (`widgets.hop_chip` /
`name_chip(role=…)`). A message goes from its author toward the nodes it names, so a
sender is an `ORIGIN` — square (or round) on the left, the point on the right — and an
`@mention` is a `DESTINATION` chip, `@` kept, opening on the notch and ending square. No
`@mention` is a `DESTINATION` chip (`widgets.mention_chip`), opening on the notch and
ending square, its `@` dropped because the chip already says "mention" (JP, 2026-10-13;
arrow mode, with no chip, keeps `@Name`). No
role is a node alone, square at both ends. Where it applies: the chat's sender chip and
mentions, the Messages list's preview (author chip, no colon; mentions), the packet
viewer's `from` (origin) and `to` (destination) rows around the `via` chain, the live
@@ -477,6 +503,33 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
`sender → recipient`), and a lone hop carries no `PATH_INK`, so a cursor row whitens it
like any name. A chip in prose never breaks across lines (`render_hanging` holds every
`whole` run together, spaces and all).
- **In a chat, we go by our name** (channel, DM, room, and the Messages list's preview —
nowhere else; JP, 2026-10-13). A route's bare `★` says "you already know who this is",
but a chat is a conversation between names, so our sender chip and every mention of our
name wear our name **in white on the normal fill of our key's hue** — the fill every
known sender gets, with white where they have their hue (`PathHop.white_ink`; JP,
2026-10-14, after a turned-over light chip read wrong). The PicoCalc draws the same
pair, white on the hue's dim twin. Our **sender** chip is `★ Name`
(`widgets.self_chip`), the star in the white label; the star on the page right of the
chip was tried and put back (JP, 2026-10-13). A **locked** sender's chip carries the
padlock in the same place, `🔒 Name` (`author_chip(locked=…)`, `marks.LOCK_ICON`, `⚿` on
the PicoCalc; JP, 2026-10-19). A **mention** of us is the same chip
with no star (`mention_chip(you=True)`); the message carrying it gets **our white
`★`** left of its time instead (`widgets.mention_mark`, the same on every platform and
in arrow mode). The map's yellow star, the chip's notch-and-point pair, its point alone
and a star in our hue were all tried there and dropped (JP, 2026-10-13/14). Every time sits two cells right to
keep the column (`❯`, a space, the star, a space, then `12:34 body`), and the Messages
list puts the star left of the row's age. Arrow mode draws the chip as a padded block
in the same pair. With no name or key from the device, the bare `★` chip stays. Path
lines (delivery paths, the packet viewer, routes) keep `★`.
- **The compose line draws a mention as its chip** (`ui/compose.py`, `MentionEditor`): the
moment `]` completes an `@[Name]` token it renders as the same chip the transcript will
show, and from then on it is an **atom** — ←/→ step over it, ^←/^→ never stop inside
it, Backspace at its end and Delete at its start remove all of it. The cursor on a chip
turns it into the composer's white slot chip; a chip past the byte limit goes red. The
text underneath is unchanged (`@[Name]` is what is sent and counted). Enter on a picked
message inserts its mention **at the cursor**, so one message can name several nodes.
The `›` wears `cursor` white and the typed text the `compose` white (JP, 2026-10-13).
- Concept icons: 📡 advert · 🕒 clock/sync · 🔄 reboot · 💾 backup · 📂 restore ·
🔑 channel/credential key · 🔐 identity/auth secret ·
🔒 lock (a locked contact, a private channel) · 🔓 unlock · 🗑 clear/delete · ✎ compose/edit ·
@@ -492,6 +545,13 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
### Colour
- **A grey is neutral: r = g = b** (JP, 2026-10-14). Every grey MeshTerm defines — chrome,
a keyless chip, a minor road, the emulator's window — has no tint, warm or cold; the
Tailwind slates were each re-made at their own OKLab lightness (`#94a3b8` → `#a2a2a2`,
`#64748b` → `#737373`, `#334155` → `#404040`), so every contrast stayed put. A colour
that has a meaning gets real chroma instead. `tests/test_neutral_greys.py` fails any
`#rrggbb` literal under OKLab chroma 0.045 that isn't r=g=b. Part of a wider pass: a
colour that carries no meaning is on its way out.
- Node names are always coloured: `name_style(name, key)` palette hue, derived from the
node's key (its first byte — any known prefix agrees) so a rename keeps the colour.
One rule on **every** platform; only the resolution changes (see **Platforms**).
@@ -501,12 +561,19 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
live claimants; outside that window the newest key wins, because a reflash makes a new
key routinely; JP, 2026-10-11), a bare hash standing in as a name, an `○` ring — takes
`node.unknown`, THE light grey for an unidentified node, everywhere and on both
platforms. Colour is reserved for keyed identities, never seeded from a name's
platforms. One exception, a chat's **chips** (`PathHop.quiet`, via `widgets.author_chip`
and `mention_hop`): an unknown sender or mention there is **black** on a grey fill
(`#cccccc`, the grey of plain text, at `CHIP_LIGHTNESS`), so it stands back among the
coloured senders (JP, 2026-10-14; a plain-text grey label read as still lit); routes
keep the keyless chip, arrow mode keeps `node.unknown`, and the PicoCalc draws black on
its keyless light grey.
Colour is reserved for keyed identities, never seeded from a name's
characters. `node.unknown` is deliberately not `muted`: muted is chrome and may sit a
step darker, while an unidentified node is content you can still act on. Our own node
is the pure-white `you` style; a context colouring (chart quality) may still win.
- **White is what "you picked this" looks like**, and nothing else in the app is allowed
to say it. The cursor row — the row `❯` points at, in a select list or in a screen
to say it — beside the `you` white and the white node-type marks, whose shape carries
their meaning. The cursor row — the row `❯` points at, in a select list or in a screen
drawing its own rows — wears `cursor` (white), never `brand`: the wordmark's teal is the
app's identity, not a selection, and teal/cyan is itself a node hue, so a cyan-keyed
node used to vanish into its own highlight. White is outside the node spectrum, so it
@@ -525,9 +592,14 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
what a route graph's one-byte labels are matched by — so `pathline` stamps its own extent
(`PATH_INK`, a style that draws nothing) and the fold skips what lies inside it. That is
what makes the arrow form say what the chip form always said, its fills having never been
in the fold's vocabulary. Reverse-video chips — a dialog's committing button,
in the fold's vocabulary. The one exception is a chat's **selected message**: its mention
chips and its group's author chip go white with its text (JP, 2026-10-19) — the label
only, the fill kept (`widgets.picked_hop`: `white_ink` on a chip, `cursor` in arrow mode).
An unknown node goes white too: its grey fill already says it is unknown, and white reads
better on it than its black label. A mention or an author is a name in a conversation,
not a hop of a route. Reverse-video chips — a dialog's committing button,
a running action's Abort, the arrow-mode composer slot, the editor cursor — are the
`selected` fill, a **grey** block (`muted`'s slate; light grey slot 7 on the VT, the one
`selected` fill, a **grey** block (`muted`'s grey; light grey slot 7 on the VT, the one
grey a reverse can put behind text there, and the same fill the F-key lane uses). A chip
marks where a press lands; it is chrome, and it does not get a hue.
Recency heat colours heard/first-heard ages, never names — seven steps on plain human
@@ -705,17 +777,21 @@ derived sweep that runs **every** registered command twice, once plain and once
One codebase, three flavours: **regular** (desktop/ssh, 72 cols, truecolor, emoji),
**picocalc-lyra** (the PicoCalc's 53×26/53×40 framebuffer console, 16 palette slots, a
512-glyph font, no emoji), and **cardputer-zero** (M5Stack's Cardputer Zero, 53×14 in 6×12
cells on a 320×170 panel MeshTerm paints itself, truecolor, no emoji — hardware not
yet in hand, so it is chosen by flag only).
512-glyph font, no emoji), and **cardputer-zero** (M5Stack's Cardputer Zero, both the
standard model and the Lite, 53×14 in 6×12 cells on a 320×170 panel MeshTerm paints
itself, truecolor, no emoji — chosen by flag only: its device-tree model is the CM0's
generic string, so MeshTerm's own console passes the flag). Never hot-swap a Cap on it,
the LoRa one included: M5Stack says it may make the device malfunction, so the docs say
to power off first.
A frozen `Platform` spec (`meshterm/platforms.py`) resolves
once at boot; consumers bind at platform-switch time via `platforms.on_platform` — never
branch on the platform per frame, and never `from meshterm.platforms import PLATFORM`.
- The borderless title bar (`frame._title_bar`) is this platform's whole panel border:
`↑↓ ──── Title ─────── Esc back`. The **clip arrows lead the row and are always both
drawn** — colour reads the scroll (the border's `accent` where that direction has more,
`muted` where it doesn't), the same live/dim language the lane draws one row below. A
drawn** — colour reads the scroll (lit like the title, `title.border`, where that
direction has more, `faint` where it doesn't; a `border`-grey arrow once sat too close
to `faint` to say it), the same live/dim language the lane draws one row below. A
pair that appeared and vanished, half of it a blank cell, asked the reader to compare
the row against a memory of itself; fixed furniture means the bar never changes width
as the body scrolls either.
@@ -754,7 +830,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
steps. Nothing loses its colour for being on the console. Marks whose hue is *fixed*
rather than derived go through a theme name so the slot is chosen deliberately (the
node types' `type.*`; a raster resolves the same entry via `theme.mark_rgb`) — a naive
downsample greys the repeater's violet. Footer hints are replaced by the **F-key lane**
downsample greys a dim violet. Footer hints are replaced by the **F-key lane**
(`ui/tui/fkeys.py`): five `FPair` slots per screen, F1–F5 primary and F6–F10 each
slot's *opposite number* (physical Shift+F1..F5), labels ≤6 cells. `DEFAULT_LANE` claims
only **F4/F5 — the pager**, which has no physical key at all; **the jump to either end
@@ -786,7 +862,12 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
on Shift+F2. F4/F5 stay the pager, with `Latest`/`Oldest` behind Shift. A verb new to
one of them goes on the Shift bank; one all three gain goes on the plain bank — and once
that is full, on Shift+F3: `Author` (^O, the author's node page, `ContactPages`) rides
there behind `Map`, its twin about the same author.
there behind `Map`, its twin about the same author. `Lock` (^K, a toggle that reads
`Unlock` on a locked author) is the one exception, by JP's choice (2026-10-19): it sits on
Shift+F2 where that is free (channel, DM), and in a room, where `Log in` holds it, the
`Author` page carries it — a conversation opens that page with its lock row and no other
management verb. Its hint atom goes in only where the hint has room without dropping
another atom.
- **Each handheld deals its own lane.** Everything about the lane that belongs to one
keyboard is a `LaneDeck` (`fkeys.PICOCALC_LYRA_DECK`, `fkeys.CARDPUTER_ZERO_DECK`), named by
`Platform.lane_deck`: which keycodes drive the slots, where the chips sit on the row, how
+15 -7
View File
@@ -27,22 +27,25 @@ run MeshTerm, the more the history of your mesh is worth.
> change something, first say hello on [Discord](https://discord.gg/AZwe5Uvb3S).
> [Read more about how the project is run](#how-this-project-is-run).
https://github.com/user-attachments/assets/f91a6695-14d5-4ae0-8fdc-e65492d0955d
https://github.com/user-attachments/assets/9921c756-c096-4224-9560-bb6180d895c8
<p align="center"><sub>A video of less than two minutes, made on a real mesh.</sub></p>
<p align="center"><sub>A video of less than two minutes, in the demo mesh over Montréal (<code>meshterm --demo</code>).</sub></p>
<p align="center">
<img src="docs/screenshots/dashboard.png" alt="The dashboard: a timeline of the packets for each minute over the last two hours, and a count for each class of packet" width="49%">
<img src="docs/screenshots/map.png" alt="The map: repeaters with their names, on an OpenStreetMap basemap of the Lachine Canal" width="49%">
<img src="docs/screenshots/terminal/menu.png" alt="The main menu: the tools in groups (Message, Watch, Explore), and a header that shows one unread message and three alerts" width="49%">
<img src="docs/screenshots/terminal/chat.png" alt="The chat on the Public channel: each sender in a chip of the colour of their key, with the time and the SNR of each message" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/trace.png" alt="A trace: the route that the trace went through, drawn as chips, then the SNR of each hop" width="49%">
<img src="docs/screenshots/path.png" alt="Message paths: each route over which a channel message was heard, drawn on the map" width="49%">
<img src="docs/screenshots/terminal/paths.png" alt="Message paths: the five paths on which one channel message arrived, drawn as a graph and as chips" width="49%">
<img src="docs/screenshots/terminal/map.png" alt="The map: the nodes of the demo mesh over Montréal, with their names, on an OpenStreetMap basemap" width="49%">
</p>
<p align="center">
<img src="docs/screenshots/picocalc.png" alt="MeshTerm on the PicoCalc, a console that is 53 columns wide, with a radio installed inside it" width="38%">
</p>
<p align="center"><sub>The dashboard, the map, a trace, the paths of a message, and the whole app on a PicoCalc.</sub></p>
<p align="center">
<img src="docs/screenshots/cardputer-zero.png" alt="MeshTerm on the display of a Cardputer Zero: the wordmark, then the list of devices, with the Cap LoRa-1262 at the top" width="60%">
</p>
<p align="center"><sub>The main menu, the chat, the paths of a message, and the map, in the demo mesh over Montréal (<code>meshterm --demo</code>). Then the whole app on a PicoCalc, and the start screen on a Cardputer Zero.</sub></p>
---
@@ -198,6 +201,7 @@ mesh. You can look at the app before you buy anything.
### On the Cardputer Zero
Install MeshTerm from M5Stack's app store on the handheld. Its share code is `MTRM`.
MeshTerm works on both models, the Cardputer Zero and the Cardputer Zero Lite.
[MeshTerm on the Cardputer Zero](docs/devices/cardputer-zero.md) has the details.
### Coming
@@ -248,6 +252,10 @@ meshterm --mock
# custom console font, and an F-key hint lane. Preview it anywhere, or inspect it:
meshterm --mock --platform picocalc-lyra
meshterm specimen
# On a Cardputer Zero, MeshTerm draws its 53×14 display itself. See that display in a
# window of its own (download its font once with `meshterm emulate --fetch-fonts`):
meshterm emulate cardputer-zero --mock
```
MeshTerm has its own manuals for handhelds. **[MeshTerm on the
+5 -2
View File
@@ -37,7 +37,7 @@ MeshCore companion on USB, Bluetooth, or Wi-Fi does not need them.
| **[MeshTerm on hardware](devices/README.md)** | Start here. It says which of the manuals below is the correct one for you, and what the scripts on the device do. |
| [MeshTerm on the PicoCalc](devices/picocalc-lyra.md) | How to change a stock ClockworkPi PicoCalc into a Linux handheld that has a LoRa radio soldered inside: the shopping list, the change to a Luckfox Lyra core, Calculinux, and the radio. |
| [MeshTerm on the uConsole](devices/uconsole.md) | A uConsole (CM4 or CM5) with the hackergadgets AIO LoRa board. Its SX1262 is on the computer's own SPI bus. MeshTerm can control it directly with `--spi`. Or a small bridge can make it a companion for a node that is always on. |
| [MeshTerm on the Cardputer Zero](devices/cardputer-zero.md) | M5Stack's Linux Cardputer: how to install MeshTerm from the app store, use the Cap LoRa-1262 radio, and try the layout in the desktop emulator. |
| [MeshTerm on the Cardputer Zero](devices/cardputer-zero.md) | M5Stack's Linux Cardputer, the standard model or the Lite: how to install MeshTerm from the app store, use the Cap LoRa-1262 radio, and try the layout in the desktop emulator. |
## Reading the code
@@ -63,4 +63,7 @@ the survey does not say which.
`screenshots/` has the captures that the README shows. It also has the captures from which
[meshterm.net](https://meshterm.net) builds its landing page. Thus more than one place
refers to each file in this folder.
refers to each file in this folder. The folders `terminal/`, `cardputer-zero/`, and
`picocalc-lyra/` hold the same four screens on each platform. `scripts/screenshots.py`
makes them from the demo mesh, and they are the same at each run (refer to
`scripts/README.md`).
+43 -1
View File
@@ -57,6 +57,7 @@ If you give no command, MeshTerm starts the interactive menu.
| `--tcp HOST[:PORT]` | The network address of a TCP companion. It selects the TCP transport. The default port is 5000. |
| `--spi` | The LoRa radio on the SPI bus of this machine. It selects the SPI transport. MeshTerm runs the node for the length of the command. If one radio is attached, MeshTerm uses that radio, with a profile or without one. If no radio is attached, MeshTerm uses the one SPI profile, or else the wiring of the uConsole AIO. If two radios are attached, MeshTerm refuses (exit `3`) and lists them. Select one with `-p`. Refer to [Adding a radio on the SPI bus](../guide/configuration.md#adding-a-radio-on-the-spi-bus). Linux only. |
| `--mock` | Use the built-in simulator instead of real hardware. Nothing transmits, and the run records to `meshterm-mock.db` instead of your real history. |
| `--demo` | Use the simulator in a demo mesh over Montréal: about forty invented nodes, a week of traffic, conversations, traces, and records. The demo has its own directory (`demo/` in the MeshTerm directory), and MeshTerm writes it again at each start. Thus each run starts from the same state, and your history never gets a node of the demo. It cannot take `--db`. |
| `--db PATH` | Use this SQLite database instead of `~/.meshterm/meshterm.db` (or `meshterm-mock.db`, with `--mock`). **It moves the database and nothing else.** Refer to [Where MeshTerm keeps its state](#where-meshterm-keeps-its-state). |
| `--json` | Print the answer as JSON instead of aligned text. |
| `--absolute` | For this run, print times as ISO-8601 instants instead of relative ages. |
@@ -99,6 +100,7 @@ radios from two directories in the same shell.
| --- | --- |
| `meshterm.db` | The history: each packet that MeshTerm heard, each trace that it walked, and each message. **This is the one file that `--db` moves.** |
| `meshterm-mock.db` | The same history for the simulator. A `--mock` run records here when the run does not name a database. The file is absent until you do a `--mock` run. |
| `demo/` | The directory of the demo (`--demo`). MeshTerm writes everything in it again at each start of the demo, except the tile cache of the map and the log. |
| `config.toml` | The setup of the machine: device profiles, and where the database is. You write this file. MeshTerm only reads it. |
| `preferences.toml` | Your overrides of the behaviour of MeshTerm. It lists only what you changed. |
| `courier.json` | The outbox: queued messages, both waiting and finished. |
@@ -113,6 +115,14 @@ radios from two directories in the same shell.
| `meshterm.log` | The log file. |
| `.lock` | The lock for a single instance. |
**No log keeps a password or a secret.** The log file, the run log in the database (the
arguments and the result of each command), and the remote command history of
`remote.json` write `<secret>` in the place of a password, a channel secret, a pairing
PIN, or a private key. This is always true, at each log level and with or without the
**Anonymize** preference. The history keeps `password ` without its value, so ↑ still
recalls the start of the command. `admin.json` keeps the admin passwords that you asked
MeshTerm to remember, because that is its purpose.
**`--db` alone does not isolate a run.** It moves the database. The database is most of
the data, but it does not hold the identity. The contact caches, the channel caches, the
outbox, the stored admin passwords, and the remembered device are all in the config
@@ -1735,6 +1745,7 @@ weekly_flood_advert off off
advert_quiet_s 30 30 Silence to wait for before the weekly advert
direct_message_soft_retries 0 0 Times to resend a message that gets no reply
message_parts_max 3 3 Send a long message as this many parts at most
room_relogin_minutes 30 30 Opening a room this quiet logs in again
tx_opt_min 18 18 Weakest power it will try
tx_opt_max 28 28 Strongest power it will try
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
@@ -1752,7 +1763,9 @@ color_depth auto auto
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
name_overlap_days 7 7 Grey a name two nodes used within this many days
section_spacing_min_rows 24 24 A blank line between sections and between chat authors
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
anonymize off off Mask the home folder and addresses in the log and diagnostics
```
**`DESCRIPTION`** says what each preference does, because the name of a key does not give
@@ -1801,7 +1814,7 @@ code beside each link. The scripted face prints only the link.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.11.1","text":"Join the Discord\n Questions, ideas, bug reports, and mesh talk.\n\n • https://discord.gg/AZwe5Uvb3S","links":["https://discord.gg/AZwe5Uvb3S"]}
{"page":"discord","title":"Join Discord","version":"0.11.5","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 |
@@ -1865,6 +1878,7 @@ tx_power_dbm 20
error -
config_dir C:\Users\you\.meshterm
anonymized no
db_size_kb 41280
log_level DEBUG
log_size_kb 96
@@ -1916,6 +1930,29 @@ public without reading it. `tests/test_diagnostics.py` plants real secrets in ea
that holds one. It fails if one of them, or a field that is only *named* like one,
reaches either face.
**`--anonymize` masks the personal details, and keeps a tidbit of each one.** Some facts
are not secrets, but they are about you and your machine: the home folder in
`config_dir`, which has your account name, and the address of a radio or a host that an
`error` can repeat. `--anonymize` masks them in the way that a card number prints as
`51xx xxxx xxxx 4571`:
| Detail | As it is | Masked |
| --- | --- | --- |
| The account name in a home folder | `C:\Users\jpmartineau` | `C:\Users\j*********u` |
| A Bluetooth address | `DA:C5:21:6B:7C:7C` | `DA:xx:xx:xx:7C:7C` |
| An IPv4 address | `192.168.0.180` | `192.xxx.x.180` |
| The name of a network companion | `meshcore.local` | `m******e.local` |
Each masked value keeps its first and its last characters, and its length. Thus two radios
in one report stay two different radios, and a maintainer can match the address in the
report with the address in the log. The `anonymized` fact says which form the report is.
The **Anonymize** preference (`meshterm preferences set anonymize on`) makes the masked
report the default, and `--no-anonymize` overrides it for one run. The preference also
masks the log file, `meshterm.log`, as MeshTerm writes each line, so you can attach the
log with the report. A line that MeshTerm wrote before you turned the preference on stays
as it is. Node names, message text, and keys are not masked.
With `--json`, the five blocks of facts merge into one flat object, and the two listings
keep a key of their own:
@@ -1951,6 +1988,11 @@ to the report. The save writes `meshterm-diagnostics.md` into the config directo
the log, and it answers in a dialog that names the path. If the write fails, the same
dialog says so, in red, and the page stays readable.
`a` changes the page between the report as it is and the anonymized report, and the title
says `Diagnostics · anonymized` while the masks are visible. The page opens in the form
that the **Anonymize** preference gives, and `s` saves the form that you see. On the
PicoCalc, the key is **Shift+F3**, behind `Save`.
#### `meshterm platform`
A smaller command than `diagnostics`. It shows which UI flavour a run resolves to, and
+7
View File
@@ -194,6 +194,7 @@ and in quoted text.
| packet | One radio transmission | frame (for radio data) |
| protocol frame | One unit of the companion protocol on USB, Bluetooth, or TCP: a command, a reply, or a push from the companion. In code that handles only this protocol, "frame" alone is permitted | packet (for this) |
| message | A text message, direct or on a channel | |
| reaction | An emoji that a node sends to answer one message. It is a message on the wire, and the chat shows it under the message that it names | |
| part | One of the messages that MeshTerm sends for a message that is too long for one packet | chunk, segment |
| part number | The `[1/3]` at the end of a part | marker (for this) |
| send | MeshTerm gives a message, a packet, or a command to the device | emit |
@@ -220,6 +221,7 @@ and in quoted text.
| prompt | A dialog or a line that asks for typed text | |
| page | All the content of a screen, which can be taller than the terminal. Also a written page | |
| frame | The full image that MeshTerm composes for the terminal in one paint: the title bar, the panel, the body, and the footer | |
| video frame | One image of a video, such as the demo video that `scripts/video.py` makes | frame (for this) |
| paint | One full update of the terminal | redraw, repaint, refresh (for the terminal) |
| animation step | One image of an animation (a spinner) | frame (for this) |
| panel | The box with a border around the body of a screen | panel (for the hardware) |
@@ -282,6 +284,11 @@ and in quoted text.
| save | Do an explicit save: the user's Save, or the device's save command | |
| record | One stored data item | |
| delete | Remove stored data permanently | purge (except the Purge feature) |
| secret | A value that unlocks something: a password, a channel secret, a pairing PIN, a private key. No log keeps one: it is hidden as `<secret>` | credential (except in a name) |
| hide (a secret) | Replace a secret with `<secret>` in a log or in the run log | redact, scrub |
| personal detail | A value that identifies the user or the equipment of the user, but is not a secret: the account name in a home folder, the address of a radio, the address of a host | PII, private data |
| mask | Replace most characters of a personal detail, and keep its first and last characters: `j*********u`, `DA:xx:xx:xx:7C:7C` | redact, scrub, obfuscate |
| anonymize | Mask each personal detail in a text: the `anonymize` preference does it to the log and the diagnostics | |
| remove | Take an item out of a collection or a text | drop, strip |
| clear | Make empty | |
| download | Get a file from the network | fetch |
+1 -1
View File
@@ -15,7 +15,7 @@ has its own manual with numbered steps. This page tells you which manual is your
| a ClockworkPi **PicoCalc**, stock or already running Calculinux on a Luckfox Lyra, and I want MeshTerm on its own display | **[MeshTerm on the PicoCalc](picocalc-lyra.md)**: from the shopping list to a working machine |
| …and I want a **radio** inside it | the same manual, [Phase 3](picocalc-lyra.md#phase-3-add-the-radio): a XIAO nRF52840 + Wio-SX1262 soldered to the UART of the Lyra |
| a ClockworkPi **uConsole** with the hackergadgets AIO board (an SX1262 on the SPI bus of the host) | **[MeshTerm on the uConsole](uconsole.md)**: MeshTerm drives the radio directly with `--spi`. A bridge service can also present the radio as a companion, if you want it always on |
| an M5Stack **Cardputer Zero**, the one with a Raspberry Pi inside (not the ESP32 Cardputer or the Cardputer-Adv) | **[MeshTerm on the Cardputer Zero](cardputer-zero.md)**: install MeshTerm from M5Stack's app store. MeshTerm drives the Cap LoRa-1262 directly, or it uses a companion |
| an M5Stack **Cardputer Zero** or **Cardputer Zero Lite**, the models with a Raspberry Pi inside (not the ESP32 Cardputer or the Cardputer-Adv) | **[MeshTerm on the Cardputer Zero](cardputer-zero.md)**: install MeshTerm from M5Stack's app store. MeshTerm drives the Cap LoRa-1262 directly, or it uses a companion |
| a MeshCore companion on USB, Bluetooth, or Wi-Fi | nothing here. Read the [README](../../README.md). If the companion does not connect, read [When a companion does not connect](../guide/connecting.md) |
The handhelds have nothing in common except MeshTerm. Each manual runs scripts on the
+27 -7
View File
@@ -7,7 +7,8 @@ this page applies to them.
MeshTerm is an app in M5Stack's app store for the Cardputer Zero. It starts from the
launcher of the handheld and draws on its display. It can drive M5Stack's Cap LoRa-1262
radio directly, or it can use a MeshCore companion.
radio directly, or it can use a MeshCore companion. MeshTerm supports both models of the
handheld: the Cardputer Zero and the Cardputer Zero Lite.
- [The device](#the-device)
- [What works](#what-works)
@@ -21,11 +22,18 @@ radio directly, or it can use a MeshCore companion.
| | |
| --- | --- |
| Models | Cardputer Zero (C154) and Cardputer Zero Lite (C155). MeshTerm supports both |
| Computer | Raspberry Pi Compute Module 0: four 1 GHz cores, 512 MB of memory |
| Screen | 1.9 inches, 320×170 pixels |
| Keyboard | 46 keys, with Shift, Fn and Sym layers |
| System | Raspberry Pi OS "Trixie", with M5Stack's own app launcher |
The two models have the same computer, display, keyboard, battery, and expansion header.
The Cardputer Zero also has a camera, motion sensors, and a 32 GB microSD card. MeshTerm
uses none of these three parts, thus it works in the same way on both models. The Lite
comes without a microSD card. It needs a card with M5Stack's system on it before it can
start (refer to [M5Stack's documentation](https://docs.m5stack.com/en/CardputerZero)).
MeshTerm draws the display in 53 columns by 14 rows. The width is the same as on the
PicoCalc, but the height is approximately half. Thus many screens have a more compact
layout here.
@@ -66,8 +74,9 @@ MeshTerm now has an icon in the launcher. The package has all that MeshTerm need
Python libraries, the radio library for the Cap, and the font of the display. Each new
version of MeshTerm also goes to the store.
M5Stack examines a new app before the store shows it. If the store does not have MeshTerm
yet, install the same package from GitHub.
The store gets a new version a short time after its release, when the check of the store
passes. If you want a new version before the store has it, install the same package from
GitHub.
### From GitHub
@@ -176,8 +185,18 @@ The Cardputer Zero has no LoRa radio built in. You can add one in three ways:
### The Cap LoRa-1262
1. Push the Cap onto the expansion header at the back.
2. Start MeshTerm.
**CAUTION:** Turn off the Cardputer Zero before you install or remove a Cap, also the Cap
LoRa-1262. M5Stack does not support hot swapping of a Cap on the Cardputer Zero. In
M5Stack's words, "hot-swapping may cause the device to malfunction" (refer to
[M5Stack's documentation](https://docs.m5stack.com/en/CardputerZero)).
**CAUTION:** Install the antenna on the Cap before you turn on the Cardputer Zero. M5Stack
says that power without the antenna can permanently damage the radio.
1. Turn off the Cardputer Zero.
2. Install the antenna on the Cap.
3. Push the Cap onto the expansion header at the back.
4. Turn on the Cardputer Zero, and start MeshTerm.
The device screen lists the Cap as **Cap LoRa-1262**. `meshterm --spi` also reaches it from the
command line.
@@ -188,7 +207,7 @@ radio library. The package from the store has it. If you install MeshTerm with p
install the library next to it:
```bash
pip install 'mesh-term[spi]'
pip install 'mesh-term[spi] @ git+https://github.com/jpmartineau/MeshTerm'
```
When MeshTerm connects, it does three things that the Cardputer Zero needs before the
@@ -201,7 +220,8 @@ radio answers:
- It switches on the antenna path of the Cap.
When MeshTerm quits, it switches the power of the header off. Thus the Cap does not drain
the battery while nothing uses it.
the battery while nothing uses it. But this does not make it safe to remove the Cap. Turn
off the Cardputer Zero first.
A new node starts with the US/Canada settings of MeshCore (910.525 MHz). In other regions,
change the settings on **Device config** before you send anything. MeshTerm keeps the key,
+4 -3
View File
@@ -138,7 +138,7 @@ the route that agrees with how you installed MeshTerm.
(refer to [Step 3](#step-3-install-meshterm)):
```bash
pip install 'mesh-term[spi]'
pip install 'mesh-term[spi] @ git+https://github.com/jpmartineau/MeshTerm'
```
- **Do you run the one-file Linux ARM64 build?** It is a single frozen binary, and it
@@ -181,7 +181,7 @@ pipx inject mesh-term 'openhop-core[hardware]'
Or use `pip`, which adds the radio library as its own extra in one line:
```bash
pip install 'mesh-term[spi]'
pip install 'mesh-term[spi] @ git+https://github.com/jpmartineau/MeshTerm'
```
---
@@ -227,7 +227,8 @@ the mesh. On Device config, **Identity key…** shows the key, exports it, and i
one, as for each companion. An export has the form that a firmware companion uses: 64
bytes, or 128 hex digits. When the node makes its own key, it keeps a shorter form in
`identity.key`: a 32-byte seed. The two forms are the same identity, and an import accepts
each of them. The node reads its key only when it starts. Thus, after an import, MeshTerm
each of them. MeshTerm saves an imported key in the form that you give, so an imported
seed stays a seed. The node reads its key only when it starts. Thus, after an import, MeshTerm
restarts the node, and the reconnect dialog shows until the node is ready again.
**A profile, if you want a name for the connection:**
+4 -1
View File
@@ -20,7 +20,10 @@ You see the same message wherever the connection fails:
MeshTerm also writes each failure to the log file, `~/.meshterm/meshterm.log` (refer to
[Where MeshTerm keeps its state](../cli/README.md#where-meshterm-keeps-its-state)). Attach
the log file to a bug report. To get more detail, set the **Log detail** preference to
`DEBUG` (`meshterm preferences set log_level DEBUG`), and run the command again.
`DEBUG` (`meshterm preferences set log_level DEBUG`), and run the command again. The log
never holds a password or a secret, at any level. It can show your account name and the
address of your radio. To mask them as MeshTerm writes
each line, turn on the **Anonymize** preference (`meshterm preferences set anonymize on`).
Sometimes MeshTerm meets a failure that it has no name for. Then the message is the error
in its own words, and a second line tells where the log file is. The log has the full
+2 -2
View File
@@ -15,7 +15,7 @@ This page lists each interactive screen with its scripted equivalent, where one
| Feature | What it does | Scripted |
| --- | --- | --- |
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. A message that is too long for one packet goes out as parts, each ending with its number (`[1/3]`). MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. Select a message and press **^E** to react to it with an emoji. The reaction shows under the message, and MeshCore One and meshcore-open show it too. The emoji that ^E offers are a preference. A message that is too long for one packet goes out as parts, each ending with its number (`[1/3]`). MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels. You can share a channel with a QR code or a `meshcore://` link. If you give a channel a **send scope**, its messages are flooded into one region. Only the repeaters that carry that region relay them. When you change to a new device, export all the channels to a file, then import the file on the new device. The **channel guide** lists the public channels that MeshTerm heard, also the channels that are not on your device, and adds one with Enter. | `meshterm channels list / add / join / import / share / clear / scope / export / guide` |
| **📌 Rooms** | Each room server that your radio knows. A room server is a message board on a radio. It keeps the latest posts and sends you the posts that you missed. Join a room with its password. If a room does not answer, MeshTerm tells you what it knows and offers the next step to try. For example, it can send again by flood when the route that your radio learned is no longer good. | `meshterm rooms list / join / forget` |
| **📨 Courier** | A store-and-forward outbox for contacts that you cannot reach yet. Queue a message. MeshTerm sends it when the contact is next heard, or at a time that you set. It tracks the acks, and it waits longer between each try (exponential backoff). | `meshterm courier queue / list / send / cancel / clear` |
@@ -67,7 +67,7 @@ only describe it. The written pages are at the end.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **⚙ Preferences** | How MeshTerm behaves, in eight groups: Device, Sending, TX optimize, Watchtower, Map, History, Display, and Diagnostics. The page stages your changes, and you save them with one action at the bottom. MeshTerm writes only the values that you changed to `preferences.toml`. [Configuring MeshTerm](configuration.md) has the detail. | `meshterm preferences show / set / reset` |
| **🩺 Diagnostics** | One block of information that each bug report starts with. It shows which build you have and how you installed it, the operating system, the terminal and its size, and the radio and its link. It also shows the verdicts from boot time that no other screen shows (icons, powerline, platform flavour, and the reasons), and how much history MeshTerm stored. It has nothing private: no keys, passwords, positions, or contact names. It describes the mesh only as counts for each table and the span of time that the tables cover. Press `s` to save the block beside the log, to attach to a report. | `meshterm diagnostics [--out PATH]` |
| **🩺 Diagnostics** | One block of information that each bug report starts with. It shows which build you have and how you installed it, the operating system, the terminal and its size, and the radio and its link. It also shows the verdicts from boot time that no other screen shows (icons, powerline, platform flavour, and the reasons), and how much history MeshTerm stored. It has nothing private: no keys, passwords, positions, or contact names. It describes the mesh only as counts for each table and the span of time that the tables cover. Press `a` to anonymize it: your account name in the home folder and the addresses of your radio and hosts are masked, and each keeps a tidbit (`j*********u`, `DA:xx:xx:xx:7C:7C`). Press `s` to save the block beside the log, to attach to a report. The **Anonymize** preference opens the page anonymized, and masks the log too. | `meshterm diagnostics [--out PATH] [--anonymize]` |
| **📖 About MeshTerm** | What MeshTerm is, where it came from, and the terms of its licence. It includes the map credit, the font credit, and the licence URL. | `meshterm about` |
| **👤 About the author** | The person behind MeshTerm, on the mesh and away from it. | `meshterm about-author` |
| **🔗 Join Discord** | The community server. The page has one invite link and a QR code of the link, which a phone can read. | `meshterm discord` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

+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.11.1"
__version__ = "0.11.5"
__author__ = "Jean-Pierre Martineau"
#: The year when MeshTerm was first published. The copyright span starts in this year.
+9 -1
View File
@@ -18,7 +18,7 @@ I wanted something I could run from my computer. I wanted it to run on Windows,
## Platform-specific developments
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave.
In addition to a generic terminal setup, I wanted it to run on two devices I had lying around in my nerdcave. Then a third one showed up.
### ClockworkPi uConsole
MeshTerm is compatible with the **HackerGadgets AIO board (v1 or v2)**. The **AIO** board has just a raw LoRa radio, meaning it can't run MeshCore firmware. MeshTerm can run the mesh node in software itself, straight over the board's SPI bus, for as long as MeshTerm is running — no separate service needed. If you'd rather the node stayed on the mesh while MeshTerm is closed, an optional bridge service can run it all the time instead.
@@ -36,6 +36,14 @@ Getting MeshTerm to run on the **PicoCalc** is a bit more involved, but totally
The UX is a bit different on the PicoCalc's 16-colour 53-column screen layout, but it's been tailored to make good use of the available space and the PicoCalc's F-keys. The result is, in my humble opinion, the best MeshCore experience you can get on a portable device. It runs on one 18650 battery, and a second one roughly doubles the runtime.
### M5Stack Cardputer Zero
The **Cardputer Zero** is the easiest of the three: MeshTerm is in **M5Stack**'s app store, so installing it takes a few keypresses. Both models work, the standard **Cardputer Zero** and the **Cardputer Zero Lite**.
- Add the **Cap LoRa-1262**, and MeshTerm runs the mesh node itself, straight over SPI, just like on the uConsole. The Cap's GPS works too.
- Power the Cardputer off before you plug in or remove a Cap — the LoRa one included. M5Stack doesn't support hot-swapping Caps, and says it may cause the device to malfunction.
- Put the antenna on the Cap before you power it on. M5Stack warns that the radio can be permanently damaged without it.
The screen is tiny — 53 columns by 14 rows — so MeshTerm draws every pixel itself, and every screen has been squeezed to fit. The F-key labels run along the bottom of the screen, right above the number keys that press them. It's a mesh terminal that fits in your pocket!
## What the future holds
MeshTerm will evolve over time. It is a work in progress.
+36 -2
View File
@@ -23,17 +23,19 @@ from . import __version__
from .context import AppContext
from .core import exitcodes, win32dll
from .core.admin_store import AdminStore
from .core.config import MOCK_DB_FILENAME, Settings
from .core.config import DB_FILENAME, MOCK_DB_FILENAME, Settings
from .core.connection import DeviceCommandError, is_connection_lost
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
from .core.instancelock import InstanceBusy, hold_instance_lock
from .core.models import utcnow
from .core.preferences import (
PREFERENCES_FILENAME,
PreferenceError,
Preferences,
report_dropped,
)
from .core.preferences import install as install_preferences
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
from .persistence.repository import Repository
@@ -189,6 +191,9 @@ def main_callback(
help="Run a node on the LoRa radio on this machine's SPI bus (selects the SPI transport)",
),
mock: bool = typer.Option(False, "--mock", help="Use the built-in simulator"),
demo: bool = typer.Option(
False, "--demo", help="Run the simulator in a demo mesh over Montréal, with its own data"
),
db_path: Path | None = typer.Option(
None, "--db", help="SQLite database path (--mock records to meshterm-mock.db)"
),
@@ -218,6 +223,8 @@ def main_callback(
tcp: An explicit network address ``host[:port]``, which selects the TCP transport.
spi: Select the radio on the SPI bus of this machine. MeshTerm itself runs the node.
mock: Whether to use the simulator instead of real hardware.
demo: Run the simulator in the demo world (:mod:`meshterm.demo`), with a data
directory of its own that MeshTerm writes again at each start.
db_path: Override the location of the database. If it is not set, a ``--mock`` run
stores to the simulator's own database and not to the real history.
json_output: Print the answer as JSON instead of aligned text.
@@ -258,9 +265,30 @@ def main_callback(
# contradict each other about the device to talk to are a bad command line. They are
# not a question of precedence.
raise typer.BadParameter(f"--mock and {named_radios[0]} name different devices; pass one")
if demo and named_radios:
raise typer.BadParameter(f"--demo and {named_radios[0]} name different devices; pass one")
if demo and db_path is not None:
# The demo writes its database again at each start. If it took --db, a typing error
# would put a demo history over the history of the user.
raise typer.BadParameter("--demo writes its own database; drop --db")
settings = Settings.load()
if db_path is not None:
if demo:
# The demo is a whole MeshTerm of its own: its own directory, which it writes again
# at each start. The directory of the user never sees a node of the demo. The
# ``emulate`` command does not prepare it, because the MeshTerm in its window gets
# the same flag and prepares it there.
from .demo.home import demo_home, prepare
mock = True
settings.config_dir = demo_home(settings.config_dir)
settings.db_path = settings.config_dir / DB_FILENAME
if ctx.invoked_subcommand != "emulate":
try:
prepare(settings.config_dir, utcnow())
except InstanceBusy as exc:
raise typer.BadParameter(str(exc), param_hint="--demo") from None
elif db_path is not None:
settings.db_path = db_path
elif mock:
# The simulator is a fake radio, but it is not a fake MeshTerm. MeshTerm stored all
@@ -292,6 +320,11 @@ def main_callback(
# registry would be a behaviour that a user cannot find. MeshTerm sets it and never
# saves it, because it is a claim about this run.
prefs.set("cli_time_format", "absolute")
# The log file reads the ``anonymize`` preference at each record, through the
# process-wide set. The context installs the same set again later. If MeshTerm installs
# it only there, the records before the context (the dropped preferences below) are not
# masked when the user asked for it.
install_preferences(prefs)
configure_logging(
# The stdout of a scripted run carries its answer and nothing else. Thus the log
# records go to stderr, and they do not mix with the data that a caller parses.
@@ -327,6 +360,7 @@ def main_callback(
admin_store=AdminStore(settings.config_dir / "admin.json"),
profile=settings.resolve_profile(profile),
mock=mock,
demo=demo,
port_override=port,
ble_override=ble,
tcp_override=tcp,
+10 -6
View File
@@ -102,6 +102,8 @@ class AppContext:
room and the access that it granted (if the caller does not inject it, the
default is ``<config_dir>/rooms.json``).
mock: Whether the context uses the simulator device.
demo: Whether the simulator is the demo world of ``--demo``
(:mod:`meshterm.demo`), which implies ``mock``.
port_override: An explicit serial port (from ``--port`` or the interactive picker),
which overrides the profile.
ble_override: An explicit Bluetooth address (from ``--ble`` or the interactive
@@ -145,6 +147,7 @@ class AppContext:
room_store: RoomStore | None = None
profile: DeviceProfile | None = None
mock: bool = False
demo: bool = False
port_override: str | None = None
ble_override: str | None = None
tcp_override: str | None = None
@@ -298,11 +301,12 @@ class AppContext:
This is a best-effort check, and it does not disturb the link. The method calls
:meth:`~meshterm.core.connection.Device.link_present` of the connected device. For
serial, this is the enumeration of OS ports. For Bluetooth, it is the connection flag
of the BLE client. Thus the liveness watcher of the session does not depend on the
transport. The method returns ``False`` when nothing is connected, because there is no
live link. If the check fails, it returns ``True``. Thus a temporary failure of the
lookup never gives a false disconnect.
serial, this is the connection flag of the client and the enumeration of OS ports.
For Bluetooth, it is the connection flag of the BLE client. Thus the liveness
watcher of the session does not depend on the transport. The method returns
``False`` when nothing is connected, because there is no live link. If the check
fails, it returns ``True``. Thus a temporary failure of the lookup never gives a
false disconnect.
"""
device = self._device
if device is None:
@@ -615,7 +619,7 @@ class AppContext:
return self._device
if self.mock:
self._device = make_device(mock=True, port=None)
self._device = make_device(mock=True, port=None, demo=self.demo)
self._active_transport = None
self._active_port = None
self._active_address = None
+259
View File
@@ -0,0 +1,259 @@
# SPDX-License-Identifier: Apache-2.0
r"""Anonymize a text: mask each personal detail in it, and keep a tidbit of each one.
The log and the diagnostics block go into public bug reports. A user who reads their own
log needs each detail as it is, so MeshTerm masks nothing by default. When the
``anonymize`` preference is on, :func:`anonymize` masks three types of personal detail:
- **The account name in a home folder.** ``C:\Users\jpmartineau`` becomes
``C:\Users\j*********u``, and ``/home/jp`` becomes ``/home/j*``.
- **The address of a radio.** A Bluetooth address ``DA:C5:21:6B:7C:7C`` becomes
``DA:xx:xx:xx:7C:7C``. A macOS peripheral id (a UUID) keeps its first two and its last
four hex digits in the same way.
- **The address of a host.** An IPv4 address ``192.168.0.180`` becomes ``192.xxx.x.180``.
The name of a network companion (``meshcore.local``) becomes ``m******e.local``, because
MeshTerm remembers each host that it opens (:func:`remember_host`).
**A mask keeps a tidbit, and never removes the value** (JP, 2026-10-17). A card number
prints as ``51xx xxxx xxxx 4571``, and a masked value keeps its first and its last
characters in the same way. Thus two radios in one log stay two different radios, and a
maintainer can still match the address in a log line with the address in the
diagnostics. Also, a value keeps its length and its separators, so the line keeps its
shape. A digit masks to ``x``, the mark that a person expects in a number. A letter
masks to ``*``, because an ``x`` in a name is a letter.
The masks are idempotent. A masked value does not match its own rule again, or it masks
to itself. Thus a text can go through :func:`anonymize` two times with no change.
Node names, message text, and keys are not personal details in this sense, and this module
does not mask them. The diagnostics never contain them (refer to
:mod:`meshterm.tools.diagnostics`).
"""
from __future__ import annotations
import ipaddress
import re
import sys
import threading
from functools import lru_cache
from pathlib import Path
#: The mark that replaces a masked letter of a name.
NAME_MASK = "*"
#: The mark that replaces a masked digit of a number or an address.
DIGIT_MASK = "x"
_HEX = frozenset("0123456789abcdefABCDEF")
#: A Bluetooth address: six hex pairs, all with the same separator. The lookarounds keep a
#: longer run of hex pairs from matching in its middle. They refuse a separator only when
#: a hex digit is on its far side, because a log line can put a colon after an address
#: ("BLE companion DA:C5:21:6B:7C:7C: connected to ...").
_MAC = re.compile(
r"(?<![0-9A-Fa-f])(?<![0-9A-Fa-f][:-])"
r"[0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\1){4}[0-9A-Fa-f]{2}"
r"(?![0-9A-Fa-f])(?![:-][0-9A-Fa-f])"
)
#: A UUID: the form in which macOS names a Bluetooth peripheral.
_UUID = re.compile(
r"(?<![0-9A-Fa-f])(?<![0-9A-Fa-f]-)"
r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}"
r"(?![0-9A-Fa-f])(?!-[0-9A-Fa-f])"
)
#: An IPv4 address. The octets are checked after the match (refer to :func:`_mask_ipv4`).
#: Thus a version with four parts and a large part (``10.0.19045.1234``) stays as it is.
_IPV4 = re.compile(r"(?<![\d.])(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?!\.?\d)")
#: A home folder in the standard place of each OS: ``C:\Users\<name>``, ``/home/<name>``,
#: and ``/Users/<name>``. The separators match in each spelling: a backslash, a slash, and
#: the doubled backslash of a ``repr``. This rule also masks the short form of a long
#: account name on Windows (``JPMART~1``), which the exact rule cannot know. The account
#: can contain ``*``, so that a name that is already masked masks to itself.
_HOME_ROOT = re.compile(
r"(?:(?<!\w)[A-Za-z]:(?:\\|/)+[Uu][Ss][Ee][Rr][Ss](?:\\|/)+|(?<![\w.])/(?:home|Users)/)"
r"(?P<account>[^\\/\s'\"<>|:,;()\[\]]+)"
)
#: The hosts that :func:`remember_host` registered, and the pattern that finds them.
_hosts: dict[str, str] = {}
_host_pattern: re.Pattern[str] | None = None
_hosts_lock = threading.Lock()
def mask_name(name: str) -> str:
"""Mask a name, and keep its first and its last characters: ``j*********u``.
The masked name has the same length as the name. A name of two characters keeps only
its first character, and a name of one character keeps nothing.
Args:
name: The name to mask.
Returns:
The masked name.
"""
if len(name) <= 1:
return NAME_MASK * len(name)
if len(name) == 2:
return name[0] + NAME_MASK
return name[0] + NAME_MASK * (len(name) - 2) + name[-1]
def mask_host(host: str) -> str:
"""Mask a host: an IP address as a number, or a host name as a name.
An IPv4 address keeps its first and its last octets (``192.xxx.x.180``). An IPv6
address keeps its first and its last groups. A host name keeps its last label, which is
the domain that tells a maintainer what type of network it is (``.local``, ``.lan``),
and masks each other label as a name. A host name of one label is masked as a name.
Args:
host: The host, with no port and no brackets.
Returns:
The masked host.
"""
try:
address = ipaddress.ip_address(host)
except ValueError:
labels = host.split(".")
if len(labels) == 1:
return mask_name(host)
return ".".join([mask_name(label) for label in labels[:-1]] + [labels[-1]])
if address.version == 4:
return _IPV4.sub(_mask_ipv4, host)
groups = host.split(":")
return ":".join([groups[0]] + [_mask_digits(group) for group in groups[1:-1]] + [groups[-1]])
def remember_host(host: str) -> None:
"""Register a network host, so that :func:`anonymize` masks it each time it occurs.
No pattern can find a host name in free text. Thus MeshTerm registers each host that it
opens, and the mask finds that exact text. :class:`~meshterm.core.connection.MeshCoreDevice`
calls this function for each TCP companion. An IPv4 address needs no registration,
because its own rule finds it.
Args:
host: A host name or an IP address. Brackets around an IPv6 address are removed.
"""
global _host_pattern
text = host.strip().strip("[]")
if not text or _IPV4.fullmatch(text):
return
with _hosts_lock:
if text.lower() in _hosts:
return
_hosts[text.lower()] = mask_host(text)
# The longest host first, so that a host never masks a part of a longer host. A
# colon can follow a host, because the port comes after it ("meshcore.local:5000").
alternatives = "|".join(re.escape(h) for h in sorted(_hosts, key=len, reverse=True))
_host_pattern = re.compile(rf"(?<![\w.-])(?:{alternatives})(?![\w-])", re.IGNORECASE)
def anonymize(text: str) -> str:
"""Mask each personal detail in ``text``: home folders, radio addresses, and hosts.
Refer to the module docstring for each rule. The home folder of this machine is masked
also when it is not in the standard place (``/root``, or a folder that a user chose).
Args:
text: Any text: a log record with its traceback, or a value of the diagnostics.
Returns:
The text with each personal detail masked.
"""
home = _home()
if home:
text = _exact_home_rule(home, sys.platform == "win32").sub(_mask_account, text)
text = _HOME_ROOT.sub(_mask_account, text)
text = _MAC.sub(_mask_address, text)
text = _UUID.sub(_mask_address, text)
text = _IPV4.sub(_mask_ipv4, text)
hosts = _host_pattern
if hosts is not None:
text = hosts.sub(lambda m: _hosts.get(m.group(0).lower(), m.group(0)), text)
return text
def _home() -> str:
"""The home folder of this machine, or ``""`` when the OS cannot say where it is."""
try:
return str(Path.home())
except (RuntimeError, KeyError, OSError):
return ""
@lru_cache(maxsize=4)
def _exact_home_rule(home: str, fold_case: bool) -> re.Pattern[str]:
"""The pattern that finds ``home`` in a text, with the separators in each spelling.
The last folder of the home path is the account name. Only that folder is masked, so
the masked path still shows where MeshTerm keeps its data. A home with no folder in it
(``/``) gives a pattern that never matches.
Args:
home: The home folder of this machine.
fold_case: Whether to ignore case, because Windows paths ignore it.
Returns:
A pattern with the group ``account``.
"""
folders = [part for part in re.split(r"[\\/]+", home) if part]
if not folders:
return re.compile(r"(?!)")
separator = r"(?:\\|/)+"
lead = separator if home[:1] in ("/", "\\") else ""
head = separator.join(re.escape(part) for part in folders[:-1])
pattern = (
rf"(?<![\w.]){lead}{head}{separator if head else ''}"
rf"(?P<account>{re.escape(folders[-1])})(?![\w-])"
)
return re.compile(pattern, re.IGNORECASE if fold_case else 0)
def _mask_account(match: re.Match[str]) -> str:
"""Mask the ``account`` group of a home-folder match, and keep the rest of the match."""
whole = match.group(0)
start = match.start("account") - match.start()
end = match.end("account") - match.start()
return whole[:start] + mask_name(match.group("account")) + whole[end:]
def _mask_address(match: re.Match[str]) -> str:
"""Mask a radio address: keep the first two and the last four hex digits."""
return _mask_digits(match.group(0), keep_head=2, keep_tail=4)
def _mask_ipv4(match: re.Match[str]) -> str:
"""Mask the two middle octets of an IPv4 address, or keep a match that is not one."""
octets = match.groups()
if any(int(octet) > 255 for octet in octets):
return match.group(0)
first, second, third, last = octets
return ".".join((first, _mask_digits(second), _mask_digits(third), last))
def _mask_digits(text: str, *, keep_head: int = 0, keep_tail: int = 0) -> str:
"""Replace the hex digits of ``text`` with :data:`DIGIT_MASK`, but the ones to keep.
The separators stay where they are, so the value keeps its shape.
Args:
text: The value to mask.
keep_head: How many digits to keep at the start.
keep_tail: How many digits to keep at the end.
Returns:
The masked value.
"""
digits = [i for i, char in enumerate(text) if char in _HEX]
masked = set(digits[keep_head : len(digits) - keep_tail])
return "".join(DIGIT_MASK if i in masked else char for i, char in enumerate(text))
__all__ = ["DIGIT_MASK", "NAME_MASK", "anonymize", "mask_host", "mask_name", "remember_host"]
+9 -5
View File
@@ -35,17 +35,21 @@ from urllib.parse import parse_qs, quote, urlsplit
# not one time for each candidate channel. Thus the reception path pays almost nothing.
#: Matches the ``Name: message`` convention that channel senders use to identify themselves
#: (the protocol has no sender field). The name is 1–20 characters that are not colons, and
#: (the protocol has no sender field). The name is 1–31 characters that are not colons, and
#: ``": "`` must follow it. This rule is strict enough that it does not match ``http://…``
#: or ``note:x``.
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
#: or ``note:x``. The firmware keeps a node name in 32 bytes with its terminator, so 31 is
#: the longest name that a sender can have. A limit of 20 left
#: a longer name in the body. Then the transcript showed no sender for it, and a reaction
#: from it did not read as a reaction.
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,30}):[ \t]+(.*)$", re.DOTALL)
#: Matches an ``@[Name]`` mention token, which the reply flow puts into the compose line in
#: advance. A transcript renders each token as a bare ``@Name`` in the hue of that sender,
#: and does not show the brackets. The history face of the CLI also takes out the same
#: names. Thus the token is defined here, with the remainder of the message-body parsing, and
#: not in one of the two renderers. The name is 1–20 characters that are not ``]``.
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
#: not in one of the two renderers. The name is 1–31 characters that are not ``]``, the
#: same limit as :data:`SENDER_PREFIX`.
MENTION = re.compile(r"@\[([^\]]{1,31})\]")
def split_channel_sender(text: str) -> tuple[str | None, str]:
+82 -27
View File
@@ -52,11 +52,13 @@ from .models import (
advert_time,
utcnow,
)
from .privatekey import normalize as normalize_key
from .region_sim import SimulatedRegionMap
from .regions import WILDCARD as REGION_WILDCARD
from .regions import normalize as normalize_region
from .regions import parse_region_list, region_key
from .regions import validate as validate_region
from .secrets import remember as remember_secret
from .tracing import path_hash_flags, trace_timeout
from .transmit_lock import TransmitLock
@@ -1237,8 +1239,8 @@ class Device(ABC):
never opens a new handle. Thus the interactive session can poll it while the device
is in use, to find a lost link during the session (a serial cable pulled, a
companion powered off, a BLE peripheral out of range). Each transport uses the
signal that correctly shows its link state (the OS port list for serial, the
connection flag of the BLE client for Bluetooth). It returns ``True`` when it
signal that correctly shows its link state (the connection flag of the client, and
for serial also the OS port list). It returns ``True`` when it
cannot find the presence, so a short lookup problem never gives a false disconnect.
Returns:
@@ -1557,12 +1559,21 @@ class Device(ABC):
# -- messaging ---------------------------------------------------------------
@abstractmethod
async def send_direct_message(self, contact: Contact, text: str) -> Delivery:
async def send_direct_message(
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
) -> Delivery:
"""Send a direct text message to a contact, and wait some time for its ack.
Args:
contact: The recipient. Its ``public_key`` addresses the message.
text: The message body.
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
the clock of this computer. The recipient sees this value, and a reaction
names the message by it (refer to :mod:`~meshterm.core.reactions`). Thus the
caller gives it when it must know the value.
attempt: The number of the try (0 to 3). A retry of the same message has the
same timestamp and the next number, as the protocol specifies. The number
makes the packet of the retry different from the first one.
Returns:
The :class:`~meshterm.core.models.Delivery`: the ack code that the radio
@@ -1574,12 +1585,16 @@ class Device(ABC):
"""
@abstractmethod
async def send_channel_message(self, index: int, text: str) -> None:
async def send_channel_message(
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
"""Broadcast a text message on a channel slot.
Args:
index: The zero-based channel slot on which to transmit.
text: The message body.
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
the clock of this computer (refer to :meth:`send_direct_message`).
Raises:
DeviceCommandError: If the companion rejected the send.
@@ -1631,7 +1646,9 @@ class Device(ABC):
self._scope_leaked = None
yield
async def send_channel_in_scope(self, index: int, text: str, scope: str | None) -> None:
async def send_channel_in_scope(
self, index: int, text: str, scope: str | None, *, timestamp: int | None = None
) -> None:
"""Broadcast on a channel in a given scope, and leave the session scope as it was.
This is the only way that MeshTerm sends a channel message. ``None`` is the plain
@@ -1659,6 +1676,8 @@ class Device(ABC):
text: The message body.
scope: A region name, :data:`~meshterm.core.regions.WILDCARD` for unscoped, or
``None`` for the default of the device.
timestamp: The timestamp that the message carries (refer to
:meth:`send_channel_message`).
Raises:
FloodScopeError: If the companion cannot send in ``scope``. Nothing was sent.
@@ -1671,10 +1690,10 @@ class Device(ABC):
bare = validate_region(bare)
async with self.transmitting():
if not bare:
await self.send_channel_message(index, text)
await self.send_channel_message(index, text, timestamp=timestamp)
return
if bare == REGION_WILDCARD and await self._unscoped_is_plain():
await self.send_channel_message(index, text)
await self.send_channel_message(index, text, timestamp=timestamp)
return
try:
await self.set_flood_scope(bare)
@@ -1682,7 +1701,7 @@ class Device(ABC):
raise FloodScopeError(bare, _scope_refusal(bare, exc)) from exc
self._scope_active = True
try:
await self.send_channel_message(index, text)
await self.send_channel_message(index, text, timestamp=timestamp)
finally:
self._scope_active = False
try:
@@ -2003,9 +2022,12 @@ class Device(ABC):
async def import_private_key(self, key_hex: str) -> None:
"""Import a private key, and replace the identity of the device with it.
Each device takes the form that it keeps. A firmware companion accepts only the
64-byte key, so it expands a seed first (:func:`meshterm.core.privatekey.normalize`).
The node of an SPI radio keeps the form that it gets.
Args:
key_hex: The 64-byte key in hex, as :func:`meshterm.core.privatekey.normalize`
makes it. A caller that has a seed expands it first.
key_hex: The key in hex: the 64-byte key, or a 32-byte seed.
"""
@abstractmethod
@@ -2095,6 +2117,15 @@ class MeshCoreDevice(Device):
self._ble_device = ble_device
self._host = host
self._tcp_port = tcp_port
if host:
# No pattern can find a host name in a log line. Thus each host that MeshTerm
# opens is registered here, and the ``anonymize`` preference can mask it.
from .anonymize import remember_host
remember_host(host)
# The pairing PIN is a secret. No log may repeat it (refer to
# meshterm.core.secrets), so it is registered before the first connect.
remember_secret(pin)
self._mc = None # type: ignore[var-annotated] # meshcore.MeshCore
#: The ``bleak.BleakClient`` of a BLE link. MeshTerm keeps it because ``meshcore``
#: releases its own reference at the moment the peripheral disappears. Refer to
@@ -3059,15 +3090,22 @@ class MeshCoreDevice(Device):
async def link_present(self) -> bool: # noqa: D102 - inherited docstring
if self._mc is None:
return True # not connected yet, or torn down already: nothing to declare lost
# The meshcore client sets ``is_connected`` to False at the moment the transport is
# lost (bleak's disconnect callback for BLE, a broken socket for TCP, a fatal read
# or write error for serial). Thus it is a fast liveness read that does not transmit.
try:
connected = bool(self._mc.is_connected)
except Exception: # noqa: BLE001 - a status hiccup must not fake a disconnect
connected = True
if self._transport in ("ble", "tcp"):
# The meshcore client sets ``is_connected`` to False at the moment the transport
# is lost (bleak's disconnect callback for BLE, a broken socket for TCP). Thus
# this is the network and Bluetooth equivalent of the check of the serial port
# list: a fast liveness read that does not transmit.
try:
return bool(self._mc.is_connected)
except Exception: # noqa: BLE001 - a status hiccup must not fake a disconnect
return True
return connected
# A serial port can stay listed after its link is dead. When a laptop wakes from
# sleep, the kernel can reset the USB device and hang up the open port, but it keeps
# the same device file. The next write fails with EIO, and the serial transport
# closes itself. Before we checked this flag, the port list alone said that the link
# was up, so no reconnect dialog opened, and each command failed (GitHub issue #32).
if not connected:
return False
if not self._port:
return True # no port stored (it should not occur after a connect): cannot tell
# Run this off the event loop, as all the other callers of the port walk do. On the
@@ -3187,7 +3225,9 @@ class MeshCoreDevice(Device):
result = await mc.commands.send_device_query()
except Exception: # noqa: BLE001 - older firmware has no query: unknown, not fatal
return {}
return dict(getattr(result, "payload", {}) or {})
payload = dict(getattr(result, "payload", {}) or {})
remember_secret(payload.get("ble_pin")) # the PIN with which a phone pairs
return payload
#: The maximum silence of the contacts stream before MeshTerm considers a read as
#: failed (seconds). The library's own ``get_contacts`` arms one future for the *full*
@@ -3457,6 +3497,9 @@ class MeshCoreDevice(Device):
"""
from meshcore import EventType
# Each password that MeshTerm sends is a secret that no log may repeat (refer to
# meshterm.core.secrets): an admin password, a room password, or a guess.
remember_secret(password)
mc = self._require()
pub = self._node_pubkey(node)
loop = asyncio.get_running_loop()
@@ -4117,7 +4160,7 @@ class MeshCoreDevice(Device):
return stop_pump
async def send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
) -> Delivery:
transmit_gate.mark()
from meshcore import EventType
@@ -4146,7 +4189,9 @@ class MeshCoreDevice(Device):
try:
# Only the hand-over holds the transmit lock. The ack wait below is not a send.
async with self.transmitting():
result = await mc.commands.send_msg(pub, text)
result = await mc.commands.send_msg(
pub, text, timestamp=timestamp, attempt=attempt & 3
)
if result is None or getattr(result, "is_error", lambda: False)():
# A recipient for which the firmware has no entry is the only rejection with
# an obvious repair. Thus it gets its own class, so the chat screen can offer
@@ -4202,11 +4247,11 @@ class MeshCoreDevice(Device):
return Delivery(code[0], ack, flood)
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
transmit_gate.mark()
async with self.transmitting():
self._ok(await self._require().commands.send_chan_msg(index, text))
self._ok(await self._require().commands.send_chan_msg(index, text, timestamp))
@staticmethod
def _ok(event): # type: ignore[no-untyped-def]
@@ -4374,6 +4419,7 @@ class MeshCoreDevice(Device):
raise DeviceCommandError(f"channel read for slot {index} returned slot {got}")
if not payload.get("channel_name"):
return None
remember_secret(payload.get("channel_secret"))
return payload
async def _channel_event(self, index: int): # noqa: ANN202 - a meshcore Event
@@ -4563,6 +4609,7 @@ class MeshCoreDevice(Device):
key = payload.get("private_key")
if key is None:
raise RuntimeError("device did not return a private key (export may be disabled)")
remember_secret(key)
return key.hex() if isinstance(key, (bytes, bytearray)) else str(key)
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
@@ -4573,7 +4620,7 @@ class MeshCoreDevice(Device):
# error, thus it waited for its full timeout and returned a refusal with no cause.
# This call waits for the third answer too, and names it.
event = await self._require().commands.send(
b"\x18" + bytes.fromhex(key_hex),
b"\x18" + normalize_key(bytes.fromhex(key_hex)),
[EventType.OK, EventType.ERROR, EventType.DISABLED],
)
if getattr(event, "type", None) == EventType.DISABLED:
@@ -4886,7 +4933,7 @@ class MockDevice(Device):
self._tx_power = value
async def send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
) -> Ack | None:
transmit_gate.mark()
async with self.transmitting():
@@ -4931,7 +4978,7 @@ class MockDevice(Device):
task.add_done_callback(self._bg_tasks.discard)
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
transmit_gate.mark()
async with self.transmitting():
@@ -5418,7 +5465,8 @@ class MockDevice(Device):
return self._private_key
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
self._private_key = bytes.fromhex(key_hex).hex() # checks the hex, and normalizes it
# As a firmware does: it keeps only the 64-byte key, so a seed is expanded.
self._private_key = normalize_key(bytes.fromhex(key_hex)).hex()
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
self._custom_vars.clear()
@@ -5993,6 +6041,7 @@ def make_device(
tcp_port: int | None = None,
spi: SpiWiring | None = None,
state: Path | None = None,
demo: bool = False,
) -> Device:
"""Make the correct :class:`Device` for the current run.
@@ -6018,6 +6067,8 @@ def make_device(
state: The state directory of the SPI radio (refer to
:func:`meshterm.core.spiradio.state_dir`). It is necessary when
``transport="spi"``.
demo: With ``mock``, return the simulator of the demo world
(:class:`~meshterm.demo.device.DemoDevice`) instead of the test world.
Returns:
A :class:`Device` instance that connects when you enter it.
@@ -6026,6 +6077,10 @@ def make_device(
ValueError: If a real device is requested without an endpoint that its transport
can use.
"""
if mock and demo:
from ..demo.device import DemoDevice # deferred: the demo builds on this module
return DemoDevice()
if mock:
return MockDevice(optimal_tx=mock_optimal_tx)
if transport == "ble":
+34
View File
@@ -235,6 +235,40 @@ class CourierStore:
self._save()
return True
def edit(self, ident: int, text: str) -> bool:
"""Replace the body of a waiting entry. Its schedule and its attempts stay.
A finished entry is history: its body is what went out (or what MeshTerm stopped
trying to send), so it does not change.
Returns:
Whether a waiting entry got the new body.
"""
message = self.get(ident)
if message is None or message.status != QUEUED:
return False
if message.text != text:
message.text = text
self._save()
return True
def delete(self, ident: int) -> bool:
"""Remove one entry, waiting or finished.
For a waiting entry, this is the same as :meth:`cancel`. The outbox screen deletes
one row of either section with the Delete key.
Returns:
Whether an entry was removed.
"""
messages = self._load()
kept = [m for m in messages if m.ident != ident]
if len(kept) == len(messages):
return False
self._messages = kept
self._save()
return True
def clear_done(self) -> None:
"""Remove all the finished entries, and do not change the waiting queue."""
messages = self._load()
+29 -6
View File
@@ -145,7 +145,7 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
return True
if low in _FALSE:
return False
raise DeviceConfigError(f"{spec.key}: expected a boolean, got {raw!r}")
raise DeviceConfigError(f"{spec.key}: expected a boolean{_got(spec, repr(raw))}")
# Numeric (int / enum / float): the try wraps only the conversion. Thus this handler
# does not catch and wrap again a DeviceConfigError from the range and enum checks
@@ -158,7 +158,9 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
else: # int or enum
value = int(text, 0) if isinstance(raw, str) else int(raw)
except (ValueError, TypeError) as exc:
raise DeviceConfigError(f"{spec.key}: invalid {spec.value_type} value {raw!r}") from exc
raise DeviceConfigError(
f"{spec.key}: invalid {spec.value_type} value{_got(spec, repr(raw), before=' ')}"
) from exc
if (
spec.value_type == "enum"
@@ -167,15 +169,35 @@ def _parse_typed(spec: SettingSpec, raw: Any, snapshot: dict | None) -> Any:
and value not in spec.choices
):
allowed = ", ".join(str(k) for k in spec.choices)
raise DeviceConfigError(f"{spec.key}: must be one of {allowed}, got {value}")
raise DeviceConfigError(f"{spec.key}: must be one of {allowed}{_got(spec, value)}")
if spec.minimum is not None and value < spec.minimum:
raise DeviceConfigError(f"{spec.key}: must be >= {spec.minimum}, got {value}")
raise DeviceConfigError(f"{spec.key}: must be >= {spec.minimum}{_got(spec, value)}")
maximum = effective_maximum(spec, snapshot)
if maximum is not None and value > maximum:
raise DeviceConfigError(f"{spec.key}: must be <= {maximum:g}, got {value}")
raise DeviceConfigError(f"{spec.key}: must be <= {maximum:g}{_got(spec, value)}")
return value
def _got(spec: SettingSpec, shown: Any, *, before: str = ", got ") -> str:
"""The end of an error message that repeats the refused value, or nothing for a secret.
An error goes to the log and to the run log, and the value of a secret setting (the
pairing PIN) must not (refer to :mod:`meshterm.core.secrets`). Thus the message of a
secret setting states the rule and not the value.
Args:
spec: The setting that refused the value.
shown: The refused value, as the message writes it.
before: The text before the value: ``", got "``, or ``" "`` for "value X".
Returns:
``", got 1234"``, ``" '12a'"``, or ``""`` for a secret setting.
"""
from .secrets import names_secret
return "" if names_secret(spec.key) else f"{before}{shown}"
def effective_maximum(spec: SettingSpec, snapshot: dict | None) -> float | None:
"""The inclusive maximum for ``spec``: the device value if it is known, else the static one.
@@ -430,7 +452,8 @@ def _pin_rule(value: Any, snapshot: dict | None) -> str | None:
"""The firmware accepts only a pairing PIN of zero (no PIN) or of exactly six digits."""
if value == 0 or 100000 <= value <= 999999:
return None
return f"must be 0 (no PIN) or six digits, got {value}"
# No "got": the PIN is a secret, and the message goes to the log (refer to _got).
return "must be 0 (no PIN) or six digits"
def _repeat_rule(value: Any, snapshot: dict | None) -> str | None:
+54
View File
@@ -55,6 +55,7 @@ from .atomicwrite import write_atomically
from .courier_store import DONE_CAP
from .geo import DEFAULT_VIEW_FRACTION
from .models import NAME_OVERLAP_DAYS
from .reactions import DEFAULT_REACTIONS
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
if sys.version_info >= (3, 11):
@@ -173,6 +174,22 @@ _QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S}
#: column, and for the cost when MeshTerm reclaims a column that is not hidden.
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: The smallest terminal, in rows, that gets a blank line before each section. The two
#: ends are numbers too, so that one comparison (``rows >= value``) gives each choice: 0 is
#: lower than each terminal, and :data:`_SPACING_NEVER` is higher than each terminal. The
#: steps between them fall between the heights that MeshTerm knows: the 14 rows of the
#: Cardputer Zero, a standard terminal of 24, the 26 and 40 rows of the two console fonts of
#: the PicoCalc, and a tall window on a desktop.
_SPACING_NEVER = 10_000
_SPACING_CHOICES: dict[int, str] = {
0: "any height",
20: "20+ rows",
24: "24+ rows",
30: "30+ rows",
40: "40+ rows",
_SPACING_NEVER: "never",
}
#: How many colours to send to the terminal. ``auto`` is the honest default, and it is the
#: only value that is not a depth. It means "take prompt_toolkit's verdict unless we can
#: positively establish better". This rule is the difference between a repaired host and a
@@ -290,6 +307,16 @@ PREFERENCES: tuple[PrefSpec, ...] = (
minimum=0,
maximum=2,
),
PrefSpec(
key="reaction_emoji",
label="Reactions",
help="Emoji to offer when you react to a message",
group="Sending",
value_type="str",
# The default set of MeshCore One. A word that is not one emoji is ignored when the
# chat reads the list (refer to meshterm.core.reactions.reaction_set).
default=DEFAULT_REACTIONS,
),
PrefSpec(
key="message_parts_max",
label="Message parts",
@@ -517,6 +544,21 @@ PREFERENCES: tuple[PrefSpec, ...] = (
maximum=3650,
unit="days",
),
PrefSpec(
key="section_spacing_min_rows",
label="Section spacing",
help="A blank line between sections and between chat authors",
group="Display",
value_type="enum",
# The line costs one row for each section. A standard terminal (24 rows) and the
# PicoCalc (26) have the rows. The 14 rows of the Cardputer Zero do not: there a
# blank line is a row less of the list. The test is the real height of the
# terminal, so a small window on a desktop drops the line too (JP, 2026-10-13). It
# applies to each section heading of a list, to the day dividers of a chat, and to
# the messages of each author in a chat (JP, 2026-10-15).
default=24,
choices=_SPACING_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
@@ -531,6 +573,18 @@ PREFERENCES: tuple[PrefSpec, ...] = (
choices=_LOG_LEVEL_CHOICES,
relaunch=True,
),
PrefSpec(
key="anonymize",
label="Anonymize",
help="Mask the home folder and addresses in the log and diagnostics",
group="Diagnostics",
value_type="bool",
# Off: the person who reads the log is usually its owner, who needs each detail as
# it is. A user who posts the log and the diagnostics in public turns it on (refer
# to meshterm.core.anonymize). The log reads it at each record, so a change applies
# at once and not at the next start.
default=False,
),
)
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
+27 -12
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""The private key of our node: the forms that MeshTerm accepts, and the one form that it sends.
"""The private key of our node: the two forms that MeshTerm accepts, and where each one goes.
A MeshCore companion exports and imports its private key in one form only: 64 bytes, the
expanded Ed25519 key of the orlp library that the firmware uses (a 32-byte scalar, then a
@@ -7,10 +7,16 @@ expanded Ed25519 key of the orlp library that the firmware uses (a 32-byte scala
A key can also exist in a shorter form: the 32-byte Ed25519 seed from which the 64 bytes
come. The node that MeshTerm runs on an SPI radio keeps such a seed in its ``identity.key``
when it makes its own key. A seed is the same identity, but a firmware companion does not
accept it. Thus MeshTerm expands a seed before it sends it (:func:`expand_seed`), in the
same way that the firmware makes its own key. The user can give either form, and the
device always gets the 64 bytes.
when it makes its own key, because its radio library keeps keys in that form. A seed is
the same identity, and it is the more complete form: a seed always gives the 64 bytes,
and the 64 bytes never give back the seed.
The user can give either form, and the parsers here return it as given
(:func:`parse_private_key`, :func:`read_private_key_file`). Each device then takes the
form that it keeps. A firmware companion accepts only the 64 bytes, so it gets a seed
expanded (:func:`normalize`, in the same way that the firmware makes its own key). The
node of an SPI radio keeps the form that it gets, so an imported seed stays a seed there
(JP, 2026-10-13).
"""
from __future__ import annotations
@@ -22,6 +28,7 @@ from pathlib import Path
__all__ = [
"KEY_BYTES",
"SEED_BYTES",
"checked",
"expand_seed",
"normalize",
"parse_private_key",
@@ -68,10 +75,18 @@ def normalize(raw: bytes) -> bytes:
Raises:
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
"""
if len(raw) == KEY_BYTES:
raw = checked(raw)
return expand_seed(raw) if len(raw) == SEED_BYTES else raw
def checked(raw: bytes) -> bytes:
"""``raw`` itself, if it is a 64-byte key or a 32-byte seed.
Raises:
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
"""
if len(raw) in (KEY_BYTES, SEED_BYTES):
return bytes(raw)
if len(raw) == SEED_BYTES:
return expand_seed(raw)
raise ValueError(
f"A private key has {KEY_BYTES} bytes ({KEY_BYTES * 2} hex digits), or "
f"{SEED_BYTES} for a seed. This one has {len(raw)}."
@@ -79,7 +94,7 @@ def normalize(raw: bytes) -> bytes:
def parse_private_key(text: str) -> bytes:
"""The 64-byte key in ``text``, which the user typed or pasted as hex.
"""The key in ``text``, which the user typed or pasted as hex: 64 bytes, or a 32-byte seed.
The function ignores spaces and line breaks, because a key that the user copies from a
terminal often has them where the terminal wrapped the line.
@@ -95,11 +110,11 @@ def parse_private_key(text: str) -> bytes:
raise ValueError(
f"A private key has an even number of hex digits. This one has {len(digits)}."
)
return normalize(bytes.fromhex(digits))
return checked(bytes.fromhex(digits))
def read_private_key_file(path: Path) -> bytes:
"""The 64-byte key in the file at ``path``: hex text, or the raw bytes of a key or a seed.
"""The key in the file at ``path``, in its own form: hex text, or the raw bytes of a key.
``config export-key --out`` writes hex text. The ``identity.key`` of a node keeps raw
bytes. The function accepts the two forms. It reads the file as hex text first. A raw
@@ -121,7 +136,7 @@ def read_private_key_file(path: Path) -> bytes:
if digits and _HEX.fullmatch(digits):
return parse_private_key(digits)
if len(data) in (KEY_BYTES, SEED_BYTES):
return normalize(data)
return checked(data)
raise ValueError(
f"{path.name} does not hold a private key. A key file holds the key as hex "
f"text, or {KEY_BYTES} or {SEED_BYTES} raw bytes."
+538
View File
@@ -0,0 +1,538 @@
# SPDX-License-Identifier: Apache-2.0
"""Emoji reactions to chat messages, in the wire format of MeshCore One.
A reaction is a usual text message. The firmware and the repeaters do not know the format.
Only the apps do. MeshTerm writes the format of MeshCore One, and reads it and the two
formats of meshcore-open. The protocol is in ``docs/Reactions.md`` of MeshCore One. In short:
* A reaction names its target message by a hash of the body of the target and the
timestamp of its sender: SHA-256 of the UTF-8 body and the timestamp as a little-endian
32-bit integer, the first five bytes, in Crockford Base32 (eight characters). Each node
that received the target calculates the same hash, because the sender's timestamp travels
with the message.
* On a channel, the reaction also names the author of the target, because two nodes can
send the same body in the same second: ``@[Author]👍`` then a newline and the hash.
Before MeshCore One 1.4.1, the emoji came first (``👍@[Author]``). MeshTerm reads the two
orders and writes the new one.
* In a direct exchange, there are two persons only, so the reaction is ``👍``, a newline,
and the hash.
A body that only starts with an emoji is a usual message. The emoji part must be exactly
one emoji (one glyph) for the body to be a reaction.
MeshTerm stores a reaction as the usual message that it is. Thus the history keeps all that
was received. The chat screen folds the reactions of a transcript onto their targets when it
shows the transcript (refer to :class:`ReactionFold`).
"""
from __future__ import annotations
import hashlib
import unicodedata
from dataclasses import dataclass, field
from datetime import datetime
from .channels import split_channel_sender
from .models import ChatMessage
#: The reactions that the reaction dialog offers when the preference is empty. They are the
#: default set of MeshCore One, in its order.
DEFAULT_REACTIONS = "👍 👎 ❤️ 😂 😮 😢"
#: How far apart in time, in seconds, a reaction of meshcore-open and its target can be. The
#: short hash of meshcore-open (16 bits) matches many messages in a long history. Thus
#: MeshTerm compares it only with the messages near the reaction in time. MeshCore One uses
#: the same window.
WINDOW_S = 300
#: The maximum number of reactions that wait for a target that did not arrive yet.
PENDING_CAP = 100
# -- Crockford Base32 ---------------------------------------------------------------------
_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz"
_DECODE = {char: char for char in _CROCKFORD}
_DECODE.update({char.upper(): char for char in _CROCKFORD})
# The letters that a person can mistake for a digit read as that digit.
_DECODE.update({"o": "0", "O": "0", "i": "1", "I": "1", "l": "1", "L": "1"})
def _crockford(data: bytes) -> str:
"""Encode five bytes as eight Crockford Base32 characters, the most significant first."""
bits = int.from_bytes(data[:5], "big")
return "".join(_CROCKFORD[(bits >> shift) & 0x1F] for shift in range(35, -5, -5))
def _normal_hash(raw: str) -> str | None:
"""The canonical (lowercase) form of an eight-character hash, or ``None`` if it is not one."""
if len(raw) != 8:
return None
out = [_DECODE.get(char) for char in raw]
if None in out:
return None
return "".join(out) # type: ignore[arg-type] - None is excluded above
def message_hash(body: str, timestamp: int) -> str:
"""The hash with which a reaction names its target message.
Args:
body: The body of the target. On a channel, this is the text after the ``Name: ``
prefix of the sender.
timestamp: The timestamp that the sender gave the target, in seconds.
Returns:
Eight lowercase Crockford Base32 characters.
"""
data = body.encode("utf-8") + (timestamp & 0xFFFFFFFF).to_bytes(4, "little")
return _crockford(hashlib.sha256(data).digest())
def wire_timestamp(when: datetime) -> int:
"""The timestamp of a message as its sender put it on the wire: whole seconds."""
return int(when.timestamp())
# -- the emoji part -----------------------------------------------------------------------
_VS = frozenset("︎️")
_ZWJ = "‍"
_KEYCAP = "⃣"
_TONES = frozenset(chr(cp) for cp in range(0x1F3FB, 0x1F400))
_TAGS = frozenset(chr(cp) for cp in range(0xE0020, 0xE0080))
def _is_regional(char: str) -> bool:
"""Whether ``char`` is a Regional Indicator (one half of a flag)."""
return 0x1F1E6 <= ord(char) <= 0x1F1FF
def _is_pictograph(char: str) -> bool:
"""Whether ``char`` can start an emoji: a symbol outside the Latin and punctuation blocks."""
return ord(char) >= 0x2000 and unicodedata.category(char) == "So"
def is_reaction_emoji(value: str) -> bool:
"""Whether ``value`` is exactly one emoji: the emoji part of a reaction.
One emoji is one glyph. It can be a flag (two Regional Indicators), a keycap (``1️⃣``),
or pictographs joined with zero-width joiners (``❤️‍🔥``). Each pictograph can have a
variation selector, a skin tone, and a tag sequence. Text, or an emoji with text after
it, is not a reaction.
"""
if not value or value.isascii():
return False
if len(value) == 2 and _is_regional(value[0]) and _is_regional(value[1]):
return True
if value[0] in "0123456789#*":
return value[1:] in (_KEYCAP, "️" + _KEYCAP)
for part in value.split(_ZWJ):
if not part or not _is_pictograph(part[0]):
return False
if any(char not in _VS and char not in _TONES and char not in _TAGS for char in part[1:]):
return False
return True
def emoji_name(emoji: str) -> str:
"""A name for an emoji, for a person to read: its Unicode name in lowercase."""
try:
return unicodedata.name(emoji[0]).lower()
except (ValueError, IndexError):
return "emoji"
def reaction_set(preference: str) -> list[str]:
"""The emoji that the reaction dialog offers, from the ``reaction_emoji`` preference.
The preference is a list of emoji with spaces between them. A word that is not one emoji
is ignored, and so is a second copy of an emoji. An empty result gives
:data:`DEFAULT_REACTIONS`, because a reaction dialog with no rows is of no use.
"""
seen: list[str] = []
for word in (preference or "").split():
if is_reaction_emoji(word) and word not in seen:
seen.append(word)
return seen or DEFAULT_REACTIONS.split()
# -- the wire format ----------------------------------------------------------------------
#: The three forms of a reaction on the wire.
MC1 = "mc1" # MeshCore One: an eight-character hash
MCO = "mco" # meshcore-open v3: a 16-bit hash, and an index into its table of emoji
MCO_V1 = "mco1" # meshcore-open v1 (before January 2026): the time and two 30-bit hashes
@dataclass(frozen=True, slots=True)
class Reaction:
"""A reaction, as read from the body of a message.
Attributes:
emoji: The emoji of the reaction.
form: :data:`MC1`, :data:`MCO`, or :data:`MCO_V1`.
hash: How the reaction names its target. For :data:`MC1`, the eight-character hash
(refer to :func:`message_hash`). For :data:`MCO`, four lowercase hex digits.
For :data:`MCO_V1`, the message id of meshcore-open.
author: For a channel reaction of :data:`MC1`, the name of the author of the
target. ``None`` in all other cases.
timestamp: For :data:`MCO_V1`, the timestamp of the target, in seconds.
author_hash: For :data:`MCO_V1`, the Dart hash of the name of the author.
body_hash: For :data:`MCO_V1`, the Dart hash of the body of the target.
"""
emoji: str
form: str
hash: str
author: str | None = None
timestamp: int | None = None
author_hash: int | None = None
body_hash: int | None = None
def parse(body: str, *, channel: bool) -> Reaction | None:
"""Read a message body as a reaction.
Args:
body: The body of the message. On a channel, this is the text after the
``Name: `` prefix of the sender.
channel: Whether the message is on a channel. A channel reaction of MeshCore One
names the author of its target, and a direct one does not.
Returns:
The reaction, or ``None`` when the body is a usual message.
"""
if body.startswith("r:"):
return _parse_mco(body) or _parse_mco_v1(body)
head, newline, tail = body.rpartition("\n")
if not newline:
return None
digest = _normal_hash(tail)
if digest is None:
return None
if not channel:
if "@[" in head or not is_reaction_emoji(head):
return None
return Reaction(head, MC1, digest)
before, mark, after = head.partition("@[")
if not mark:
return None
if before:
# The order before MeshCore One 1.4.1: the emoji, then the mention.
if not after.endswith("]"):
return None
emoji, author = before, after[:-1]
else:
author, close, emoji = after.partition("]")
if not close:
return None
if not author or not is_reaction_emoji(emoji):
return None
return Reaction(emoji, MC1, digest, author=author)
def channel_text(emoji: str, author: str, body: str, timestamp: int) -> str:
"""The body of a channel reaction to the message ``body`` of ``author``.
Args:
emoji: The emoji of the reaction.
author: The name of the author of the target, as the target carried it.
body: The body of the target, without the ``Name: `` prefix.
timestamp: The timestamp that the author gave the target.
"""
return f"@[{author}]{emoji}\n{message_hash(body, timestamp)}"
def direct_text(emoji: str, body: str, timestamp: int) -> str:
"""The body of a direct reaction to the message ``body`` sent at ``timestamp``."""
return f"{emoji}\n{message_hash(body, timestamp)}"
# -- meshcore-open ------------------------------------------------------------------------
#: The table of emoji of meshcore-open (its ``emoji_picker.dart``), in order: the quick set,
#: then the smileys, gestures, hearts, and objects. A reaction of v3 sends an index into it.
# fmt: off
MCO_EMOJI: tuple[str, ...] = (
"👍", "❤️", "😂", "🎉", "👏", "🔥",
"😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊",
"😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘",
"😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪",
"🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏",
"😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖",
"😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡",
"🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
"😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶",
"👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️",
"🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆",
"👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏",
"🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳",
"💪",
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍",
"🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗",
"💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫",
"💦", "💨", "🕳️", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭",
"🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆",
"🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐",
"🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒",
"🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥",
"⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔",
"📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶",
"🚀",
)
# fmt: on
_HEX = frozenset("0123456789abcdef")
def _parse_mco(body: str) -> Reaction | None:
"""Read ``r:{hash}:{index}``, a reaction of meshcore-open v3 (four and two hex digits)."""
if len(body) != 9 or body[6] != ":":
return None
digest, index = body[2:6], body[7:]
if not set(digest + index) <= _HEX:
return None
position = int(index, 16)
if position >= len(MCO_EMOJI):
return None
return Reaction(MCO_EMOJI[position], MCO, digest)
def _parse_mco_v1(body: str) -> Reaction | None:
"""Read ``r:{millis}_{author hash}_{body hash}:{emoji}``, a reaction of meshcore-open v1."""
ident, colon, emoji = body[2:].rpartition(":")
if not colon or not ident or not emoji:
return None
parts = ident.split("_")
if len(parts) != 3 or not all(part.isdigit() for part in parts):
return None
seconds = int(parts[0]) // 1000
if seconds > 0xFFFFFFFF:
return None
return Reaction(
emoji,
MCO_V1,
ident,
timestamp=seconds,
author_hash=int(parts[1]),
body_hash=int(parts[2]),
)
def dart_hash(units: list[int]) -> int:
"""The ``String.hashCode`` of the Dart VM over UTF-16 code units: a 30-bit value, never 0."""
mask = 0xFFFFFFFF
value = 0
for unit in units:
value = (value + unit) & mask
value = (value + (value << 10)) & mask
value ^= value >> 6
value = (value + (value << 3)) & mask
value ^= value >> 11
value = (value + (value << 15)) & mask
value &= (1 << 30) - 1
return value or 1
def _units(text: str) -> list[int]:
"""The UTF-16 code units of ``text``."""
raw = text.encode("utf-16-le")
return [int.from_bytes(raw[i : i + 2], "little") for i in range(0, len(raw), 2)]
def mco_hash(timestamp: int, author: str | None, body: str) -> str:
"""The 16-bit hash with which meshcore-open v3 names a target, as four hex digits.
It is the Dart hash of the timestamp in decimal, the name of the author (on a channel
only), and the first five UTF-16 code units of the body.
"""
units = _units(str(timestamp)) + (_units(author) if author else []) + _units(body)[:5]
return f"{dart_hash(units) & 0xFFFF:04x}"
# -- the fold -----------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class Reactor:
"""Who sent a reaction: our node, or another node by the name that it gave.
Attributes:
name: The name of the node. ``None`` for our node, and for the peer of a direct
exchange (the conversation names the peer).
ours: Whether our node sent the reaction.
"""
name: str | None
ours: bool = False
@dataclass(slots=True)
class Badge:
"""One emoji on one message, and the nodes that reacted with it, the first first."""
emoji: str
reactors: list[Reactor] = field(default_factory=list)
@property
def ours(self) -> bool:
"""Whether our node is one of the nodes that reacted with this emoji."""
return any(reactor.ours for reactor in self.reactors)
@dataclass(slots=True)
class _Target:
"""A message of the transcript, as the fold matches reactions against it."""
message: ChatMessage
author: str | None
body: str
timestamp: int
class ReactionFold:
"""Fold the reactions of one conversation onto the messages that they react to.
Give each message of the transcript to :meth:`take`, in the order of arrival. A message
that is not a reaction becomes a target, and :meth:`take` returns ``False``. A reaction
attaches to its target, and :meth:`take` returns ``True``. Then the transcript does not
show it as a message. A reaction whose target is not known yet waits (at most
:data:`PENDING_CAP` of them), because on a mesh a reaction can arrive before its target.
:meth:`badges` gives the reactions on one message.
The fold counts each emoji one time for each node. A node can send the same reaction
again, and a repeater can deliver a copy two times.
"""
def __init__(self, *, channel: bool, self_name: str | None = None) -> None:
"""Make an empty fold.
Args:
channel: Whether the conversation is a channel. A channel reaction names the
author of its target, and a direct one does not.
self_name: The name of our node. On a channel, it is the author of each message
that we sent. Without it, the reactions to our channel messages of
MeshCore One cannot find their target.
"""
self._channel = channel
self._self_name = self_name
self._targets: list[_Target] = []
self._by_id: dict[int, _Target] = {}
self._by_hash: dict[tuple[str | None, str], _Target] = {}
self._badges: dict[int, list[Badge]] = {}
self._pending: list[tuple[Reaction, Reactor]] = []
def take(self, message: ChatMessage) -> bool:
"""Fold one message in, and return whether it is a reaction.
Args:
message: The next message of the transcript.
Returns:
``True`` when the message is a reaction. The transcript then does not show it.
``False`` when it is a usual message, which other reactions can now name.
"""
author, body = self._author_and_body(message)
reaction = parse(body, channel=self._channel)
if reaction is None:
self._index(message, author, body)
return False
reactor = Reactor(
None if message.outbound else (author if self._channel else None), ours=message.outbound
)
target = self._find(reaction, wire_timestamp(message.created_at))
if target is not None:
self._attach(target.message, reaction.emoji, reactor)
elif reaction.form == MC1:
self._pending.append((reaction, reactor))
del self._pending[:-PENDING_CAP]
return True
def badges(self, message: ChatMessage) -> list[Badge]:
"""The reactions on ``message``: the most frequent emoji first, then the earliest."""
badges = self._badges.get(id(message), [])
order = {id(badge): index for index, badge in enumerate(badges)}
return sorted(badges, key=lambda badge: (-len(badge.reactors), order[id(badge)]))
def count(self, message: ChatMessage) -> int:
"""The number of reactions on ``message``. A render cache compares it to see a change."""
return sum(len(badge.reactors) for badge in self._badges.get(id(message), []))
def reacted(self, message: ChatMessage, emoji: str) -> bool:
"""Whether our node already reacted to ``message`` with ``emoji``."""
return any(
badge.emoji == emoji and badge.ours for badge in self._badges.get(id(message), [])
)
def target_of(self, message: ChatMessage) -> tuple[str | None, str, int] | None:
"""The author, the body, and the timestamp with which a reaction names ``message``.
``None`` when ``message`` is not a target of this fold (a reaction, or a message
that the fold never took).
"""
target = self._by_id.get(id(message))
if target is None or target.message is not message:
return None
return target.author, target.body, target.timestamp
def _author_and_body(self, message: ChatMessage) -> tuple[str | None, str]:
"""The author and the body of a message, as the hash of a reaction sees them.
On a channel, the body of a received message is the text after the ``Name: ``
prefix, without the spaces at its two ends. Our channel messages have no prefix in
the history, and their author is our node.
"""
if not self._channel:
return None, message.text
if message.outbound:
return self._self_name, message.text.strip(" \t")
name, body = split_channel_sender(message.text)
return name, body.strip(" \t")
def _index(self, message: ChatMessage, author: str | None, body: str) -> None:
"""Make ``message`` a target, and attach the reactions that waited for it."""
target = _Target(message, author, body, wire_timestamp(message.created_at))
self._targets.append(target)
self._by_id[id(message)] = target
if self._channel and not author:
return # a channel reaction of MeshCore One must name the author
key = (author if self._channel else None, message_hash(body, target.timestamp))
self._by_hash[key] = target
waiting = [entry for entry in self._pending if (entry[0].author, entry[0].hash) == key]
if waiting:
self._pending = [entry for entry in self._pending if entry not in waiting]
for reaction, reactor in waiting:
self._attach(message, reaction.emoji, reactor)
def _find(self, reaction: Reaction, at: int) -> _Target | None:
"""The target that ``reaction`` names, or ``None``. The newest target wins a tie."""
if reaction.form == MC1:
return self._by_hash.get((reaction.author, reaction.hash))
if reaction.form == MCO:
anchor = at
else:
anchor = reaction.timestamp or 0
for target in reversed(self._targets):
if abs(target.timestamp - anchor) > WINDOW_S:
continue
if reaction.form == MCO:
author = target.author if self._channel else None
if mco_hash(target.timestamp, author, target.body) == reaction.hash:
return target
elif dart_hash(_units(target.body)) == reaction.body_hash:
if self._channel and target.author is not None:
if dart_hash(_units(target.author)) != reaction.author_hash:
continue
return target
return None
def _attach(self, message: ChatMessage, emoji: str, reactor: Reactor) -> None:
"""Add one reaction to ``message``, if that node did not already send that emoji."""
badges = self._badges.setdefault(id(message), [])
badge = next((b for b in badges if b.emoji == emoji), None)
if badge is None:
badge = Badge(emoji)
badges.append(badge)
if reactor not in badge.reactors:
badge.reactors.append(reactor)
+25 -3
View File
@@ -28,6 +28,8 @@ from typing import ClassVar
from .admin_store import admin_key
from .atomicwrite import write_atomically
from .models import Contact, utcnow
from .secrets import names_secret, without_secret
from .secrets import remember as remember_secret
#: How many command-line entries the store keeps for each node (newest last).
HISTORY_CAP = 100
@@ -121,7 +123,11 @@ class _RemoteNode:
if value is not None:
settings[str(key)] = value
raw_history = record.get("history")
history = [str(c) for c in raw_history] if isinstance(raw_history, list) else []
# A history that an earlier version wrote can hold a password. The record keeps
# the command without it, so the next write removes it from the file.
history = (
[without_secret(str(c)) for c in raw_history] if isinstance(raw_history, list) else []
)
return cls(settings=settings, history=history)
@property
@@ -169,11 +175,20 @@ class RemoteStore:
return dict(self._node(node).settings)
def remember_setting(self, node: Contact, key: str, value: str) -> None:
"""Cache the value of one setting for ``node``, with the time now."""
"""Cache the value of one setting for ``node``, with the time now.
The value of a setting that is a secret (``guest.password``, ``bridge.secret``) is
also registered, so that no log can repeat it (refer to
:mod:`meshterm.core.secrets`).
"""
if names_secret(key):
remember_secret(value)
self._put(node, key, CachedValue(value=value, read_at=utcnow()))
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
"""Cache a setting that is not in the catalog, which ``node`` proved that it has."""
if names_secret(key):
remember_secret(value)
self._put(node, key, CachedValue(value=value, read_at=utcnow(), discovered=True))
def remember_unsupported(self, node: Contact, key: str) -> None:
@@ -209,10 +224,17 @@ class RemoteStore:
return list(self._node(node).history)
def append_history(self, node: Contact, command: str) -> None:
"""Add one sent command to the history of the node (not a repeat of the last command)."""
"""Add one sent command to the history of the node (not a repeat of the last command).
A command that sets a password or a secret is kept without its value
(:func:`~meshterm.core.secrets.without_secret`): ``password hunter2`` is kept as
``password``, with the space after it. Thus ↑ recalls the start of the command, and
the file never holds the secret.
"""
command = command.strip()
if not command:
return
command = without_secret(command)
records = self._load()
record = records.setdefault(admin_key(node), _RemoteNode())
if record.history and record.history[-1] == command:
+383
View File
@@ -0,0 +1,383 @@
# SPDX-License-Identifier: Apache-2.0
"""Keep each password and each secret out of each log: the log file and the run log.
MeshTerm keeps a record of what it does in two places: the log file (``meshterm.log``) and
the ``runs`` table, which stores the parameters and the summary of each run. Neither place
may ever hold a password, a channel secret, a pairing PIN, or a private key (JP,
2026-10-18: "NEVER log passwords or secrets!"). Each one is written as :data:`SECRET_TEXT`
instead. This rule does not depend on a preference, and the ``anonymize`` preference
(refer to :mod:`meshterm.core.anonymize`) adds to it and does not replace it.
A secret gets into a record in many shapes, and this module has one rule for each shape.
No rule depends on a list of the tools or of their parameters. Thus a tool that is added
later is covered on the day that it arrives:
- **A named value.** A value is a secret when its name says so (:func:`names_secret`):
a mapping key (``{"password": …}``), a dataclass field (``Conversation.secret``), the
``key`` of a record (``{"key": "device_pin", "value": …}``), the name in an operation
(``("set", "device_pin", "123456")``), or the name before ``=`` in a text
(``…&secret=8f…``, ``secret=b'…'``).
- **A wrapped value.** A tool can wrap a value in :class:`Secret` when nothing names it.
- **Bytes.** The parameters of a run carry bytes only for keys and secrets. Thus each
``bytes`` value is a secret.
- **A remote command.** ``password <new>`` and ``set guest.password <new>`` carry the
secret as their last word.
- **A known value in free text.** Each value that a rule above hid, and each secret that
MeshTerm reads (a stored password, a channel secret, a pairing PIN), is registered
(:func:`remember`). :func:`hide` then finds it in any text: an error message that
repeats it, or a frame that a library writes in hex.
:func:`loggable` applies the rules to a structure (the parameters and the summary of a
run). :func:`hide` applies them to a text (a log record).
"""
from __future__ import annotations
import re
import threading
from collections.abc import Mapping
from dataclasses import fields, is_dataclass
from datetime import date, datetime
from enum import Enum
from pathlib import PurePath
from typing import Any
#: What a log and the run log show in the place of a secret.
SECRET_TEXT = "<secret>"
#: The words that make a name the name of a secret. A name matches when one of its words
#: is in this set. The words of a name are the parts between the characters that are not
#: letters or digits. Thus ``device_pin``, ``guest.password``, and ``bridge.secret`` match,
#: and ``pinned`` and ``spin`` do not. ``prv`` is the private key of a repeater
#: (``prv.key``). ``key`` alone is not a secret word, because most keys in MeshTerm are
#: public keys.
SECRET_WORDS = frozenset(
{
"password",
"passwd",
"passphrase",
"secret",
"pin",
"private",
"privkey",
"prv",
"psk",
"token",
"credential",
"credentials",
}
)
#: The entries of a record whose ``key`` names a secret, and which hold its value.
_RECORD_VALUES = frozenset({"value", "previous", "default", "old", "new"})
#: The entries of a record that name the thing that the record is about.
_RECORD_NAMES = ("key", "setting")
#: The shortest text that :func:`remember` registers. A shorter value matches too much
#: ordinary text in a log (``abc``, ``123``). The structural rules still hide it where it
#: has a name.
_MIN_REMEMBERED = 4
#: The shortest hex value that :func:`hide` finds inside a longer run of hex. A channel
#: secret has 32 hex digits. With 16 or more, a match by chance is not probable.
_MIN_HEX_SUBSTRING = 16
_WORD_SPLIT = re.compile(r"[^a-z0-9]+")
_HEX = re.compile(r"[0-9a-fA-F]+")
#: ``name = value`` in a text: a URL query, a ``repr`` of an object, or a ``key=value``
#: log line. A name with no quotes takes ``=``. A quoted name (a ``repr`` of a dict, or
#: JSON) also takes ``:``. Prose ("device_pin: must be six digits") is not an assignment.
#: The value is a quoted literal (also a ``bytes`` literal), or a run of characters up to
#: the end of the value.
_ASSIGNMENT = re.compile(
r"(?P<name>(?<![\w.-])[A-Za-z_][\w.-]*)"
r"(?P<sep>\s*=\s*|['\"]\s*[=:]\s*)"
r"(?P<value>b?'(?:[^'\\]|\\.)*'|b?\"(?:[^\"\\]|\\.)*\"|<secret>|[^\s'\",;)}\]&<>]+)"
)
#: A remote command that sets a password: ``password <new>``.
_PASSWORD_COMMAND = re.compile(r"^(?P<head>\s*password\s+)(?P<value>\S.*)$", re.IGNORECASE)
#: A remote command that sets a setting: ``set <name> <value>``.
_SET_COMMAND = re.compile(r"^(?P<head>\s*set\s+(?P<name>\S+)\s+)(?P<value>\S.*)$", re.IGNORECASE)
_known: set[str] = set()
_known_pattern: re.Pattern[str] | None = None
_known_lock = threading.Lock()
class Secret:
"""A value that no log may keep, for example a private key or a password.
:meth:`~meshterm.tools.base.Tool.execute` stores the parameters of each run in the
``runs`` table, and logs them at debug level. A tool that has a secret that nothing
names (a key in a tuple) puts it in this wrapper. :func:`loggable` and each text form
of this object show :data:`SECRET_TEXT`, and the tool reads :attr:`value` (refer to
:func:`reveal`).
The class does not derive from ``str``, because ``json.dumps`` writes the value of a
``str`` subclass and never calls its ``default`` for it.
Attributes:
value: The secret itself.
"""
__slots__ = ("value",)
def __init__(self, value: str) -> None:
"""Keep ``value`` out of each text form of this object, and register it."""
self.value = value
remember(value)
def __repr__(self) -> str:
""":data:`SECRET_TEXT`, never the value."""
return SECRET_TEXT
__str__ = __repr__
def reveal(value: Any) -> Any:
"""The value inside a :class:`Secret`, or ``value`` itself if it is not a secret."""
return value.value if isinstance(value, Secret) else value
def names_secret(name: str) -> bool:
"""Whether ``name`` is the name of a secret (refer to :data:`SECRET_WORDS`).
Args:
name: A parameter name, a field name, a setting key, or a word of a text.
Returns:
``True`` when one of the words of ``name`` is a secret word.
"""
return not SECRET_WORDS.isdisjoint(_WORD_SPLIT.split(name.lower()))
def remember(value: Any) -> None:
"""Register a secret, so that :func:`hide` finds it in any text after this call.
A value of bytes registers as hex, the form in which a log writes bytes. A value that
is too short to find safely, or that is a secret word (a password of ``password``), is
not registered: each mention of the word in the log would become the mark. Each other
rule still hides such a value where it has a name.
Args:
value: The secret: text, a number (a PIN), or bytes.
"""
global _known_pattern
if value is None or isinstance(value, bool):
return
if isinstance(value, (bytes, bytearray, memoryview)):
text = bytes(value).hex()
else:
text = str(value).strip()
if len(text) < _MIN_REMEMBERED or text == SECRET_TEXT or text.lower() in SECRET_WORDS:
return
with _known_lock:
if text in _known:
return
_known.add(text)
_known_pattern = _compile_known(_known)
def hide(text: str) -> str:
"""Replace each secret in ``text`` with :data:`SECRET_TEXT`.
Args:
text: A log record, an error message, a remote command, or a URL.
Returns:
The text with each secret that a rule finds replaced.
"""
match = _PASSWORD_COMMAND.match(text)
if match is not None:
_remember_text(match.group("value"))
return match.group("head") + SECRET_TEXT
match = _SET_COMMAND.match(text)
if match is not None and names_secret(match.group("name")):
_remember_text(match.group("value"))
return match.group("head") + SECRET_TEXT
text = _ASSIGNMENT.sub(_hide_assignment, text)
known = _known_pattern
if known is not None:
text = known.sub(SECRET_TEXT, text)
return text
def without_secret(command: str) -> str:
"""A remote command with its secret value removed, for a history to recall.
The history of the remote command line keeps ``password `` for ``password hunter2``.
Thus ↑ recalls the start of the command, and the user types the new value again. If
the history kept :data:`SECRET_TEXT`, a recall and Enter would set the password to
that text.
Args:
command: A command as the user typed it.
Returns:
The command up to its secret value, or the command itself when it has none.
"""
for pattern in (_PASSWORD_COMMAND, _SET_COMMAND):
match = pattern.match(command)
if match is not None and (pattern is _PASSWORD_COMMAND or names_secret(match["name"])):
return match.group("head")
return command
def loggable(value: Any) -> Any:
"""A copy of ``value`` that a log can keep: each secret in it is :data:`SECRET_TEXT`.
The copy has the same shape: mappings stay mappings, and lists and tuples keep their
types. A dataclass becomes an object whose text form is the ``repr`` of the dataclass
with its secret fields hidden, so the log line looks as it did. Each text goes through
:func:`hide`. Each value that a rule hides is registered (:func:`remember`). Thus the
same value is also hidden when an error message repeats it later in the run.
Args:
value: The parameters or the summary of a run, or any part of them.
Returns:
The copy, ready for a log line or for ``json.dumps(..., default=str)``.
"""
if isinstance(value, Secret):
remember(value.value)
return SECRET_TEXT
if isinstance(value, (bytes, bytearray, memoryview)):
return _hidden(value)
if isinstance(value, str):
return hide(value)
if value is None or isinstance(value, (bool, int, float, Enum, PurePath, date, datetime)):
return value
if isinstance(value, Mapping):
return _loggable_mapping(value)
if is_dataclass(value) and not isinstance(value, type):
items = []
for f in fields(value):
item = getattr(value, f.name)
items.append((f.name, _hidden(item) if names_secret(f.name) else loggable(item)))
return _Shown(type(value).__name__, items)
if isinstance(value, (list, tuple, set, frozenset)):
items = _loggable_sequence(list(value))
return tuple(items) if isinstance(value, tuple) else items
return _Shown(hide(repr(value)))
class _Shown:
"""An object in a log, with its secrets hidden: its ``repr`` and its ``str`` are the same.
``json.dumps(..., default=str)`` writes an object as its ``str``, and a log line writes
a value in a dict as its ``repr``. Both forms of the original object can hold the
secret, so both forms of this object are the hidden form.
"""
__slots__ = ("_text",)
def __init__(self, name: str, items: list[tuple[str, Any]] | None = None) -> None:
"""Build the form ``Name(a=1, b=<secret>)``, or keep ``name`` as the whole text."""
if items is None:
self._text = name
else:
inner = ", ".join(f"{field}={_repr(value)}" for field, value in items)
self._text = f"{name}({inner})"
def __repr__(self) -> str:
"""The hidden form."""
return self._text
__str__ = __repr__
def _repr(value: Any) -> str:
"""The ``repr`` of a value in a hidden form, with :data:`SECRET_TEXT` not in quotes."""
return SECRET_TEXT if value == SECRET_TEXT else repr(value)
def _hidden(value: Any) -> Any:
""":data:`SECRET_TEXT` for a value that a name or a type makes secret, after it is registered.
An absent value (``None``, or an empty text) stays as it is. That a password was not
given is a fact that a maintainer can use, and it hides nothing.
"""
if value is None or value == "" or value == b"":
return value
remember(reveal(value))
return SECRET_TEXT
def _loggable_mapping(value: Mapping) -> dict:
""":func:`loggable` for a mapping: hide each value that its key or its record names secret."""
record_names = [value.get(name) for name in _RECORD_NAMES]
secret_record = any(isinstance(name, str) and names_secret(name) for name in record_names)
shown: dict = {}
for key, item in value.items():
named = names_secret(str(key)) or (secret_record and key in _RECORD_VALUES)
shown[key] = _hidden(item) if named else loggable(item)
return shown
def _loggable_sequence(items: list) -> list:
""":func:`loggable` for a sequence, and for an operation that sets a secret.
An operation that sets a value names the setting before the value:
``("set", "device_pin", "123456")``. Thus, in a sequence of three items that starts
with ``set`` and whose second item names a secret, the third item is the secret. No
other sequence has a value after a name. A list of setting names
(``["name", "device_pin", "radio_freq"]``) stays as it is.
"""
shown = [loggable(item) for item in items]
verb, name = (items[0], items[1]) if len(items) == 3 else (None, None)
if isinstance(verb, str) and verb.lower() == "set" and isinstance(name, str):
if names_secret(name):
shown[2] = _hidden(items[2])
return shown
def _hide_assignment(match: re.Match[str]) -> str:
"""Hide the value of ``name = value`` when the name is the name of a secret."""
value = match.group("value")
if not names_secret(match.group("name")) or value in (SECRET_TEXT, "None", "null"):
return match.group(0)
if value in ("''", '""', "b''", 'b""'):
return match.group(0)
_remember_text(value)
return match.group("name") + match.group("sep") + SECRET_TEXT
def _remember_text(value: str) -> None:
"""Register a value that a text rule found, without the quotes of a literal."""
text = value.strip()
if len(text) >= 2 and text[-1] in "'\"" and text.lstrip("b")[:1] == text[-1]:
text = text.lstrip("b")[1:-1]
remember(text)
def _compile_known(known: set[str]) -> re.Pattern[str]:
"""The pattern that finds each registered secret.
A long hex value matches also inside a longer run of hex, in any letter case, because a
library writes a whole frame as one run of hex. Each other value matches only as a
whole word, so a PIN of ``123456`` does not hide a part of ``71234567``.
"""
parts: list[str] = []
for text in sorted(known, key=len, reverse=True):
if len(text) >= _MIN_HEX_SUBSTRING and _HEX.fullmatch(text):
parts.append(f"(?i:{re.escape(text)})")
else:
parts.append(rf"(?<![\w]){re.escape(text)}(?![\w])")
return re.compile("|".join(parts))
__all__ = [
"SECRET_TEXT",
"SECRET_WORDS",
"Secret",
"hide",
"loggable",
"names_secret",
"remember",
"reveal",
"without_secret",
]
+10 -6
View File
@@ -53,7 +53,7 @@ from .atomicwrite import write_atomically
from .config import DeviceProfile, SpiWiring
from .connection import DeviceCommandError, MeshCoreDevice
from .discovery import TRANSPORT_SERIAL, DiscoveredDevice, spi_device
from .privatekey import KEY_BYTES, SEED_BYTES
from .privatekey import checked
_log = logging.getLogger(__name__)
@@ -949,15 +949,19 @@ class SpiRadioDevice(MeshCoreDevice):
menu announces a reconnect, which stops this node and starts a new one, and a run
on the command line stops the node when it ends.
The file gets the key in the form that the user gave. An imported seed stays a seed,
because the radio library keeps keys in that form, and a seed is the more complete
form (JP, 2026-10-13). A 64-byte key from a firmware companion has no seed, and the
library reads it in that form too.
The method does not restart the node itself. The services of the session subscribe
to the events of this connection, and a new connection under them would leave them
deaf. The reconnect of the session is the one path that rebuilds them.
"""
key = bytes.fromhex(key_hex)
if len(key) not in (KEY_BYTES, SEED_BYTES):
raise DeviceCommandError(
f"a private key has {KEY_BYTES} or {SEED_BYTES} bytes, not {len(key)}"
)
try:
key = checked(bytes.fromhex(key_hex))
except ValueError as exc:
raise DeviceCommandError(str(exc)) from None
await asyncio.to_thread(
write_atomically, self._state / "identity.key", key, owner_only=True
)
+2
View File
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: Apache-2.0
"""The demo: a synthetic Montréal mesh that the ``--demo`` flag runs MeshTerm against."""
+452
View File
@@ -0,0 +1,452 @@
# SPDX-License-Identifier: Apache-2.0
"""The device of the demo: the companion of our node in the Montréal world.
:class:`DemoDevice` is the simulator (:class:`~meshterm.core.connection.MockDevice`) with
the demo world in place of its small test world. It holds the contacts, the channels, and
the settings of a companion that has lived on the mesh for months. It answers a trace
from the link model, a repeater from the region tables and the neighbours of the world,
and it plays the live traffic of the world while the demo runs: the adverts, the packets
of the background, and the lines of the script that arrive after the start
(:data:`~meshterm.demo.script.LIVE_DIRECT`, :data:`~meshterm.demo.script.LIVE_CHANNEL`).
When a channel message of our node mentions a node that has an answer in the script
(:data:`~meshterm.demo.script.REPLIES`), that node answers it.
Everything that the device sends comes from a fixed seed and a time after the start (or
after the message that it answers). Thus two runs play the same traffic at the same
moments.
"""
from __future__ import annotations
import asyncio
import hashlib
import random
from datetime import timedelta
from ..core import transmit_gate
from ..core.channels import MENTION
from ..core.connection import EventCallback, MockDevice, Unsubscribe
from ..core.events import MeshEvent
from ..core.models import Contact, Hop, Message, NeighbourInfo, RoomAccess, TraceResult, utcnow
from ..core.region_sim import SimulatedRegionMap
from . import script, traffic, world
from .history import TX_POWER
#: The length of one part of the live traffic (seconds). The device makes the traffic one
#: part at a time, each from its own seed, so a long session never waits for all of it.
_PART_S = 3600.0
#: Whether the device plays the background traffic of the world while the demo runs. The
#: screenshots turn it off (``scripts/screenshots.py``): a packet that arrives during a
#: capture changes the activity bars of the header, so two captures would not be the same.
#: The lines of the script still arrive.
PLAY_BACKGROUND = True
#: The TX power of each repeater of the world, as a read of its settings shows it.
REMOTE_TX = 22
#: The TX power at which a repeater of the world is best heard. It is lower than
#: :data:`REMOTE_TX`. Thus TX optimize finds a lower power for a repeater, with a better
#: signal at its target (refer to :meth:`DemoDevice._tuned`).
REMOTE_TX_BEST = 19
class DemoDevice(MockDevice):
"""The simulator, in the demo world (refer to the module docstring)."""
def __init__(self) -> None:
"""Build the companion of our node, with the state that the demo starts from."""
super().__init__(
seed=hash_seed("device"),
optimal_tx=TX_POWER,
optimal_remote_tx=REMOTE_TX_BEST,
admin_password=world.ADMIN_PASSWORD,
)
now = utcnow()
self._tx_power = TX_POWER
self._default_remote_tx = REMOTE_TX
self._contacts = [_contact(node) for node in world.NODES if node.added]
room = world.node(world.JOINED_ROOM)
# The room knows our node as a member. Its posts until now are in the history
# already, so a login brings only the posts that come after the start.
self._room_board = []
self._room_access = {room.public_key: RoomAccess.MEMBER}
self._room_synced = {room.public_key: now}
self._neighbour_tables = {}
self._region_maps = {
node.node_id: SimulatedRegionMap(world.REGION_TABLES[node.name])
for node in world.REPEATERS
}
self._info.update(
name=world.SELF.name,
public_key=world.SELF.public_key,
adv_lat=world.SELF.lat,
adv_lon=world.SELF.lon,
adv_loc_policy=1,
max_tx_power=TX_POWER,
# The preset of North America, which the mesh of Montréal uses.
radio_freq=910.525,
radio_bw=62.5,
radio_sf=7,
radio_cr=5,
)
self._channels = {
channel.slot: {
"channel_idx": channel.slot,
"channel_name": channel.name,
"channel_secret": channel.secret,
}
for channel in world.CHANNELS
}
self._clock_offset = 0
# Our node uses the path width of most of the mesh. A trace collapses it to the
# widest width that it can encode (three bytes become two).
self._path_hash_mode = world.path_width(world.SELF) - 1
self._answers = 0
#: The route that a flood login teaches the device: one hop, through Mont-Royal.
_LEARNED_ROUTE = (world.node("Mont-Royal").hash,)
async def get_device_info(self) -> dict: # noqa: D102 - inherited docstring
return {
"model": "MeshCore Simulator",
"ver": "v1.15.0",
"fw_build": "demo",
"ble_pin": self._device_pin,
"repeat": self._client_repeat,
}
async def get_battery(self) -> dict: # noqa: D102 - inherited docstring
return {"level": 3980, "used_kb": 412, "total_kb": 1024}
async def get_stats(self) -> dict: # noqa: D102 - inherited docstring
return {
"battery_mv": 3980,
"uptime_secs": 273_640, # 3 d 4 h
"errors": 0,
"queue_len": 0,
"noise_floor": -112,
"last_rssi": -96,
"last_snr": 7.25,
"tx_air_secs": 318,
"rx_air_secs": 9_412,
"recv": 14_803,
"sent": 1_212,
"flood_tx": 388,
"direct_tx": 824,
"flood_rx": 11_590,
"direct_rx": 3_213,
"recv_errors": 41,
}
# -- the remote nodes -----------------------------------------------------------
def _remote_config(self, node: Contact) -> dict[str, str]:
"""The settings of a node of the world, as its CLI shows them."""
cfg = super()._remote_config(node)
known = _node_of(node)
if known is not None and cfg.get("lat") == "0":
cfg.update(
lat=f"{known.lat:.4f}",
lon=f"{known.lon:.4f}",
freq="910.525",
sf="7",
**{"owner.info": "Mesh Montréal · #montreal", "advert.interval": "240"},
)
return cfg
async def send_remote_command( # noqa: D102 - inherited docstring
self, node: Contact, command: str, *, timeout: float = 8.0
) -> str | None:
verb = command.strip().split()[0].lower() if command.strip() else ""
if verb == "neighbors" and self._mock_key(node) in self._admin_sessions:
transmit_gate.mark()
await asyncio.sleep(0)
table = self._neighbours_of(node)
return "\n".join(f"{n.node} {n.snr:+.1f}dB {_ago(n)}s" for n in table) or "none"
return await super().send_remote_command(node, command, timeout=timeout)
async def fetch_neighbours(self, node: Contact) -> list[NeighbourInfo]: # noqa: D102
await asyncio.sleep(0)
if self._mock_key(node) not in self._admin_sessions:
return await super().fetch_neighbours(node) # the same refusal as the simulator
return self._neighbours_of(node)
def _neighbours_of(self, node: Contact) -> list[NeighbourInfo]:
"""The neighbour table of a repeater of the world: each node that it hears now."""
repeater = _node_of(node)
if repeater is None or not repeater.is_repeater:
return []
now = utcnow()
table = []
for other in (world.SELF, *world.NODES):
snr = world.link_snr(other, repeater)
if snr is None or not (other is world.SELF or traffic.active(other, 0.0)):
continue
heard_at = now - timedelta(seconds=self._rng.uniform(30.0, 3600.0))
table.append(
NeighbourInfo(other.public_key[:8], world.reading(snr, self._rng), heard_at)
)
return table
# -- the answers -------------------------------------------------------------------
async def send_channel_message(self, index: int, text: str) -> None: # noqa: D102
await super().send_channel_message(index, text)
self._answer(index, text)
def _answer(self, index: int, text: str) -> None:
"""Let a node that ``text`` mentions answer it, on the same channel.
The first mentioned node that has an answer in :data:`~meshterm.demo.script.REPLIES`
answers after :data:`~meshterm.demo.script.REPLY_DELAY_S`. The answer arrives as a
live line of the script does: the copies of its flood, then the message.
"""
channel = next((c for c in world.CHANNELS if c.slot == index), None)
name = next((n for n in MENTION.findall(text) if n in script.REPLIES), None)
if channel is None or name is None:
return
self._answers += 1
rng = random.Random(f"meshterm-demo/answer/{self._answers}")
line = script.Line(0.0, name, script.REPLIES[name])
items = _live_channel(script.REPLY_DELAY_S, channel.name, line, rng)
loop = asyncio.get_running_loop()
start = loop.time()
async def play() -> None:
for item in items:
await asyncio.sleep(max(0.0, item.offset_s - (loop.time() - start)))
for listener in list(self._listeners):
_deliver(item, listener)
task = asyncio.create_task(play())
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)
# -- traces -----------------------------------------------------------------------
async def run_trace( # noqa: D102 - inherited docstring
self,
target: str,
*,
path: str | None = None,
timeout: float | None = None,
) -> TraceResult:
transmit_gate.mark()
await asyncio.sleep(0.4) # the time on the air, so that the progress is visible
relays = _relays(target, path)
width = len(path.split(",")[0].strip()) // 2 if path else 1
if relays is None:
return TraceResult(target=target, success=False, tx_power=self._tx_power)
walked = world.walk(relays, self._rng)
snrs = self._tuned(relays, walked.snrs) if walked.success else None
if snrs is None:
return TraceResult(
target=target, success=False, tx_power=self._tx_power, path_hash_bytes=width
)
hops = [
Hop(index, relay.public_key[: 2 * width], snr)
for index, (relay, snr) in enumerate(zip(relays, snrs, strict=False))
]
hops.append(Hop(len(relays), None, snrs[-1]))
return TraceResult(
target=target,
success=True,
hops=hops,
round_trip_ms=walked.rtt_ms,
tx_power=self._tx_power,
path_hash_bytes=width,
)
def _tuned(self, relays: list[world.Node], snrs: list[float]) -> list[float] | None:
"""The readings of a trace, with the TX power that TX optimize set on its relays.
The world gives each link its SNR at :data:`REMOTE_TX_BEST`. A relay that
transmits at another power is heard less well on the link that it transmits into.
The loss follows the curve of the simulator, which has its peak at the best power
(:meth:`~meshterm.core.connection.MockDevice._expected_remote_snr`). Thus the sweep
of TX optimize finds that peak, and a power far from it loses traces: a reading
under :data:`~meshterm.demo.world.FLOOR_DB` always does. A relay that nobody tuned
keeps the readings of the world.
Args:
relays: The forced path, as nodes.
snrs: The readings of the world: one for each link, the last one back to us.
Returns:
The readings, or ``None`` if a weaker link lost the trace.
"""
best = self._expected_remote_snr(self.optimal_remote_tx)
readings = list(snrs)
for index, relay in enumerate(relays):
tx = self._remote_tx_for(relay.public_key)
if tx is None:
continue
snr = readings[index + 1] + self._expected_remote_snr(tx) - best
# Below the floor, the radio cannot decode the packet at all.
if snr < world.FLOOR_DB or self._rng.random() < world.loss_chance(snr):
return None
readings[index + 1] = round(snr * 4) / 4
return readings
# -- the live traffic ---------------------------------------------------------------
async def subscribe_events( # noqa: D102 - inherited docstring
self, on_event: EventCallback
) -> Unsubscribe:
# Each subscriber gets the traffic from the moment that it subscribes, in parts of
# one hour. A part comes from its own seed, so the same moment of each run has the
# same packet.
stop = asyncio.Event()
loop = asyncio.get_running_loop()
start = loop.time()
async def play() -> None:
part = 0
while not stop.is_set():
for item in _live_part(part):
wait = item.offset_s - (loop.time() - start)
if wait > 0:
try:
await asyncio.wait_for(stop.wait(), wait)
except asyncio.TimeoutError:
pass # the moment of the next item came
if stop.is_set():
return
_deliver(item, on_event)
part += 1
task = asyncio.create_task(play())
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)
self._listeners.append(on_event)
def unsubscribe() -> None:
stop.set()
if on_event in self._listeners:
self._listeners.remove(on_event)
return unsubscribe
def hash_seed(name: str) -> int:
"""A fixed integer seed for one random source of the demo device."""
return int.from_bytes(hashlib.sha256(f"meshterm-demo/{name}".encode()).digest()[:4], "big")
def _contact(node: world.Node) -> Contact:
"""The contact that the demo device holds for ``node``.
The contact has no time of last reception. The app takes it from the history instead,
where it is the time of the last advert that our node heard from the node (refer to
:class:`~meshterm.services.device_state.DeviceState`). A real device knows no more.
"""
return Contact(
name=node.name,
public_key=node.public_key,
key_prefix=node.node_id,
node_type=node.node_type,
lat=node.lat if node.shares_location else None,
lon=node.lon if node.shares_location else None,
route_hops=world.device_route(node),
)
def _node_of(contact: Contact) -> world.Node | None:
"""The node of the world that a contact names, by its key (or by its name)."""
key = (contact.public_key or contact.key_prefix or "").lower()
for node in (world.SELF, *world.NODES):
if key and node.public_key.startswith(key[:12]):
return node
return world.BY_NAME.get(contact.name)
def _relays(target: str, path: str | None) -> list[world.Node] | None:
"""The relays of a trace: the forced path, or the route out to ``target`` and back.
Returns:
The relays, or ``None`` when a hash of the path names no repeater of the world
(then no relay answers, and the trace times out).
"""
if path:
relays = []
for token in (part.strip().lower() for part in path.split(",") if part.strip()):
relay = next((r for r in world.REPEATERS if r.public_key.startswith(token)), None)
if relay is None:
return None
relays.append(relay)
return relays
node = world.BY_NAME.get(target)
if node is None or not node.is_repeater:
return None
route = world.best_route(world.SELF, node)
if route is None:
return None
return [*route, node, *reversed(route)]
def _ago(entry: NeighbourInfo) -> int:
"""The seconds since a repeater last heard a neighbour, as its CLI writes them."""
if entry.heard_at is None:
return 0
return max(0, int((utcnow() - entry.heard_at).total_seconds()))
def _live_part(part: int) -> list[traffic.Heard]:
"""The live traffic of one part: the background, then the lines of the script in it."""
start, end = part * _PART_S, (part + 1) * _PART_S
heard = traffic.traffic(start, end, f"live/{part}") if PLAY_BACKGROUND else []
rng = random.Random(f"meshterm-demo/live-lines/{part}")
for offset, line in script.LIVE_DIRECT:
if start <= offset < end:
heard.extend(_live_direct(offset, line, rng))
for offset, name, line in script.LIVE_CHANNEL:
if start <= offset < end:
heard.extend(_live_channel(offset, name, line, rng))
heard.sort(key=lambda item: item.offset_s)
return heard
def _live_direct(offset: float, line: script.Line, rng: random.Random) -> list[traffic.Heard]:
"""A direct message that arrives during the demo, and the packets that carried it."""
sender = world.node(line.sender)
body = {
"payload_typename": "TEXT_MSG",
"dest_hash": world.SELF.hash,
"src_hash": sender.hash,
"cipher_mac": f"{rng.getrandbits(16):04x}",
}
copies = traffic.heard_flood(sender, offset, body, rng)
first = copies[0].offset_s if copies else offset + 1.0
snr = copies[0].observation.snr if copies and copies[0].observation else None
message = Message(text=line.text, sender=sender.node_id, snr=snr)
return [*copies, traffic.Heard(first + 0.05, message=message)]
def _live_channel(
offset: float, name: str, line: script.Line, rng: random.Random
) -> list[traffic.Heard]:
"""A channel message that arrives during the demo, and the packets that carried it."""
channel = next(c for c in world.CHANNELS if c.name == name)
sender = world.node(line.sender)
wire = f"{sender.name}: {line.text}"
body = traffic.grp_txt_body(channel.secret, wire, utcnow() + timedelta(seconds=offset))
if channel.scope:
body = traffic.scoped(body, channel.scope)
copies = traffic.heard_flood(sender, offset, body, rng, region=channel.scope)
first = copies[0].offset_s if copies else offset + 1.0
snr = copies[0].observation.snr if copies and copies[0].observation else None
message = Message(text=wire, channel=channel.slot, is_channel=True, snr=snr)
return [*copies, traffic.Heard(first + 0.05, message=message)]
def _deliver(item: traffic.Heard, on_event: EventCallback) -> None:
"""Send one live item to the subscriber, stamped with the time of now."""
now = utcnow()
if item.observation is not None:
item.observation.observed_at = now
on_event(MeshEvent.observation_event(item.observation))
if item.message is not None:
item.message.received_at = now
item.message.sender_timestamp = now
on_event(MeshEvent.message_event(item.message))
+371
View File
@@ -0,0 +1,371 @@
# SPDX-License-Identifier: Apache-2.0
"""The history of the demo: what our node heard and did before the demo started.
At each start, ``--demo`` writes a new database with this module. The database holds what
the companion of our node would have stored after months on the mesh of Montréal:
* the adverts of each node since our node first heard it, and the traffic of the last
eight days, packet by packet, each copy on its own path (:mod:`meshterm.demo.traffic`),
* the conversations of :mod:`meshterm.demo.script`: the stored messages, and the packets
that carried them, encrypted as the firmware encrypts them, so that the paths of a
message can be found again,
* the traces that our node ran (:data:`~meshterm.demo.script.TRACES`), each try in the
link model, and the records that the walks set in the trophy case,
* the neighbour tables of the repeaters that our node administers.
Each time comes from ``now`` and a fixed age, and each random value comes from a fixed
seed. Thus two runs give the same database, with only the times moved to the new now.
"""
from __future__ import annotations
import random
from collections import defaultdict
from datetime import datetime, timedelta
from .. import __version__
from ..core.channels import channel_identity
from ..core.models import (
PATH_TRACE_TARGET,
ChatMessage,
Hop,
NeighbourInfo,
Observation,
TraceResult,
)
from ..persistence.repository import Repository
from ..services.records import CATEGORY_BY_ID, walk_from_trace, walk_scores
from . import script, traffic, world
#: How many days of history the demo writes. The traffic of the last eight days is
#: complete. Each older day holds one advert from each node (refer to
#: :func:`~meshterm.demo.traffic.adverts`).
HISTORY_DAYS = 300
#: The transmit power of the demo device (dBm), stored with each trace.
TX_POWER = 22
#: The map viewport that the demo opens on: the island of Montréal, with the two shores.
MAP_VIEW = (45.5150, -73.5900, 10)
def seed(repo: Repository, now: datetime) -> None:
"""Write the full history of the demo into ``repo``.
Args:
repo: An empty database.
now: The moment that the history ends at: the start of the demo.
"""
rng = random.Random("meshterm-demo/history")
heard = traffic.traffic(-HISTORY_DAYS * 86400.0, 0.0, "history")
messages, carried = _conversations(now, rng)
heard.extend(carried)
heard.sort(key=lambda item: item.offset_s)
traffic.stamp(heard, now)
_store_observations(repo, now, [item.observation for item in heard if item.observation])
# The transcripts are in the order of the row ids. Thus the oldest message goes first.
for message in sorted(messages, key=lambda m: m.created_at):
repo.record_chat_message(message)
_store_traces(repo, now, rng)
_store_neighbours(repo, now, rng)
repo.set_map_view(*MAP_VIEW)
def _store_observations(repo: Repository, now: datetime, observations: list[Observation]) -> None:
"""Store the observations, in one ``monitor`` run for each day, as the app makes them.
The app opens one run each time it starts to record. The demo writes one for each of
the dense days, and one for all the older days.
"""
by_day: dict[int, list[Observation]] = defaultdict(list)
for obs in observations:
age_days = (now - obs.observed_at).total_seconds() / 86400.0
by_day[min(int(age_days), 8)].append(obs)
for _, rows in sorted(by_day.items(), reverse=True):
rows.sort(key=lambda obs: obs.observed_at)
run_id = repo.start_run("monitor", {"mode": "background"}, at=rows[0].observed_at)
count = repo.record_observations(run_id, rows)
repo.finish_run(run_id, "ok", {"observations": count}, at=rows[-1].observed_at)
def _first_copy(origin: world.Node, offset: float, body: dict, rng: random.Random, **kwargs):
"""The copies of a flood that our node must have received, because the script says so.
A flood can lose all its copies on weak links. A line of the script arrived by
definition. Thus the function runs the flood again until a copy arrives.
"""
for _ in range(12):
copies = traffic.heard_flood(origin, offset, body, rng, **kwargs)
if copies:
return copies
raise RuntimeError(f"no flood from {origin.name} reaches our node")
def _conversations(
now: datetime, rng: random.Random
) -> tuple[list[ChatMessage], list[traffic.Heard]]:
"""The stored messages of the script, and the packets that carried them.
Args:
now: The end of the history.
rng: The source of the delays, the losses, and the noise.
Returns:
The messages, and the packets that our node heard of them.
"""
messages: list[ChatMessage] = []
heard: list[traffic.Heard] = _greetings(rng)
for channel in world.CHANNELS:
identity = channel_identity(channel.name, channel.secret)
for line in script.CHANNEL_LINES.get(channel.name, ()):
offset = -line.age_min * 60.0
sender = world.node(line.sender)
wire = f"{sender.name}: {line.text}"
when = now + timedelta(seconds=offset)
body = traffic.grp_txt_body(channel.secret, wire, when)
if channel.scope:
body = traffic.scoped(body, channel.scope)
if line.sender == script.ME:
heard.extend(traffic.heard_flood(sender, offset, body, rng, region=channel.scope))
messages.append(
ChatMessage(
text=line.text,
outbound=True,
is_channel=True,
channel_id=identity,
channel_idx=channel.slot,
peer_name=channel.name,
scope=channel.scope or "*",
created_at=when,
)
)
continue
copies = _first_copy(sender, offset, body, rng, region=channel.scope)
heard.extend(copies)
messages.append(
ChatMessage(
text=wire,
is_channel=True,
channel_id=identity,
channel_idx=channel.slot,
snr=copies[0].observation.snr if copies[0].observation else None,
created_at=when,
)
)
for peer_name, lines in script.DIRECT_LINES.items():
peer = world.node(peer_name)
for line in lines:
offset = -line.age_min * 60.0
when = now + timedelta(seconds=offset)
mac = f"{rng.getrandbits(16):04x}"
if line.sender == script.ME:
body = {
"payload_typename": "TEXT_MSG",
"dest_hash": peer.hash,
"src_hash": world.SELF.hash,
"cipher_mac": mac,
}
heard.extend(_send(world.SELF, peer, offset, body, rng))
if line.acked:
ack = {"payload_typename": "ACK", "ack_crc": f"{rng.getrandbits(32):08x}"}
heard.extend(_send(peer, world.SELF, offset + 2.5, ack, rng))
messages.append(
ChatMessage(
text=line.text,
outbound=True,
peer=peer.node_id,
peer_name=peer.name,
acked=line.acked,
created_at=when,
)
)
continue
body = {
"payload_typename": "TEXT_MSG",
"dest_hash": world.SELF.hash,
"src_hash": peer.hash,
"cipher_mac": mac,
}
copies = _first_copy(peer, offset, body, rng)
heard.extend(copies)
snr = copies[0].observation.snr if copies[0].observation else None
messages.append(
ChatMessage(text=line.text, peer=peer.node_id, snr=snr, created_at=when)
)
for room_name, lines in script.ROOM_LINES.items():
room = world.node(room_name)
link = world.link_snr(room, world.SELF) or 0.0
for line in lines:
when = now - timedelta(minutes=line.age_min)
if line.sender == script.ME:
messages.append(
ChatMessage(
text=line.text,
outbound=True,
peer=room.node_id,
peer_name=room.name,
acked=line.acked,
created_at=when,
)
)
continue
author = world.BY_NAME.get(line.sender)
messages.append(
ChatMessage(
text=line.text,
peer=room.node_id,
snr=world.reading(link, rng),
author=author.public_key[:8] if author is not None else line.sender,
created_at=when,
)
)
return messages, heard
def _greetings(rng: random.Random) -> list[traffic.Heard]:
"""The adverts that the people of the script send when they come online to talk.
A person who opens the app to write often sends an advert first. Thus a node that
spoke a few minutes ago was also heard a few minutes ago, and the lists show it as
recent. The function adds one advert before the first line of each sitting in the last
two days (a sitting is a run of lines less than an hour apart).
"""
lines = [
line
for group in (script.CHANNEL_LINES, script.DIRECT_LINES)
for conversation in group.values()
for line in conversation
if line.sender != script.ME and line.age_min < 2 * 1440
]
last_advert: dict[str, float] = {}
heard: list[traffic.Heard] = []
for line in sorted(lines, key=lambda line: -line.age_min):
offset = -line.age_min * 60.0
if offset - last_advert.get(line.sender, -1e12) < 3600.0:
continue
sender = world.node(line.sender)
moment = offset - rng.uniform(60.0, 900.0)
heard.extend(
traffic.heard_flood(sender, moment, traffic.advert_body(sender), rng, advert=True)
)
last_advert[line.sender] = offset
return heard
def _send(
src: world.Node, dst: world.Node, offset: float, body: dict, rng: random.Random
) -> list[traffic.Heard]:
"""The packets that our node hears from a direct message (or its ack) from ``src`` to ``dst``.
The message follows the route that the device holds when it holds one, else it floods.
"""
holder, target = (dst, src) if dst is world.SELF else (src, dst)
route = world.device_route(target) if holder is world.SELF else None
if route is None:
return traffic.heard_flood(src, offset, body, rng)
relays = tuple(world.BY_NAME[_name_of(h)] for h in route)
if src is not world.SELF:
relays = tuple(reversed(relays))
return traffic.heard_direct(relays, src, offset, body, rng)
def _name_of(hash_hex: str) -> str:
"""The name of the repeater with this path hash."""
return next(node.name for node in world.REPEATERS if node.hash == hash_hex)
def _store_traces(repo: Repository, now: datetime, rng: random.Random) -> None:
"""Run each trace of the script in the link model, store each try, and score the walks.
A trace that does not come back is tried again a minute later, as a person does, up to
five times. Each try is a run of its own, as in the trace screen. A walk that comes
back goes to the trophy case, through the same scoring as a live trace.
"""
canonical = {node.hash: node.node_id for node in world.NODES}
positions = {node.node_id: (node.lat, node.lon) for node in world.NODES if node.shares_location}
self_pos = (world.SELF.lat, world.SELF.lon)
for age_min, target, names in script.TRACES:
relays = [world.node(name) for name in names]
spec = ",".join(relay.hash for relay in relays)
label = target or PATH_TRACE_TARGET
for attempt in range(5):
when = now - timedelta(minutes=age_min) + timedelta(seconds=75 * attempt)
walked = world.walk(relays, rng)
# The last reading of a walk that came back is the link to our node: the hop
# without a hash that the firmware adds at the end.
hops = [
Hop(i, relay.hash, snr)
for i, (relay, snr) in enumerate(zip(relays, walked.snrs, strict=False))
]
if walked.success:
hops.append(Hop(len(relays), None, walked.snrs[-1]))
result = TraceResult(
target=label,
success=walked.success,
hops=hops if walked.success else [],
round_trip_ms=walked.rtt_ms,
tx_power=TX_POWER,
path_hash_bytes=1,
timestamp=when,
)
params = {"target": label, "path": spec}
run_id = repo.start_run("trace", params, at=when - timedelta(seconds=2))
repo.record_trace(run_id, result)
placed: list[str] = []
derived = walk_from_trace(
result, canonical=canonical.get, positions=positions, self_pos=self_pos
)
if derived is not None:
walk_spec, route, stats = derived
for category_id, score in walk_scores(stats).items():
category = CATEGORY_BY_ID[category_id]
row = repo.record_discovery(
category_id,
1,
walk_spec,
route,
score=score,
stats=stats.as_dict(),
app_version=__version__,
ascending=category.ascending,
at=when,
)
if row is not None:
placed.append(f"{category.title} — {category.format_score(score)}")
summary = {
"target": label,
"success": result.success,
"min_snr": result.min_snr,
"rtt_ms": result.round_trip_ms,
"records": placed or None,
}
repo.finish_run(run_id, "ok", summary, at=when)
if walked.success:
break
def _store_neighbours(repo: Repository, now: datetime, rng: random.Random) -> None:
"""Store two fetches of the neighbour table of each repeater that our node administers.
A repeater lists each node that it hears directly, with the SNR that it measured, the
same as the simulated device answers a live fetch.
"""
for name in world.ADMINISTERED:
repeater = world.node(name)
for age_h in (30.0, 2.0):
fetched = now - timedelta(hours=age_h)
offset = -age_h * 3600.0
entries = []
for other in (world.SELF, *world.NODES):
snr = world.link_snr(other, repeater)
if snr is None or not (other is world.SELF or traffic.active(other, offset)):
continue
heard_at = fetched - timedelta(seconds=rng.uniform(60.0, 4 * 3600.0))
entries.append(
NeighbourInfo(other.public_key[:8], world.reading(snr, rng), heard_at)
)
params = {"mode": "neighbours", "repeater": repeater.name}
run_id = repo.start_run("trace", params, at=fetched - timedelta(seconds=3))
repo.record_neighbours(run_id, repeater.node_id, entries, at=fetched)
summary = {"repeater": repeater.name, "neighbours": len(entries)}
repo.finish_run(run_id, "ok", summary, at=fetched)
+159
View File
@@ -0,0 +1,159 @@
# SPDX-License-Identifier: Apache-2.0
"""The home of the demo: its own data directory, written again at each start.
``--demo`` never uses the data directory of the user. It uses ``<config_dir>/demo``, and
before the app opens, :func:`prepare` writes everything in it again from the demo world:
the database (:mod:`meshterm.demo.history`) and the stores of the config directory (the
joined room, the admin passwords, the regions, the Watchtower). Thus each run starts from
the same state, whatever the previous run changed, and the history of the user never
gets a node of the demo.
Two things in the directory stay from one run to the next: the tile cache of the map,
because the tiles are the same and a download is slow, and the log.
"""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from ..core.admin_store import AdminStore
from ..core.channels import channel_identity
from ..core.config import DB_FILENAME
from ..core.instancelock import hold_instance_lock
from ..core.models import Contact, LoginResult, RoomAccess, RoomLogin
from ..core.region_sim import SimulatedRegionMap
from ..core.region_store import RegionStore
from ..core.regions import WILDCARD, parse_region_list
from ..core.room_store import RoomStore
from ..core.watch_store import WatchStore
from ..persistence.repository import Repository
from . import history, script, world
#: The name of the demo directory, under the config directory of the user.
DEMO_DIRNAME = "demo"
#: The files that the demo writes again at each start. A store that the demo does not
#: fill is deleted, so that nothing that a previous run changed stays.
_STORES = (
"preferences.toml",
"contacts.json",
"channels.json",
"rooms.json",
"admin.json",
"regions.json",
"watchtower.json",
"courier.json",
"mutes.json",
"remote.json",
"settings.json",
"adverts.json",
"devices.json",
)
def demo_home(config_dir: Path) -> Path:
"""The data directory of the demo, under the config directory of the user."""
return config_dir / DEMO_DIRNAME
def prepare(home: Path, now: datetime) -> None:
"""Write the demo directory again: a new history that ends at ``now``, and new stores.
The function holds the instance lock of the directory while it writes. Thus it never
writes under a demo that runs in another window.
Args:
home: The demo directory (refer to :func:`demo_home`).
now: The moment that the history ends at.
Raises:
InstanceBusy: Another MeshTerm runs the demo now.
"""
with hold_instance_lock(home):
for name in _STORES:
(home / name).unlink(missing_ok=True)
db_path = home / DB_FILENAME
_empty(db_path)
repo = Repository(db_path)
try:
history.seed(repo, now)
finally:
repo.close()
_write_stores(home, now)
def _empty(db_path: Path) -> None:
"""Delete each row of an existing database, and keep the file.
The function does not delete the file, because another process can have it open: the
``emulate`` command opens the database of the demo, then starts the MeshTerm that runs
in its window, which prepares the demo. Windows does not let a process delete a file
that is open.
"""
if not db_path.exists():
return
conn = sqlite3.connect(db_path)
try:
tables = [
row[0]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")
if row[0] != "sqlite_sequence" and not row[0].startswith("sqlite_")
]
conn.execute("PRAGMA foreign_keys = OFF")
for table in tables:
conn.execute(f'DELETE FROM "{table}"')
if conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_sequence'").fetchone():
conn.execute("DELETE FROM sqlite_sequence")
conn.commit()
finally:
conn.close()
def contact(node: world.Node) -> Contact:
"""The contact that the demo device holds for ``node``, as a store keys it."""
return Contact(
name=node.name,
public_key=node.public_key,
key_prefix=node.node_id,
node_type=node.node_type,
)
def _write_stores(home: Path, now: datetime) -> None:
"""Write the stores of the config directory that the demo fills.
Each store writes its own file through its own methods, so that the files have the
form that the app reads.
Args:
home: The demo directory.
now: The start of the demo, from which the times of the alerts come.
"""
room = world.node(world.JOINED_ROOM)
RoomStore(home / "rooms.json").record(
contact(room),
world.ROOM_PASSWORD,
RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER, flood=False),
)
admins = AdminStore(home / "admin.json")
for name in world.ADMINISTERED:
admins.remember(contact(world.node(name)), world.ADMIN_PASSWORD)
regions = RegionStore(home / "regions.json")
for repeater in world.REPEATERS:
listed = SimulatedRegionMap(world.REGION_TABLES.get(repeater.name)).allowed_names()
regions.learn_carried(repeater.node_id, [WILDCARD, *parse_region_list(listed)])
for channel in world.CHANNELS:
if channel.scope:
regions.set_channel_scope(channel_identity(channel.name, channel.secret), channel.scope)
watch = WatchStore(home / "watchtower.json")
for name in script.WATCHED:
node = world.node(name)
watch.watch(node.node_id, node.name, node_type=node.node_type)
for age_min, kind, name, message in script.ALERTS:
watch.add_alert(kind, name, message, when=now - timedelta(minutes=age_min))
for node in world.NODES:
if node.quiet_hours is not None:
watch.remember_known(node.node_id)
watch.flush()
+444
View File
@@ -0,0 +1,444 @@
# SPDX-License-Identifier: Apache-2.0
"""What the people of the demo world say: the channels, the direct messages, and the room.
The conversations are in English, and they are about ordinary things: the mesh, a
meetup, a bike ride, the weather, a family dinner. Each line has a time on a virtual clock
(refer to :func:`at`). The virtual clock sets "now" at :data:`NOW_LOCAL`, a late morning
on a weekday. The history writes each line at its age before the real time of the run.
Thus the ages on the screens ("12m ago") are the same at each run. When the clock of the
run is also set to that morning (the screenshots do this), the times of day are correct
too: the plan for lunch is made before lunch.
The script only holds text and times. :mod:`meshterm.demo.history` turns each line into
the stored message and into the packets that our node heard when the line went over the
air.
"""
from __future__ import annotations
from dataclasses import dataclass
from .world import SELF
#: The local time of day that "now" stands for on the virtual clock (hours): 11:42.
NOW_LOCAL = 11 + 42 / 60
def at(days_ago: int, clock: str) -> float:
"""The age (minutes before now) of a moment on the virtual clock.
Args:
days_ago: 0 for today, 1 for yesterday, and so on.
clock: The local time of day, as ``"HH:MM"``.
Returns:
The minutes from that moment to now. A time later than :data:`NOW_LOCAL` on day
0 is a bug in the script, because it is in the future.
"""
hours, minutes = (int(part) for part in clock.split(":"))
age = days_ago * 1440.0 + NOW_LOCAL * 60.0 - (hours * 60.0 + minutes)
if age < 0:
raise ValueError(f"{clock} today is after now on the virtual clock")
return age
@dataclass(frozen=True, slots=True)
class Line:
"""One line of a conversation.
Attributes:
age_min: How many minutes before now the line was sent.
sender: The name of the node that sent it: a node of the world, :data:`ME` for our
node, or, in a room, the room itself (a notice of its admin) or an 8-hex key
prefix (an author that no contact names).
text: The body.
acked: For our own direct message or room post: whether the ack came back.
"""
age_min: float
sender: str
text: str
acked: bool = True
#: The sender name of our own lines.
ME = SELF.name
#: The channel conversations, by channel name, oldest line first.
CHANNEL_LINES: dict[str, tuple[Line, ...]] = {
"Public": (
Line(
at(6, "20:12"),
"Félix",
"New repeater up in Longueuil, on the roof of my building. Can anyone hear it?",
),
Line(at(6, "20:15"), "Steve K.", "Loud and clear from Old Longueuil, +9 dB"),
Line(
at(6, "20:21"),
"Ana Brossard",
"Heard it from Brossard too, two hops through Mont-Royal",
),
Line(at(6, "20:24"), "Félix", "Thanks! I'll leave it in test mode this week"),
Line(at(5, "21:40"), "Kim-NDG", "Anyone else getting duplicates through Oratoire tonight?"),
Line(
at(5, "21:46"),
"Jean-Seb",
"That's expected, Oratoire and Mont-Royal hear each other really well. Nothing broke.",
),
Line(at(5, "21:48"), "Kim-NDG", "ok good, I thought my T-Deck was haunted 👻"),
Line(
at(4, "07:55"),
"Sam Verdun",
"Verdun-Quai is offline, flat battery. I'll go up on the roof tomorrow morning",
),
Line(at(3, "08:31"), "Sam Verdun", "Verdun-Quai is back with a new battery 🔋"),
Line(at(3, "08:40"), ME, "Thanks Sam! Getting it through Mont-Royal at +3 dB"),
Line(
at(2, "21:05"),
"Dave-Lachine",
"Lachine path is solid tonight: Lachine → Mont-Royal → Stade, worst link -1.5 dB",
),
Line(
at(2, "21:19"),
"Nico R.",
"Biking up Berri with a node in my bag, pinging every 2 min. Zero drops so far 🚲",
),
Line(
at(1, "12:10"),
"Chloé L.",
"The leaves are peaking on the mountain, great day for a walk",
),
Line(
at(1, "12:25"),
"Jean-Seb",
"And the repeater at the chalet hears everyone, even through the trees 😄",
),
Line(at(0, "07:48"), "Noor", "First test from Côte-des-Neiges, can anyone hear me?"),
Line(at(0, "07:52"), "Marie-Ève", "@[Noor] got you through Mont-Royal, +6 dB. Welcome!"),
Line(
at(0, "10:05"), "Mathilde", "Hi everyone! First time on MeshCore, I'm in Saint-Henri 👋"
),
Line(
at(0, "10:08"),
"Marie-Ève",
"Welcome @[Mathilde]! Which repeater are you going through?",
),
Line(at(0, "10:12"), "Mathilde", "Mont-Royal I think, or Oratoire. How do I check?"),
Line(
at(0, "10:15"),
ME,
"Your advert reaches me through Mont-Royal, then Rosemont. Trace path shows it all",
),
Line(
at(0, "10:40"),
"Jean-Seb",
"Reminder: mesh meetup Saturday at 2 pm, Jean-Talon market ☕",
),
Line(
at(0, "11:01"),
"Noor",
"@[Alex Base] do you still have the coverage map? We'd love to see it Saturday",
),
Line(
at(0, "11:04"),
ME,
"Yes! https://www.openstreetmap.org/#map=11/45.5200/-73.6400 I'll bring a printout",
),
Line(at(0, "11:30"), "Steve K.", "I'll bring a spare antenna for anyone who needs one"),
Line(
at(0, "11:33"),
"Gabriel",
"Hello from Beloeil! My messages get to Montréal through Mont-St-Hilaire",
),
Line(at(0, "11:39"), "Félix", "Longueuil ↔ Stade at +6 dB since the new antenna, finally!"),
),
"#montreal": (
Line(
at(6, "18:40"),
"Raphaël",
"Pont Viau is closed tonight for roadwork, take Papineau instead",
),
Line(
at(4, "16:10"),
"Olivier.T",
"Anyone know if the Jean-Talon market is open late on Thursday?",
),
Line(at(4, "16:21"), "Marie-Ève", "Until 6 I think, and 8 on Fridays"),
Line(at(3, "18:05"), "Olivier.T", "Bike stations are all full downtown, good luck tonight"),
Line(
at(3, "20:05"),
"Jean-Seb",
"Power outage in Ahuntsic. The nodes on batteries are holding up fine 💪",
),
Line(at(3, "20:13"), "Félix", "Same in Hochelaga, but Stade-Olympique didn't even notice"),
Line(
at(1, "09:15"),
"Chloé L.",
"Snow in the forecast next week. Time to bring in the balcony antennas ⛄",
),
Line(
at(0, "07:40"),
ME,
"Anyone seen the crane on Saint-Denis? It's blocking my link to Stade 😑",
),
Line(at(0, "07:48"), "Félix", "Ha, the crane has a better SNR than the rest of us"),
Line(
at(0, "08:10"),
"Raphaël",
"Orange line is running slow between Henri-Bourassa and Montmorency",
),
Line(
at(0, "11:20"), "Kim-NDG", "Anyone up for lunch near the Atwater market? Around 12:30"
),
Line(at(0, "11:24"), "Steve K.", "Can't today, working from home"),
Line(at(0, "11:27"), "Sam Verdun", "I'm in! Meet at the main entrance?"),
),
"#cycling": (
Line(
at(5, "10:20"),
"Nico R.",
"Lachine Canal ride on Sunday at 9, leaving from the Atwater market",
),
Line(
at(5, "10:31"), "Dave-Lachine", "Count me in. I'll bring the T-Deck for a coverage test"
),
Line(at(5, "10:52"), "Sam Verdun", "I'll be there with the cargo bike 🚲"),
Line(
at(3, "13:30"),
"Nico R.",
"Ride results: 11 repeaters heard between Atwater and Lachine",
),
Line(
at(3, "13:42"), "Dave-Lachine", "And the Lachine repeater heard all of us. Great test!"
),
Line(
at(1, "16:20"),
"Sam Verdun",
"The Saint-Denis bike lane is still under construction near Jean-Talon",
),
Line(at(0, "09:30"), "Nico R.", "Anyone up for a ride up the mountain tomorrow morning?"),
),
"Family": (
Line(at(4, "19:02"), "Léa", "Dinner at Grandma's on Sunday, 5 pm"),
Line(at(4, "19:10"), ME, "Perfect, I'll bring dessert"),
Line(at(4, "19:12"), "Léa", "Don't forget the folding chairs!"),
Line(at(1, "17:41"), "Léa", "Where are you? Dinner's ready 😄"),
Line(at(1, "17:44"), ME, "Almost there, just around the corner"),
Line(at(0, "08:15"), "Léa", "It's 2 degrees this morning, dress warm!"),
Line(at(0, "08:18"), ME, "Too late, I'm already outside 🥶"),
),
}
#: The direct conversations, by the name of the other node, oldest line first.
DIRECT_LINES: dict[str, tuple[Line, ...]] = {
"Jean-Seb": (
Line(
at(7, "09:12"),
"Jean-Seb",
"Can you reboot Stade-Olympique? It stopped answering logins",
),
Line(at(7, "09:31"), ME, "Done, it's back"),
),
"Nico R.": (
Line(at(2, "17:02"), "Nico R.", "Is the link to Mont-St-Hilaire holding up?"),
Line(at(2, "17:06"), ME, "Yes, -5 dB from Mont-Royal. Fragile, but it works"),
),
"Dave-Lachine": (
Line(
at(2, "20:20"),
ME,
"Can you trace me from Lachine tonight? Testing the path through Oratoire",
),
Line(at(2, "20:51"), "Dave-Lachine", "Done: 3 hops, worst link -2.5 dB at Oratoire"),
Line(at(2, "20:55"), ME, "Thanks Dave!", acked=False),
),
"Marie-Ève": (
Line(at(1, "18:20"), "Marie-Ève", "Are you coming to the meetup on Saturday?"),
Line(at(1, "18:31"), ME, "Yes! I'm bringing two spare nodes for the newcomers"),
Line(at(1, "18:40"), "Marie-Ève", "Great. I'll book a table at the café in the market"),
Line(at(1, "18:44"), ME, "Perfect, see you Saturday 👍"),
Line(at(0, "10:50"), "Marie-Ève", "Mathilde is new, could you help her set up her Heltec?"),
Line(at(0, "10:53"), ME, "Of course, I'll message her"),
),
"Mathilde": (
Line(
at(0, "10:55"),
ME,
"Hi! Marie-Ève said you're just getting started. Need a hand with the Heltec?",
),
Line(at(0, "11:10"), "Mathilde", "Yes please! I'm not sure which frequency to use"),
Line(at(0, "11:12"), ME, "910.525 MHz, BW 62.5, SF7. That's what the Montréal mesh uses"),
Line(at(0, "11:20"), "Mathilde", "That worked! I can see three repeaters now 🎉"),
),
}
#: The board of each room, oldest post first. The room name as the sender is a notice of
#: the room admin. ``5e1d9c07`` is a member that no contact of our node names.
ROOM_LINES: dict[str, tuple[Line, ...]] = {
"MTL BBS": (
Line(
at(2, "19:30"),
"Jean-Seb",
"Mesh meetup Saturday at 2 pm, at the café in the Jean-Talon market. Bring your nodes!",
),
Line(at(2, "19:50"), "Marie-Ève", "I'll be there, with a few antennas to give away"),
Line(
at(1, "12:50"),
"Noor",
"Lost: a Heltec V3 in a yellow case, somewhere in La Fontaine Park. Reward offered",
),
Line(at(1, "13:00"), "Steve K.", "I'll keep an eye out, I walk through there every day"),
Line(
at(1, "22:50"),
"MTL BBS",
"Mont-Royal maintenance on Tuesday from 10 to 11 pm. Expect detours.",
),
Line(at(0, "07:50"), "5e1d9c07", "Does anyone have an SMA adapter I could borrow?"),
Line(at(0, "08:40"), "Félix", "I do, come by Hochelaga anytime"),
Line(at(0, "08:50"), ME, "I'm bringing the antenna analyzer on Saturday"),
Line(
at(0, "10:30"), "Kim-NDG", "Slides from the last meetup are on the board, ask Jean-Seb"
),
Line(at(0, "11:25"), "Jean-Seb", "Six spots left for Saturday"),
),
}
#: The lines that arrive while the demo runs, as ``(seconds after the start, line)``. The
#: first one is a direct message, so the header shows one unread message soon after the
#: start. The age of each line is unused.
LIVE_DIRECT: tuple[tuple[float, Line], ...] = (
(6.0, Line(0.0, "Nico R.", "Did you see the record? 34 km to Mont-St-Hilaire 🔥")),
)
LIVE_CHANNEL: tuple[tuple[float, str, Line], ...] = (
(
45.0,
"Public",
Line(0.0, "Chloé L.", "Anyone heading up the mountain tomorrow for a range test?"),
),
(160.0, "#montreal", Line(0.0, "Kim-NDG", "The 80 bus is late again on Parc 🙄")),
)
#: The answers of the people of the demo world to a channel message of our node that
#: mentions them, by node name. The node answers on the same channel, a few seconds later
#: (:data:`REPLY_DELAY_S`), and its answer mentions our node. A node that is not in this
#: table does not answer. The demo video asks if Dave comes to the meetup
#: (``scripts/video.py``).
REPLIES: dict[str, str] = {
"Dave-Lachine": f"@[{ME}] Yes! I'll be there at 2, with the T-Deck",
}
#: The seconds from a message of our node to the answer of a node that it mentions (refer
#: to :data:`REPLIES`).
REPLY_DELAY_S = 4.0
#: The traces that our node ran in the past days, as ``(age in minutes, target, path)``.
#: The target is a repeater name for a target trace, or ``None`` for a composed path. A
#: path names the relays, without our node at the two ends. The history runs each one in
#: the link model, tries again a few times when the trace does not come back (as a person
#: does), and stores each try.
TRACES: tuple[tuple[float, str | None, tuple[str, ...]], ...] = (
(
at(6, "21:30"),
"Longueuil",
("Rosemont", "Stade-Olympique", "Longueuil", "Stade-Olympique", "Rosemont"),
),
(
at(5, "20:10"),
"Pointe-Claire",
("Villeray", "Mont-Royal", "Pointe-Claire", "Mont-Royal", "Villeray"),
),
(
at(5, "20:20"),
None,
(
"Villeray",
"Mont-Royal",
"Pointe-Claire",
"Île-Bizard",
"Pointe-Claire",
"Mont-Royal",
"Villeray",
),
),
(
at(4, "21:00"),
"Laval-Chomedey",
("Villeray", "Ahuntsic", "Laval-Chomedey", "Ahuntsic", "Villeray"),
),
(at(3, "22:15"), None, ("Rosemont", "Mont-Royal", "Mont-St-Hilaire", "Mont-Royal", "Rosemont")),
(at(2, "20:40"), None, ("Villeray", "Oratoire", "Lachine", "Oratoire", "Villeray")),
(
at(2, "21:50"),
None,
(
"Rosemont",
"Stade-Olympique",
"Mont-St-Bruno",
"Mont-St-Hilaire",
"Mont-St-Bruno",
"Stade-Olympique",
"Rosemont",
),
),
(
at(1, "20:30"),
None,
("Villeray", "Mont-Royal", "Laval-Chomedey", "Ahuntsic", "Stade-Olympique", "Rosemont"),
),
(
at(1, "21:10"),
None,
(
"Rosemont",
"Centre-Ville",
"Verdun-Quai",
"Brossard",
"Mont-St-Bruno",
"Mont-St-Hilaire",
"Mont-St-Bruno",
"Longueuil",
"Stade-Olympique",
"Ahuntsic",
"Laval-Chomedey",
"Mont-Royal",
"Oratoire",
"Lachine",
"Pointe-Claire",
"Mont-Royal",
"Villeray",
),
),
(
at(1, "21:40"),
None,
(
"Villeray",
"Oratoire",
"Lachine",
"Pointe-Claire",
"Mont-Royal",
"Brossard",
"Mont-St-Bruno",
"Mont-St-Hilaire",
"Stade-Olympique",
"Rosemont",
),
),
(at(0, "09:05"), "Brossard", ("Rosemont", "Mont-Royal", "Brossard", "Mont-Royal", "Rosemont")),
(at(0, "10:20"), None, ("Rosemont", "Longueuil", "Stade-Olympique", "Villeray")),
)
#: The Watchtower: the nodes that our node watches, and the alerts that it raised and the
#: user did not acknowledge yet, as ``(age in minutes, kind, node name, message)``. The
#: messages are the words of :mod:`meshterm.services.watchtower`.
WATCHED: tuple[str, ...] = ("Mont-Royal", "Mont-St-Hilaire", "Verdun-Quai", "Longueuil")
ALERTS: tuple[tuple[float, str, str, str], ...] = (
(at(4, "07:40"), "silence", "Verdun-Quai", "nothing heard for 6 h"),
(
at(3, "08:31"),
"recovered",
"Verdun-Quai",
"back on the air — heard again after a silence alarm",
),
(at(0, "09:55"), "new-node", "Mathilde", "first appearance — never heard on this mesh before"),
)
+563
View File
@@ -0,0 +1,563 @@
# SPDX-License-Identifier: Apache-2.0
"""The packets of the demo world: what our node hears from the mesh, packet by packet.
Two parts of the demo use this module. The history (:mod:`meshterm.demo.history`) asks it
for the packets of the past days, and stores them. The simulated device
(:mod:`meshterm.demo.device`) asks it for the packets of the next hour, and sends each one
as a live event at its time. Thus the live feed continues the stored history without a
seam.
Each transmission on the mesh goes through the link model of :mod:`meshterm.demo.world`.
A flood reaches our node on each path that the race of the relays gives
(:func:`~meshterm.demo.world.flood`), so one transmission is often heard several times,
on several paths, as on a real mesh. A direct packet follows the route of its sender, and
our node hears it only where a relay of that route is in its range. Each packet that our
node hears becomes one ``packet`` observation, with the fields that the meshcore library
gives for an overheard packet (refer to
:func:`~meshterm.core.connection.packet_observation_from_event`). An advert also becomes
one ``advert`` observation, as the companion reports it.
The rate of the traffic follows the hour of the virtual clock (:data:`RHYTHM`): the mesh is
quiet at night and busy in the evening.
"""
from __future__ import annotations
import hashlib
import random
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime, timedelta
from ..core.channels import channel_hash
from ..core.models import (
NODE_TYPE_REPEATER,
NODE_TYPE_ROOM,
NODE_TYPE_SENSOR,
Message,
Observation,
)
from ..core.regions import region_key, scope_body, transport_code
from .script import NOW_LOCAL
from .world import (
FOREIGN_CHANNELS,
NODES,
REGION_TABLES,
REPEATERS,
SELF,
Copy,
Node,
best_route,
flood,
link_snr,
loss_chance,
path_width,
reading,
)
#: The relative rate of the traffic for each local hour of the day, from midnight.
RHYTHM: tuple[float, ...] = (
0.35, 0.2, 0.15, 0.12, 0.12, 0.18, 0.35, 0.6, 0.75, 0.7, 0.65, 0.7,
0.8, 0.7, 0.65, 0.7, 0.8, 0.95, 1.0, 1.0, 1.0, 0.9, 0.7, 0.5,
) # fmt: skip
#: The number of transmissions that start on the mesh in each minute at the busiest hour.
#: The adverts and the lines of the script come on top of this rate.
PEAK_PER_MIN = 1.1
#: The payload classes of the background traffic, with their relative weights. A flood
#: is heard several times. Thus the counts of the stored packets have other proportions:
#: the channel texts and the requests lead, as on the real mesh of Montréal.
_KINDS: tuple[tuple[str, float], ...] = (
("GRP_TXT", 18.0),
("REQ", 16.0),
("RESPONSE", 10.0),
("TEXT_MSG", 9.0),
("ACK", 5.0),
("ANON_REQ", 4.0),
("PATH", 3.0),
("TRACE", 2.0),
("GRP_DATA", 0.3),
("CONTROL", 0.3),
("MULTIPART", 0.05),
)
#: The 4-bit payload type of each class in the packet header (MeshCore's ``Packet.h``). The
#: transport code of a scoped flood comes from it.
_PAYLOAD_TYPE = {"GRP_TXT": 5, "ADVERT": 4}
#: How often each type of node sends a flood advert (hours), and how often a repeater
#: sends a local advert that only its neighbours hear (hours).
_ADVERT_EVERY_H = {"repeater": 12.0, "room": 12.0, "sensor": 6.0, "companion": 9.0}
_LOCAL_ADVERT_EVERY_H = 4.0
def rhythm(local_hour: float) -> float:
"""The relative rate of the traffic at a local hour of the virtual clock."""
return RHYTHM[int(local_hour) % 24]
def local_hour(offset_s: float) -> float:
"""The local hour on the virtual clock, ``offset_s`` seconds after now (negative: before)."""
return (NOW_LOCAL + offset_s / 3600.0) % 24.0
def _rssi(snr: float, rng: random.Random) -> float:
"""A plausible RSSI (dBm) for a reception with this SNR, over a noise floor near -110 dBm."""
return float(round(-108.0 + 1.1 * snr + rng.gauss(0.0, 1.5)))
def active(node: Node, offset_s: float) -> bool:
"""Whether ``node`` transmits at this moment (``offset_s`` seconds after now).
A node transmits after our node first heard it, and before it went quiet. A node that
is active now stays active after now, so the live traffic continues the history. A
contact that our node never heard never transmits.
"""
if node.quiet_hours is None:
return False
first = -node.first_heard_days * 86400.0
if node.quiet_hours <= 0:
return first <= offset_s
return first <= offset_s <= -node.quiet_hours * 3600.0
def _kind_of(node: Node) -> str:
"""The advert schedule that a node follows (refer to :data:`_ADVERT_EVERY_H`)."""
return {
NODE_TYPE_REPEATER: "repeater",
NODE_TYPE_ROOM: "room",
NODE_TYPE_SENSOR: "sensor",
}.get(node.node_type, "companion")
def _phase(node: Node, salt: str) -> float:
"""A fixed fraction in ``[0, 1)`` for a node: where its periodic schedule starts."""
return hashlib.sha256(f"{salt}/{node.name}".encode()).digest()[0] / 256.0
# --------------------------------------------------------------------------- packets
@dataclass(frozen=True, slots=True)
class Heard:
"""One event that our node hears: an observation or a received message.
Attributes:
offset_s: When it is heard, in seconds after now (negative: in the past).
observation: The observation, or ``None``.
message: The received message, or ``None``.
"""
offset_s: float
observation: Observation | None = None
message: Message | None = None
def _mac(rng: random.Random) -> str:
"""A random 2-byte MAC (hex): the tag of an encrypted body that our node cannot read."""
return f"{rng.getrandbits(16):04x}"
def grp_txt_body(secret: bytes, text: str, sent_at: datetime, attempt: int = 0) -> dict:
"""The fields of a GRP_TXT packet for ``text`` on the channel with ``secret``.
The body is what the firmware sends: the timestamp of the sender and the attempt byte,
then the text, padded to whole AES blocks and encrypted in ECB mode with the key of the
channel. The MAC is the first two bytes of the HMAC-SHA256 of the ciphertext. Thus
MeshTerm decrypts a stored copy exactly as it decrypts a real one (refer to
:func:`~meshterm.core.channels.decrypt_channel_text`).
Args:
secret: The 16-byte key of the channel.
text: The text of the message, with the ``Name: `` prefix of the sender.
sent_at: The time on the clock of the sender.
attempt: The resend counter of the sender.
Returns:
The ``payload_typename``, ``chan_hash``, ``cipher_mac``, and ``crypted`` fields.
"""
from Crypto.Cipher import AES
from Crypto.Hash import HMAC, SHA256
plain = int(sent_at.timestamp()).to_bytes(4, "little") + bytes([attempt & 0x03])
plain += text.encode("utf-8")
plain += b"\x00" * (-len(plain) % 16)
crypted = AES.new(secret, AES.MODE_ECB).encrypt(plain)
mac = HMAC.new(secret, digestmod=SHA256)
mac.update(crypted)
return {
"payload_typename": "GRP_TXT",
"chan_hash": channel_hash(secret),
"cipher_mac": mac.digest()[:2].hex(),
"crypted": crypted.hex(),
}
def scoped(body: dict, region: str) -> dict:
"""``body`` as a scoped flood: the route type, the transport code, and the HMAC input.
Args:
body: The fields of a GRP_TXT packet (refer to :func:`grp_txt_body`).
region: The bare region name.
Returns:
A copy of ``body`` with ``route_typename``, ``transport_code``, and ``scope_body``.
"""
payload = (
bytes.fromhex(body["chan_hash"])
+ bytes.fromhex(body["cipher_mac"])
+ bytes.fromhex(body["crypted"])
)
hmac_input = scope_body(_PAYLOAD_TYPE["GRP_TXT"], payload)
code = transport_code(region_key(region), hmac_input)
return {
**body,
"route_typename": "TC_FLOOD",
"transport_code": code.to_bytes(2, "little").hex() + "0000",
"scope_body": hmac_input.hex(),
}
def _relays_region(relay: Node, region: str | None) -> bool:
"""Whether ``relay`` relays a flood in ``region`` (``None``: an unscoped flood)."""
if region is None:
return True
return any(name == region and allowed for name, _, allowed in REGION_TABLES.get(relay.name, ()))
def heard_flood(
origin: Node,
start_s: float,
body: dict,
rng: random.Random,
*,
region: str | None = None,
advert: bool = False,
) -> list[Heard]:
"""The packets that our node hears from one flood.
Args:
origin: The node that transmits the flood.
start_s: When it transmits (seconds after now).
body: The payload fields of the packet.
rng: The source of the delays, the losses, and the noise.
region: The scope of the flood, or ``None`` for an unscoped flood. Only the
repeaters that carry the region relay it.
advert: Whether the packet is an advert. Then the companion also reports one
``advert`` observation, with the SNR of the first copy.
Returns:
One :class:`Heard` for each copy, and one more for the advert report.
"""
copies = [
copy
for copy in flood(origin, rng)
if all(_relays_region(relay, region) for relay in copy.relays)
]
route = "TC_FLOOD" if region else "FLOOD"
width = path_width(origin)
out = [
_packet(
origin if advert else None,
start_s,
copy,
{"route_typename": route, **body},
rng,
width=width,
)
for copy in copies
]
if advert and copies:
first = copies[0]
out.append(Heard(start_s + first.delay_s, observation=_advert_report(origin, first, rng)))
return out
def _packet(
origin: Node | None,
start_s: float,
copy: Copy,
body: dict,
rng: random.Random,
*,
width: int = 1,
) -> Heard:
"""One ``packet`` observation for one copy of a transmission, with ``width``-byte hashes."""
path = ",".join(relay.public_key[: 2 * width] for relay in copy.relays)
raw = {**body, "path_len": len(copy.relays), "path_hash_size": width}
share = origin is not None and origin.shares_location
return Heard(
start_s + copy.delay_s,
observation=Observation(
node=origin.node_id if origin is not None else None,
public_key=origin.public_key if origin is not None else None,
name=origin.name if origin is not None else None,
kind="packet",
node_type=origin.node_type if origin is not None else None,
snr=copy.snr,
rssi=_rssi(copy.snr, rng),
lat=origin.lat if share else None,
lon=origin.lon if share else None,
path=path,
raw=raw,
),
)
def _advert_report(origin: Node, copy: Copy, rng: random.Random) -> Observation:
"""The ``advert`` observation that the companion reports for an advert that it heard."""
return Observation(
node=origin.node_id,
public_key=origin.public_key,
name=origin.name,
kind="advert",
node_type=origin.node_type,
snr=copy.snr,
rssi=_rssi(copy.snr, rng),
lat=origin.lat if origin.shares_location else None,
lon=origin.lon if origin.shares_location else None,
)
def advert_body(origin: Node) -> dict:
"""The decoded fields of an advert packet from ``origin``."""
body: dict = {
"payload_typename": "ADVERT",
"adv_key": origin.public_key,
"adv_name": origin.name,
"adv_type": origin.node_type,
}
if origin.shares_location:
body.update(adv_lat=origin.lat, adv_lon=origin.lon)
return body
def heard_direct(
route: tuple[Node, ...], src: Node, start_s: float, body: dict, rng: random.Random
) -> list[Heard]:
"""The packets that our node hears from one direct packet along ``route``.
The sender writes the route into the packet, and each relay removes itself before it
transmits. Thus our node hears the sender with the full route, and each relay with
the part of the route that is still in front of the packet.
Args:
route: The relays between the sender and the recipient.
src: The sender.
start_s: When the sender transmits (seconds after now).
body: The payload fields of the packet.
rng: The source of the delays, the losses, and the noise.
Returns:
One :class:`Heard` for each transmission that our node hears.
"""
out: list[Heard] = []
elapsed = 0.0
for index, transmitter in enumerate((src, *route)):
elapsed += rng.uniform(0.2, 0.9)
if transmitter.name == SELF.name:
continue
snr = link_snr(transmitter, SELF)
if snr is None or rng.random() < loss_chance(snr):
continue
remaining = route[index:]
copy = Copy(remaining, reading(snr, rng), elapsed)
direct = {"route_typename": "DIRECT", **body}
out.append(_packet(None, start_s, copy, direct, rng, width=path_width(src)))
return out
# --------------------------------------------------------------------------- traffic
def _pick(nodes: list[Node], rng: random.Random) -> Node:
"""A node, with the weight of its :attr:`~meshterm.demo.world.Node.talk`."""
return rng.choices(nodes, weights=[node.talk for node in nodes])[0]
def adverts(start_s: float, end_s: float, rng: random.Random) -> Iterator[Heard]:
"""The adverts that our node hears between two moments (seconds after now).
Each node sends a flood advert on a fixed period, with a fixed phase and some jitter.
A repeater also sends local adverts, which only its neighbours hear. More than eight
days before now, the history keeps only one advert of each node each day: enough for
the dates of first and last reception, and not a full week of packets for each day of
the year.
"""
dense_from = -8 * 86400.0
for origin in (*NODES, SELF):
kind = _kind_of(origin)
period = _ADVERT_EVERY_H[kind] * 3600.0
moment = start_s - (start_s % period) + _phase(origin, "advert") * period
while moment < end_s:
sparse = moment < dense_from
when = moment + rng.uniform(-0.1, 0.1) * period
step = 86400.0 if sparse else period
if start_s <= when < end_s and (origin is SELF or active(origin, when)):
if origin is SELF:
# Our node hears its own advert only where the repeaters relay it. The
# history keeps these packets only for the recent days.
if not sparse:
yield from heard_flood(origin, when, advert_body(origin), rng)
elif sparse:
copies = flood(origin, rng)
if copies:
yield Heard(
when + copies[0].delay_s,
observation=_advert_report(origin, copies[0], rng),
)
else:
yield from heard_flood(origin, when, advert_body(origin), rng, advert=True)
moment += step
if kind != "repeater" or link_snr(origin, SELF) is None:
continue
period = _LOCAL_ADVERT_EVERY_H * 3600.0
moment = max(start_s, dense_from)
moment = moment - (moment % period) + _phase(origin, "local") * period
while moment < end_s:
if start_s <= moment and active(origin, moment):
snr = link_snr(origin, SELF)
if snr is not None and rng.random() >= loss_chance(snr):
copy = Copy((), reading(snr, rng), 0.3)
body = {"route_typename": "FLOOD", **advert_body(origin)}
yield _packet(origin, moment, copy, body, rng)
yield Heard(moment + 0.3, observation=_advert_report(origin, copy, rng))
moment += period
def background(start_s: float, end_s: float, rng: random.Random) -> Iterator[Heard]:
"""The background traffic of the mesh between two moments (seconds after now).
The transmissions arrive as a Poisson process, with the rate of the hour
(:func:`rhythm`). Each one has a class (:data:`_KINDS`), a sender, and a recipient
where its class has one. Then it becomes the packets that our node hears.
"""
kinds = [kind for kind, _ in _KINDS]
weights = [weight for _, weight in _KINDS]
moment = start_s
while True:
rate_s = PEAK_PER_MIN * rhythm(local_hour(moment)) / 60.0
moment += rng.expovariate(max(rate_s, 1e-6))
if moment >= end_s:
return
awake = [node for node in NODES if active(node, moment)]
people = [node for node in awake if node.node_type == SELF.node_type]
servers = [node for node in awake if node.node_type != SELF.node_type]
if not people or not servers:
continue
kind = rng.choices(kinds, weights=weights)[0]
yield from _transmission(kind, moment, people, servers, rng)
def _transmission(
kind: str, moment: float, people: list[Node], servers: list[Node], rng: random.Random
) -> Iterator[Heard]:
"""The packets that our node hears from one background transmission of class ``kind``."""
sender = _pick(people, rng)
if kind in ("GRP_TXT", "GRP_DATA"):
secret = rng.choice(FOREIGN_CHANNELS)
body = {
"payload_typename": kind,
"chan_hash": channel_hash(secret),
"cipher_mac": _mac(rng),
"crypted": rng.randbytes(16 * rng.randint(2, 6)).hex(),
}
yield from heard_flood(sender, moment, body, rng)
return
if kind == "CONTROL":
# A discovery request: one hop, heard only near its sender.
snr = link_snr(sender, SELF)
if snr is not None:
copy = Copy((), reading(snr, rng), 0.2)
yield _packet(
None, moment, copy, {"route_typename": "DIRECT", "payload_typename": kind}, rng
)
return
if kind == "MULTIPART":
yield from heard_flood(sender, moment, {"payload_typename": kind}, rng)
return
if kind == "TRACE":
yield from _trace(moment, rng)
return
if kind in ("REQ", "ANON_REQ"):
target = _pick(servers, rng)
else:
target = _pick([node for node in people if node is not sender] or people, rng)
if kind == "RESPONSE":
sender, target = _pick(servers, rng), sender
body: dict = {"payload_typename": kind, "dest_hash": target.hash, "cipher_mac": _mac(rng)}
if kind == "ANON_REQ":
body["src_key"] = sender.public_key
elif kind != "ACK":
body["src_hash"] = sender.hash
if kind == "ACK":
body = {"payload_typename": kind, "ack_crc": f"{rng.getrandbits(32):08x}"}
route = best_route(sender, target)
if route is not None and kind in ("ACK", "RESPONSE", "TEXT_MSG", "REQ") and rng.random() < 0.55:
yield from heard_direct(route, sender, moment, body, rng)
else:
yield from heard_flood(sender, moment, body, rng)
def _trace(moment: float, rng: random.Random) -> Iterator[Heard]:
"""A trace that another node runs, as our node hears it pass.
The trace walks a few repeaters, out and back. Our node hears each relay of the walk
that is in its range, with the SNR readings that the packet carries at that point.
"""
start = rng.choice(REPEATERS)
out = [start]
for _ in range(rng.randint(1, 3)):
options = [r for r in REPEATERS if r not in out and link_snr(out[-1], r) is not None]
if not options:
break
out.append(rng.choice(options))
walk = [*out, *reversed(out[:-1])]
snrs: list[float] = []
elapsed = 0.0
tag = f"{rng.getrandbits(32):08x}"
for a, b in zip(walk, walk[1:], strict=False):
snr = link_snr(a, b)
if snr is None or rng.random() < loss_chance(snr):
return
snrs.append(reading(snr, rng))
elapsed += rng.uniform(0.2, 0.6)
heard = link_snr(b, SELF)
if heard is not None and rng.random() >= loss_chance(heard):
body = {
"route_typename": "DIRECT",
"payload_typename": "TRACE",
"trace_tag": tag,
"trace_snrs": list(snrs),
}
yield _packet(None, moment, Copy((), reading(heard, rng), elapsed), body, rng)
def traffic(start_s: float, end_s: float, seed: str) -> list[Heard]:
"""All the traffic that our node hears between two moments, in time order.
Args:
start_s: The start, in seconds after now (negative: in the past).
end_s: The end, in seconds after now.
seed: The seed of the random source. The same seed gives the same traffic.
Returns:
The adverts and the background packets, sorted by time.
"""
rng = random.Random(f"meshterm-demo/{seed}")
heard = [*adverts(start_s, end_s, rng)]
dense_from = max(start_s, -8 * 86400.0)
if dense_from < end_s:
heard.extend(background(dense_from, end_s, rng))
heard = [item for item in heard if start_s <= item.offset_s < end_s]
heard.sort(key=lambda item: item.offset_s)
return heard
def stamp(heard: list[Heard], now: datetime) -> list[Heard]:
"""Give each observation in ``heard`` its time, from ``now`` and its offset."""
for item in heard:
if item.observation is not None:
item.observation.observed_at = now + timedelta(seconds=item.offset_s)
return heard
+629
View File
@@ -0,0 +1,629 @@
# SPDX-License-Identifier: Apache-2.0
"""The demo world: a synthetic MeshCore mesh over Montréal.
The ``--demo`` flag runs MeshTerm against this world. Two parts of the demo read it. The
simulated device (:mod:`meshterm.demo.device`) answers as the companion of our node. The
history (:mod:`meshterm.demo.history`) fills the database with what that companion heard
in the days before. Thus each screen shows one consistent mesh: a node on the map is a
node that the contacts, the chat, and the routes also know, and a route that a trace
walks is a route that the floods also use.
The world is fixed data and pure functions. Nothing in this module reads the clock, and
each random value comes from a generator with a fixed seed. Thus the world is the same at
each run, and the screenshots of the website show the same mesh each time that somebody
makes them again.
All the names are invented, and no node is a real node. The places are real: the
repeaters stand on the high points of the Montréal area, and the links between them come
from a simple model of the radio path (refer to :func:`link_snr`).
"""
from __future__ import annotations
import functools
import hashlib
import heapq
import math
import random
from collections.abc import Sequence
from dataclasses import dataclass, field
from itertools import pairwise
from ..core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
from ..core.geo import haversine_km
from ..core.models import (
NODE_TYPE_CHAT,
NODE_TYPE_REPEATER,
NODE_TYPE_ROOM,
NODE_TYPE_SENSOR,
)
# --------------------------------------------------------------------------- the nodes
@dataclass(frozen=True, slots=True, eq=False)
class Node:
"""One node of the demo world.
Each node exists once, in :data:`NODES` or as :data:`SELF`. Thus two nodes are equal
only when they are the same object, and the hash of a node is cheap: the link model
keys its caches on nodes millions of times.
Attributes:
name: The name that the node advertises.
hue: The first byte of its public key. This byte is the hash of the node in a
path, and it selects the hue of its name on each screen. The world gives
each node its own byte, so that no two nodes of the world have the same hash,
and the hues of the repeaters stay far apart.
node_type: The advert type (``NODE_TYPE_*``).
lat: The latitude of the node. The link model always uses it.
lon: The longitude of the node.
site_db: The gain of the site, in dB, against a node at street level: a mountain
top is worth more than a roof, and a roof more than a pocket.
shares_location: Whether the adverts of the node carry its position. A node that
does not share it still has one, because the link model needs it.
added: Whether the node is in the contact list of our device. A node that is not
added is a discovered node: MeshTerm only heard it.
first_heard_days: How many days before now our node first heard this node.
quiet_hours: How many hours before now the node transmitted for the last time.
Zero is a node that is active now. ``None`` is a contact that our node never
heard (a contact added from a shared card).
talk: How often the node starts a transmission, relative to an ordinary node.
public_key: The full public key (64 hex digits): the hue byte, then a fixed digest
of the name.
"""
name: str
hue: int
node_type: int
lat: float
lon: float
site_db: float
shares_location: bool = True
added: bool = True
first_heard_days: float = 120.0
quiet_hours: float | None = 0.0
talk: float = 1.0
public_key: str = field(init=False, repr=False)
def __post_init__(self) -> None:
"""Derive the public key from the hue byte and the name."""
tail = hashlib.sha256(f"meshterm-demo/{self.name}".encode()).digest()[1:]
object.__setattr__(self, "public_key", (bytes([self.hue]) + tail).hex())
@property
def node_id(self) -> str:
"""The canonical id of the node in the history: the first 12 hex digits of its key."""
return self.public_key[:12]
@property
def hash(self) -> str:
"""The one-byte path hash of the node (2 hex digits)."""
return self.public_key[:2]
@property
def is_repeater(self) -> bool:
"""Whether the node relays floods."""
return self.node_type == NODE_TYPE_REPEATER
_R, _C, _ROOM, _S = NODE_TYPE_REPEATER, NODE_TYPE_CHAT, NODE_TYPE_ROOM, NODE_TYPE_SENSOR
#: Our node: the companion that the demo device is. It stands in La Petite-Patrie, near the
#: Jean-Talon market, with an antenna on the roof of a triplex. The buildings around it
#: hide the big repeaters (refer to :data:`_OBSTRUCTED`). It hears two small repeaters on
#: the roofs nearby, Villeray and Rosemont, as a companion in the city usually does.
SELF = Node("Alex Base", 0x91, _C, 45.5350, -73.6030, 2.0, first_heard_days=400.0)
# fmt: off
#: The other nodes of the world. The repeaters stand on the high points. The companions are
#: the people of the mesh. The order is the order of the contact list on the device.
NODES: tuple[Node, ...] = (
# The repeaters, on the mountains, the towers, and the roofs.
Node("Mont-Royal", 0x2A, _R, 45.5060, -73.5880, 13.0, first_heard_days=380.0),
Node("Oratoire", 0x4F, _R, 45.4924, -73.6178, 8.0, first_heard_days=300.0),
Node("Centre-Ville", 0xBC, _R, 45.5015, -73.5690, 8.0, first_heard_days=350.0),
Node("Stade-Olympique", 0x98, _R, 45.5580, -73.5517, 10.0, first_heard_days=360.0),
Node("Ahuntsic", 0x05, _R, 45.5560, -73.6600, 6.0, first_heard_days=210.0),
Node("Laval-Chomedey", 0xCF, _R, 45.5640, -73.7450, 7.0, first_heard_days=190.0),
Node("Verdun-Quai", 0xF3, _R, 45.4555, -73.5640, 5.0, first_heard_days=240.0),
Node("Lachine", 0x85, _R, 45.4340, -73.6800, 7.0, first_heard_days=160.0),
Node("Pointe-Claire", 0xAA, _R, 45.4485, -73.8165, 8.0, first_heard_days=130.0),
Node("Île-Bizard", 0x3C, _R, 45.4950, -73.8900, 6.0, first_heard_days=95.0),
Node("Longueuil", 0x73, _R, 45.5300, -73.5150, 6.0, first_heard_days=6.0),
Node("Brossard", 0x18, _R, 45.4570, -73.4660, 6.0, first_heard_days=70.0),
Node("Mont-St-Bruno", 0xE1, _R, 45.5530, -73.3260, 13.0, first_heard_days=280.0),
Node("Mont-St-Hilaire", 0x61, _R, 45.5490, -73.1570, 15.0, first_heard_days=45.0),
Node("Villeray", 0x70, _R, 45.5470, -73.6170, 4.0, first_heard_days=250.0),
Node("Rosemont", 0xD8, _R, 45.5420, -73.5850, 4.0, first_heard_days=170.0),
# The room servers.
Node("MTL BBS", 0x3B, _ROOM, 45.5225, -73.5840, 4.0, first_heard_days=330.0),
Node("Café Mesh", 0x64, _ROOM, 45.5250, -73.5990, 3.0, first_heard_days=60.0,
quiet_hours=9 * 24.0),
# The sensors.
Node("Verdun Weather", 0x58, _S, 45.4585, -73.5660, 3.0, first_heard_days=200.0,
talk=0.6),
Node("Beaver Lake", 0x9E, _S, 45.4985, -73.5970, 4.0, first_heard_days=150.0,
talk=0.6),
Node("River Gauge", 0xCB, _S, 45.5110, -73.5470, 2.0, first_heard_days=110.0,
talk=0.6, quiet_hours=30.0),
# The companions: the people of the mesh.
Node("Marie-Ève", 0xE6, _C, 45.5255, -73.5790, 1.0, first_heard_days=320.0,
talk=1.6),
Node("Jean-Seb", 0xAD, _C, 45.5590, -73.6650, 2.0, first_heard_days=300.0,
talk=1.4),
Node("Félix", 0x1A, _C, 45.5450, -73.5440, 2.0, first_heard_days=280.0,
talk=1.2),
Node("Dave-Lachine", 0x84, _C, 45.4385, -73.6710, 2.0, first_heard_days=150.0),
Node("Kim-NDG", 0xBF, _C, 45.4735, -73.6145, 1.0, first_heard_days=260.0),
Node("Nico R.", 0x94, _C, 45.5110, -73.5695, 0.0, shares_location=False,
first_heard_days=90.0, talk=1.3),
Node("Noor", 0x50, _C, 45.4960, -73.6300, 1.0, first_heard_days=75.0),
Node("Chloé L.", 0xC9, _C, 45.5170, -73.6060, 1.0, first_heard_days=220.0),
Node("Sam Verdun", 0x68, _C, 45.4610, -73.5690, 1.0, first_heard_days=240.0),
Node("Steve K.", 0x22, _C, 45.5340, -73.5050, 1.0, first_heard_days=40.0),
Node("Olivier.T", 0x33, _C, 45.5455, -73.5770, 1.0, first_heard_days=180.0,
quiet_hours=50.0),
Node("Raphaël", 0x0B, _C, 45.5810, -73.7480, 1.0, first_heard_days=130.0,
quiet_hours=3.0),
Node("Ana Brossard", 0x3F, _C, 45.4550, -73.4710, 1.0, first_heard_days=65.0,
quiet_hours=14.0),
Node("Léa", 0xDD, _C, 45.5450, -73.6240, 1.0, shares_location=False,
first_heard_days=200.0, talk=0.5),
Node("Rachid", 0x60, _C, 45.5240, -73.6000, 1.0, shares_location=False,
first_heard_days=100.0, quiet_hours=4 * 24.0, talk=0.5),
Node("Tomás", 0x77, _C, 45.4520, -73.8120, 1.0, first_heard_days=160.0,
quiet_hours=62 * 24.0, talk=0.5),
Node("Mathilde", 0xF7, _C, 45.4780, -73.5870, 0.0, first_heard_days=2 / 24),
Node("Gabriel", 0x7F, _C, 45.5650, -73.2050, 1.0, first_heard_days=20.0),
Node("Sylvie-Gaspésie", 0x6C, _C, 48.8300, -64.4800, 0.0, shares_location=False,
first_heard_days=0.0, quiet_hours=None),
# Discovered nodes: heard, but nobody added them.
Node("T1000-E", 0x4C, _C, 45.5600, -73.7100, 0.0, added=False,
first_heard_days=3.0, talk=0.4),
Node("Wio Tracker", 0xA0, _C, 45.4650, -73.5480, 0.0, added=False,
first_heard_days=12.0, quiet_hours=20.0, talk=0.4),
Node("Mobile Node", 0xB2, _C, 45.5200, -73.5300, 0.0, shares_location=False,
added=False, first_heard_days=1.5, talk=0.4),
)
# fmt: on
#: Each node of the world by name, our node included.
BY_NAME: dict[str, Node] = {node.name: node for node in (SELF, *NODES)}
#: The repeaters, in the order of :data:`NODES`.
REPEATERS: tuple[Node, ...] = tuple(node for node in NODES if node.is_repeater)
def node(name: str) -> Node:
"""The node of the world with this name. A name that the world does not have is a bug."""
return BY_NAME[name]
# --------------------------------------------------------------------------- the radio
#: The weakest SNR (dB) at which the radio still decodes a packet. The demo device uses the
#: North American preset (SF7 at 62.5 kHz), whose demodulator stops near -7.5 dB.
FLOOR_DB = -7.5
#: The SNR (dB) of a link between two nodes at street level, one kilometre apart.
_ONE_KM_DB = 5.0
#: The loss (dB) for each decade of distance. Free space loses 20 dB for each decade. A
#: city loses more, because of the buildings.
_DECADE_DB = 25.0
#: Above this SNR (dB), a reading grows slowly: a receiver gives no SNR much above +12 dB,
#: whatever the strength of the signal.
_SOFT_CAP_DB = 10.0
#: The extra loss (dB) of a link from or to a companion of the people of the mesh. Such a
#: node is indoors, or in a pocket, at street level, so the buildings around it take more
#: than the distance explains. A link between two companions takes it two times. Thus a
#: companion reaches the repeaters near it, and another companion only when the two are
#: close, as on a real mesh. Our node has its antenna on a roof, and the room servers and
#: the sensors are outdoors, so they do not take it.
_GROUND_DB = 7.0
#: Links that the terrain blocks more than the model knows, by the names of their two
#: ends, in dB of extra loss. The buildings around our node hide the big repeaters from
#: it. Thus each flood reaches our node through the two small repeaters near it, usually
#: one copy from each. This is what a real companion in Montréal hears: two copies of most
#: messages, on paths that share their start and divide near the end.
_OBSTRUCTED: dict[frozenset[str], float] = {
frozenset((SELF.name, name)): 14.0
for name in ("Mont-Royal", "Oratoire", "Centre-Ville", "Stade-Olympique", "Ahuntsic")
}
def _ground_loss(a: Node, b: Node) -> float:
"""The loss that the street level adds to a link (refer to :data:`_GROUND_DB`)."""
return _GROUND_DB * (_indoors(a) + _indoors(b))
def _indoors(node: Node) -> bool:
"""Whether ``node`` is a companion at street level: each one except our node."""
return node.node_type == NODE_TYPE_CHAT and node is not SELF
def _skew(a: Node, b: Node) -> float:
"""A fixed offset (dB) for the link from ``a`` to ``b``: the terrain that no model knows.
Two parts make the offset. The larger part is the same in each direction (a hill or a
tower between the two sites). The smaller part depends on the direction, because the
two radios are not the same. Thus a pair of nodes never has exactly the same SNR in the
two directions, as on a real mesh.
"""
pair = "|".join(sorted((a.name, b.name)))
common = hashlib.sha256(f"terrain/{pair}".encode()).digest()
oneway = hashlib.sha256(f"radio/{a.name}>{b.name}".encode()).digest()
return (common[0] / 255 - 0.5) * 3.0 + (oneway[0] / 255 - 0.5) * 1.0
@functools.cache
def link_snr(a: Node, b: Node) -> float | None:
"""The mean SNR (dB) at ``b`` of a packet that ``a`` transmits.
The model is a log-distance path loss: :data:`_ONE_KM_DB` at one kilometre, less
:data:`_DECADE_DB` for each decade of distance, plus the gain of the two sites and the
fixed terrain offset of the link (:func:`_skew`). It is simple, but it gives what a
real mesh shows: the mountains hear each other over 30 km, and two pockets in the city
hear each other over one or two kilometres.
The world is fixed, so the function keeps each answer in a cache. The history asks for
the same links almost two million times.
Args:
a: The node that transmits.
b: The node that receives.
Returns:
The mean SNR in dB, or ``None`` when ``b`` cannot decode ``a`` (below
:data:`FLOOR_DB`, or the same node).
"""
if a.name == b.name:
return None
km = max(haversine_km(a.lat, a.lon, b.lat, b.lon), 0.05)
snr = _ONE_KM_DB - _DECADE_DB * math.log10(km) + a.site_db + b.site_db + _skew(a, b)
snr -= _ground_loss(a, b) + _OBSTRUCTED.get(frozenset((a.name, b.name)), 0.0)
if snr > _SOFT_CAP_DB:
snr = _SOFT_CAP_DB + (snr - _SOFT_CAP_DB) * 0.25
return round(snr, 2) if snr >= FLOOR_DB else None
def loss_chance(snr: float) -> float:
"""The probability that one packet on a link with this mean SNR is lost.
A link well above the floor loses nothing. A link near the floor loses most packets.
"""
return max(0.0, min(0.9, (FLOOR_DB + 4.0 - snr) / 8.0))
def reading(snr: float, rng: random.Random) -> float:
"""One SNR reading of a link: the mean, plus noise, in the quarter-dB steps of the firmware."""
return round((snr + rng.gauss(0.0, 1.0)) * 4) / 4
def hears(a: Node, b: Node) -> bool:
"""Whether ``b`` can decode ``a``."""
return link_snr(a, b) is not None
# --------------------------------------------------------------------------- routes
@functools.cache
def best_route(origin: Node, target: Node) -> tuple[Node, ...] | None:
"""The repeaters on the best route from ``origin`` to ``target``, in order.
The cost of each link is one hop, plus a penalty for a weak link. Thus the route has
few hops, but it does not go over a link that loses half of its packets when a strong
detour of the same length exists. A device learns a route of this type from the floods
that it receives.
Args:
origin: The node at the start.
target: The node at the end.
Returns:
The relays between the two ends (an empty tuple when they hear each other
directly), or ``None`` when no route connects them.
"""
direct = link_snr(origin, target)
if direct is not None and direct > FLOOR_DB + 3.0:
return ()
costs: dict[str, float] = {}
queue: list[tuple[float, int, str, tuple[Node, ...]]] = []
order = 0
for relay in REPEATERS:
snr = link_snr(origin, relay)
if snr is not None and relay.name != origin.name:
order += 1
heapq.heappush(queue, (_link_cost(snr), order, relay.name, (relay,)))
best: tuple[float, tuple[Node, ...]] | None = None
if direct is not None:
best = (_link_cost(direct), ())
while queue:
cost, _, name, route = heapq.heappop(queue)
if name in costs:
continue
costs[name] = cost
last = route[-1]
tail = link_snr(last, target)
if tail is not None:
total = cost + _link_cost(tail)
if best is None or total < best[0]:
best = (total, route)
for relay in REPEATERS:
if relay.name in costs or relay.name == origin.name:
continue
snr = link_snr(last, relay)
if snr is not None:
order += 1
heapq.heappush(queue, (cost + _link_cost(snr), order, relay.name, (*route, relay)))
return best[1] if best is not None else None
def _link_cost(snr: float) -> float:
"""The cost of one link for :func:`best_route`: one hop, more when the link is weak."""
return 1.0 + 4.0 * loss_chance(snr)
#: The nodes whose floods do not use three-byte hashes, and the width that they use. Our
#: node uses three bytes, as most of the mesh does.
_NARROW_PATHS: dict[str, int] = {
"Steve K.": 2,
"Raphaël": 2,
"Olivier.T": 1,
"Tomás": 1,
"T1000-E": 1,
"Wio Tracker": 1,
"Mobile Node": 1,
}
def path_width(origin: Node) -> int:
"""The bytes of each hash in the path of a flood from ``origin``.
The node that starts a flood sets the width, and each relay adds its hash at that
width. Most nodes of the mesh of Montréal use three bytes, a few use two, and some
older ones still use one (:data:`_NARROW_PATHS`).
"""
return _NARROW_PATHS.get(origin.name, 3)
@dataclass(frozen=True, slots=True)
class Copy:
"""One copy of a flood that our node hears.
Attributes:
relays: The repeaters that the copy went through, in order. Empty when our node
heard the origin directly.
snr: The SNR of the reception at our node (dB), from the last relay.
delay_s: The time from the transmission at the origin to the reception (seconds).
"""
relays: tuple[Node, ...]
snr: float
delay_s: float
def flood(origin: Node, rng: random.Random, *, listener: Node = SELF) -> list[Copy]:
"""The copies of one flood from ``origin`` that ``listener`` hears, earliest first.
Each repeater relays the first copy that it receives, and only that copy. Thus the
function runs the flood as a race: each link adds the airtime of the packet and the
random delay of the relay, and each repeater keeps the route of its first copy. Our node
hears the origin directly when it is in range, and each repeater in range that the
flood reached. A weak link can lose a copy (:func:`loss_chance`). Thus two floods from
the same node do not always arrive on the same paths.
Args:
origin: The node that transmits the flood.
rng: The source of the delays, the losses, and the noise.
listener: The node that hears the copies (our node by default).
Returns:
The copies that the listener receives.
"""
arrivals: dict[Node, tuple[float, tuple[Node, ...]]] = {}
queue: list[tuple[float, int, Node, tuple[Node, ...]]] = []
skip = (origin, listener)
order = 0
for relay, snr in _hearers(origin):
if relay in skip or rng.random() < loss_chance(snr):
continue
order += 1
heapq.heappush(queue, (_airtime(rng), order, relay, (relay,)))
while queue:
when, _, relay, route = heapq.heappop(queue)
if relay in arrivals:
continue
arrivals[relay] = (when, route)
sent = when + rng.uniform(0.2, 1.4) # the random transmit delay of the repeater
for other, snr in _hearers(relay):
if other in arrivals or other in skip or rng.random() < loss_chance(snr):
continue
order += 1
heapq.heappush(queue, (sent + _airtime(rng), order, other, (*route, other)))
copies: list[Copy] = []
direct = link_snr(origin, listener)
if direct is not None and rng.random() >= loss_chance(direct):
copies.append(Copy((), reading(direct, rng), _airtime(rng)))
for when, route in arrivals.values():
relay = route[-1]
snr = link_snr(relay, listener)
if snr is None or rng.random() < loss_chance(snr):
continue
sent = when + rng.uniform(0.2, 1.4)
copies.append(Copy(route, reading(snr, rng), sent + _airtime(rng)))
copies.sort(key=lambda copy: copy.delay_s)
return copies
@functools.cache
def _hearers(node: Node) -> tuple[tuple[Node, float], ...]:
"""The repeaters that hear ``node``, each with the mean SNR of the link."""
return tuple((relay, snr) for relay in REPEATERS if (snr := link_snr(node, relay)) is not None)
def _airtime(rng: random.Random) -> float:
"""The time on the air of one short packet at SF7, with the time to decode it (seconds)."""
return rng.uniform(0.15, 0.4)
@dataclass(frozen=True, slots=True)
class TraceWalk:
"""The result of one simulated trace along a forced path.
Attributes:
snrs: The SNR reading at the receiving end of each link, in order: the first relay,
then each next relay, then our node for the last link. It is shorter than the
walk when the trace died on the way.
success: Whether the trace came back to our node.
rtt_ms: The round trip of the trace (milliseconds), when it came back.
"""
snrs: list[float] = field(default_factory=list)
success: bool = False
rtt_ms: float | None = None
def walk(relays: Sequence[Node], rng: random.Random) -> TraceWalk:
"""Simulate one trace from our node through ``relays`` and back to our node.
A trace is a direct packet: each relay transmits it to the next node of the path, and
measures the SNR of what it received. The last link comes back to our node. A link
that does not exist, or a link that loses the packet, stops the trace.
Args:
relays: The forced path, as nodes.
rng: The source of the noise and the losses.
Returns:
The readings, and whether the trace came back.
"""
chain = [SELF, *relays, SELF]
snrs: list[float] = []
elapsed = 0.0
for a, b in pairwise(chain):
snr = link_snr(a, b)
if snr is None or rng.random() < loss_chance(snr):
return TraceWalk(snrs, False, None)
snrs.append(reading(snr, rng))
elapsed += _airtime(rng) + rng.uniform(0.05, 0.25)
return TraceWalk(snrs, True, round(elapsed * 1000.0, 1))
# --------------------------------------------------------------------------- channels
@dataclass(frozen=True, slots=True)
class Channel:
"""One channel in a slot of the demo device.
Attributes:
slot: The slot index on the device.
name: The name of the channel as the device stores it.
secret: The 16-byte secret.
scope: The region under which MeshTerm sends on this channel, or ``None`` for the
default scope of the device.
"""
slot: int
name: str
secret: bytes
scope: str | None = None
#: The channels of the demo device. ``Family`` is a private channel, with a fixed secret
#: that is not derived from its name.
CHANNELS: tuple[Channel, ...] = (
Channel(0, "Public", DEFAULT_PUBLIC_SECRET),
Channel(1, "#montreal", derive_secret("#montreal"), scope="yul"),
Channel(2, "#cycling", derive_secret("#cycling")),
Channel(3, "Family", hashlib.sha256(b"meshterm-demo/Family").digest()[:16]),
)
#: Hashtag channels that other people of the mesh use and our device does not have. Our
#: node hears their packets, but it cannot read them.
FOREIGN_CHANNELS: tuple[bytes, ...] = tuple(
derive_secret(name) for name in ("#quebec", "#radioamateur", "#test", "#plateau")
)
# --------------------------------------------------------------------------- regions
#: The region table of each repeater, as ``(name, parent, allows floods)`` rows, in the
#: form that :class:`~meshterm.core.region_sim.SimulatedRegionMap` takes. The regions are
#: the ones of the real mesh, each in the one before it: ``na`` (North America), ``can``
#: (Canada), ``onqc`` (Ontario and Québec), ``qc`` (Québec), and ``yul`` (Montréal).
#: Each repeater of Montréal, Laval, and the south shore carries all five.
#: Mont-St-Hilaire is outside the city: it has ``yul`` in its table, but it does not
#: relay its floods.
REGION_TABLES: dict[str, list[tuple[str, str, bool]]] = {}
_REGIONS = [
("na", "*", True),
("can", "na", True),
("onqc", "can", True),
("qc", "onqc", True),
("yul", "qc", True),
]
for _name in (
"Oratoire",
"Centre-Ville",
"Stade-Olympique",
"Verdun-Quai",
"Ahuntsic",
"Villeray",
"Rosemont",
"Mont-Royal",
"Lachine",
"Pointe-Claire",
"Île-Bizard",
"Laval-Chomedey",
"Longueuil",
"Brossard",
"Mont-St-Bruno",
):
REGION_TABLES[_name] = _REGIONS
REGION_TABLES["Mont-St-Hilaire"] = [*_REGIONS[:-1], ("yul", "qc", False)]
#: The contacts to which the device learned a route. A real device learns few routes: it
#: learns one only from a flood that it received from the node, and forgets it when a send
#: fails. Thus most contacts of the demo stay on flood, as on hardware.
ROUTED: tuple[str, ...] = (
"Mont-Royal",
"Stade-Olympique",
"MTL BBS",
"Marie-Ève",
"Jean-Seb",
"Dave-Lachine",
"Nico R.",
"Mathilde",
)
def device_route(target: Node) -> tuple[str, ...] | None:
"""The route that the demo device holds for ``target``, as path hashes.
Returns:
The hashes of the relays (empty for a direct neighbour), or ``None`` when the
device holds no route (the contact is on flood).
"""
if target.name not in ROUTED:
return None
route = best_route(SELF, target)
return None if route is None else tuple(relay.hash for relay in route)
#: The repeaters for which our node remembers the admin password. The demo device accepts
#: this password on each repeater, as the simulator does.
ADMIN_PASSWORD = "admin"
ADMINISTERED: tuple[str, ...] = ("Mont-Royal", "Stade-Olympique")
#: The room that our node joined, and its room password (the stock password of a room
#: server, which the simulator accepts).
JOINED_ROOM = "MTL BBS"
ROOM_PASSWORD = "hello"
+4 -4
View File
@@ -81,8 +81,8 @@ _SHIFTS = ("Shift_L", "Shift_R")
_REPEAT_GAP_MS = 30
#: The cap of a drawn lane key, and the ink of the digit that is printed on it.
_KEY_BODY = "#1d2025"
_KEY_DIGIT = "#eef1f4"
_KEY_BODY = "#202020"
_KEY_DIGIT = "#f1f1f1"
#: The modifier bits of Tk: Shift and Control on all platforms, Alt where the platform puts it.
_SHIFT_BIT = 0x0001
@@ -231,12 +231,12 @@ class EmulatorWindow:
keys_h = 26 * scale if self._device.lane_keys else 0
root = tk.Tk()
root.title(f"MeshTerm — {self._device.name} emulator")
root.configure(background="#2b2f36")
root.configure(background="#2f2f2f")
root.resizable(False, False)
width = panel_w * scale + 2 * margin
height = panel_h * scale + 2 * margin + keys_h
canvas = tk.Canvas(
root, width=width, height=height, background="#2b2f36", highlightthickness=0
root, width=width, height=height, background="#2f2f2f", highlightthickness=0
)
canvas.pack()
base = tk.PhotoImage(width=panel_w, height=panel_h)
+32 -1
View File
@@ -12,7 +12,7 @@ import json
import sqlite3
from pathlib import Path
SCHEMA_VERSION = 19
SCHEMA_VERSION = 20
_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_meta (
@@ -404,6 +404,37 @@ def _migrate(conn: sqlite3.Connection) -> None:
# that the run log writes as "<secret>" (refer to meshterm.tools.base.Secret).
# This step removes the keys that the earlier versions stored, one time.
_forget_imported_keys(conn)
if stored is not None and int(stored["value"]) < 20:
# v19 -> v20: no secret of any kind in the run log. The parameters and summaries
# of the runs held channel secrets (in each chat run, inside its Conversation),
# room passwords, admin passwords, and pairing PINs. The run log now stores each
# one as "<secret>" (refer to meshterm.core.secrets). This step hides the secrets
# that the earlier versions stored, one time.
_hide_stored_secrets(conn)
def _hide_stored_secrets(conn: sqlite3.Connection) -> None:
"""Hide each secret in the stored arguments and summaries of the runs.
The rules are the rules that the run log applies to a new run
(:func:`~meshterm.core.secrets.loggable`). The stored JSON has lost the types of the
objects: a ``Conversation`` is the text of its ``repr``. The text rules find the
secret in that text (``secret=b'...'``). A row that does not change is not written.
"""
from ..core.secrets import loggable
rows = conn.execute("SELECT id, args_json, summary_json FROM runs").fetchall()
for row in rows:
for column in ("args_json", "summary_json"):
stored = row[column]
if not stored:
continue
try:
hidden = json.dumps(loggable(json.loads(stored)), default=str)
except ValueError:
continue
if hidden != json.dumps(json.loads(stored)):
conn.execute(f"UPDATE runs SET {column} = ? WHERE id = ?", (hidden, row["id"]))
def _forget_imported_keys(conn: sqlite3.Connection) -> None:
+75 -1
View File
@@ -18,6 +18,16 @@ space on a handheld, and it is too large to attach to a bug report.
A preference sets how much MeshTerm writes: ``log_level``, with the default ``WARNING``.
Thus the file has the problems, not a full account of a session that works. When you
look for the cause of a problem, set it lower, to ``INFO`` or ``DEBUG``.
No log ever holds a password or a secret. Both handlers hide each secret that
:mod:`meshterm.core.secrets` finds in a record, and the file formatter does it again on
the traceback. This does not depend on a preference.
A preference, ``anonymize``, masks the personal details of each line before it
goes into the file: the account name in a home folder, the address of a radio, and the
address of a host (refer to :mod:`meshterm.core.anonymize`). The mask applies as each line
is written, so the file never holds what it masks. A line that was written before the
preference was turned on stays as it is.
"""
from __future__ import annotations
@@ -136,6 +146,7 @@ def configure_logging(
console=console, rich_tracebacks=True, show_path=False, markup=False
)
rich_handler.setLevel(level)
rich_handler.addFilter(_HIDE_SECRETS)
logger.addHandler(rich_handler)
log_dir.mkdir(parents=True, exist_ok=True)
@@ -146,7 +157,8 @@ def configure_logging(
encoding="utf-8",
)
file_handler.setLevel(file_level)
file_handler.setFormatter(logging.Formatter(_LINE_FORMAT, datefmt=_TIME_FORMAT))
file_handler.addFilter(_HIDE_SECRETS)
file_handler.setFormatter(_FileFormatter(_LINE_FORMAT, datefmt=_TIME_FORMAT))
logger.addHandler(file_handler)
_quiet_library_console(file_handler)
@@ -154,6 +166,68 @@ def configure_logging(
return logger
class _HideSecrets(logging.Filter):
"""Hide each password and secret in the message of a record, for every handler.
A filter on a handler sees each record that the handler gets, also the records of a
child logger and of the libraries in :data:`_LIBRARY_LOGGERS`. A filter on a logger
sees only the records of that logger. The filter writes the hidden message back into
the record, so each handler after it gets the hidden form. A second pass finds nothing
more, so the order of the handlers does not matter.
The text of an exception can repeat a secret too. The console handler draws a
traceback from the exception itself, and not from text that a filter can change. Thus,
when the traceback of a record holds a secret, the filter adds the hidden traceback to
the message as text and removes the exception from the record. Only such a record
loses the drawn traceback of the console.
"""
def filter(self, record: logging.LogRecord) -> bool:
"""Replace the message of ``record`` with its hidden form, and keep the record."""
from ..core.secrets import hide
try:
message = record.getMessage()
except Exception: # noqa: BLE001 - a bad format is the problem of the handler
return True
hidden = hide(message)
if record.exc_info and record.exc_info[0] is not None:
traceback = logging.Formatter().formatException(record.exc_info)
hidden_traceback = hide(traceback)
if hidden_traceback != traceback:
hidden = f"{hidden}\n{hidden_traceback}"
record.exc_info, record.exc_text = None, None
if hidden != message or record.args:
record.msg, record.args = hidden, None
return True
_HIDE_SECRETS = _HideSecrets()
class _FileFormatter(logging.Formatter):
"""The line format of the file, with no secret, and anonymized when the user asks.
The formatter works on the whole formatted text: the message, and the traceback under
it. :class:`_HideSecrets` already hid the message. The text of an exception in a
traceback can repeat a secret too, so the formatter hides secrets again here, always.
Then it masks the personal details if the ``anonymize`` preference is on. A traceback
holds the most home folders, because each frame names its file. The formatter reads
the preference at each record, so a change on the Preferences page applies to the
next line. The mask does not change the record. The console handler gets the same
record and shows it unmasked, because the console belongs to the user who reads it.
"""
def format(self, record: logging.LogRecord) -> str:
"""Format ``record`` as one line (and its traceback), with no secret in it."""
from ..core.anonymize import anonymize
from ..core.preferences import current
from ..core.secrets import hide
text = hide(super().format(record))
return anonymize(text) if current().anonymize else text
#: Third-party loggers that must never write to the terminal, because their records print
#: over the full-screen TUI. This module routes them to the file handler instead, so that
#: the records stay available for debugging. ``meshcore`` is the main cause: it calls
+106 -41
View File
@@ -30,6 +30,7 @@ from ..core.models import (
utcnow,
)
from ..core.regions import SCOPED_ROUTES, raw_scope_body
from ..core.secrets import loggable
from . import db
if TYPE_CHECKING:
@@ -75,6 +76,17 @@ ACTIVITY_DRAWN_BUCKETS = 24
#: recent past gets several readings for a median.
HOP_EVIDENCE_LIMIT = 20000
#: The statement that stores one observation. :meth:`Repository.record_observation` and
#: :meth:`Repository.record_observations` share it, with the values of
#: :meth:`Repository._observation_row`.
_INSERT_OBSERVATION = (
"INSERT INTO observations "
"(run_id, node, public_key, name, kind, node_type, snr, rssi, lat, lon, path, "
"observed_at, chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, "
"trace_snrs, route, transport_code, scope_body) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
def _packet_raw(row: sqlite3.Row) -> dict | None:
"""Rebuild the minimum raw payload with which MeshTerm reads a stored ``packet`` row.
@@ -350,40 +362,65 @@ class Repository:
# -- runs -------------------------------------------------------------------
def start_run(self, tool: str, args: dict[str, Any], profile: str | None = None) -> int:
def start_run(
self,
tool: str,
args: dict[str, Any],
profile: str | None = None,
*,
at: datetime | None = None,
) -> int:
"""Store the start of a tool run.
The run log never keeps a secret. The arguments go through
:func:`~meshterm.core.secrets.loggable`, so a password, a channel secret, or a PIN
is stored as ``<secret>``.
Args:
tool: The name of the tool.
args: The arguments with which the tool was called (they must be
JSON-serializable).
profile: The name of the active device profile, if any.
at: When the run started. The default is now. Only the demo history gives
another time, because it writes the runs of past days.
Returns:
The primary key of the new run.
"""
stored = json.dumps(loggable(args), default=str)
cur = self._conn.execute(
"INSERT INTO runs (tool, profile, args_json, status, started_at) "
"VALUES (?, ?, ?, 'running', ?)",
(tool, profile, json.dumps(args, default=str), utcnow().isoformat()),
(tool, profile, stored, (at or utcnow()).isoformat()),
)
self._conn.commit()
return int(cur.lastrowid)
def finish_run(self, run_id: int, status: str, summary: dict | None = None) -> None:
def finish_run(
self,
run_id: int,
status: str,
summary: dict | None = None,
*,
at: datetime | None = None,
) -> None:
"""Mark a run as finished.
The summary goes through :func:`~meshterm.core.secrets.loggable`, as the arguments
do in :meth:`start_run`. An error message that repeats a secret is hidden too.
Args:
run_id: The run to update.
status: The final status (``ok`` or ``error``).
summary: An optional JSON-serializable summary of the result.
at: When the run finished. The default is now (refer to :meth:`start_run`).
"""
self._conn.execute(
"UPDATE runs SET status = ?, summary_json = ?, finished_at = ? WHERE id = ?",
(
status,
json.dumps(summary, default=str) if summary is not None else None,
utcnow().isoformat(),
json.dumps(loggable(summary), default=str) if summary is not None else None,
(at or utcnow()).isoformat(),
run_id,
),
)
@@ -683,7 +720,12 @@ class Repository:
return paths
def record_neighbours(
self, run_id: int, repeater: str, neighbours: list[NeighbourInfo]
self,
run_id: int,
repeater: str,
neighbours: list[NeighbourInfo],
*,
at: datetime | None = None,
) -> None:
"""Store one snapshot of the neighbour table that MeshTerm got from a remote repeater.
@@ -699,8 +741,10 @@ class Repository:
repeater: The canonical id of the repeater that sent the table.
neighbours: The entries that MeshTerm got (this list can be empty: then no
rows are stored).
at: When MeshTerm got the table. The default is now (refer to
:meth:`start_run`).
"""
fetched_at = utcnow().isoformat()
fetched_at = (at or utcnow()).isoformat()
self._conn.executemany(
"INSERT INTO neighbour_reports "
"(run_id, repeater, neighbour, snr, heard_at, fetched_at) "
@@ -840,6 +884,7 @@ class Repository:
app_version: str,
keep: int = 5,
ascending: bool = False,
at: datetime | None = None,
) -> int | None:
"""Offer one walk to a category leaderboard. Store it only if it gets a place.
@@ -862,11 +907,13 @@ class Repository:
keep: The board size (the rows kept for each category and width).
ascending: ``True`` for categories in which lower scores win
(Thin thread looks for the weakest link that survives).
at: When the walk was made. The default is now (refer to :meth:`start_run`).
Returns:
The id of the stored row when the walk got a place (a new row, or a better
score on a known route), or ``None`` when the walk did not get on the board.
"""
when = (at or utcnow()).isoformat()
def beats(challenger: float, standing: float) -> bool:
return challenger < standing if ascending else challenger > standing
@@ -888,7 +935,7 @@ class Repository:
score,
json.dumps(stats),
app_version,
utcnow().isoformat(),
when,
existing["id"],
),
)
@@ -906,7 +953,7 @@ class Repository:
score,
json.dumps(stats),
app_version,
utcnow().isoformat(),
when,
),
)
new_id = int(cursor.lastrowid)
@@ -1043,6 +1090,32 @@ class Repository:
run_id: The run that owns the observation.
obs: The observation to store.
"""
self._conn.execute(_INSERT_OBSERVATION, self._observation_row(run_id, obs))
self._conn.commit()
def record_observations(self, run_id: int, observations: Iterable[Observation]) -> int:
"""Store many overheard packets of one run in one transaction.
The rows are the same as the rows of :meth:`record_observation`. The difference is
the commit: one for all the rows, not one for each row. The demo history writes
tens of thousands of packets at each start, and a commit for each row costs more
than a second for each thousand rows on an SD card.
Args:
run_id: The run that owns the observations.
observations: The observations to store.
Returns:
The number of stored rows.
"""
rows = [self._observation_row(run_id, obs) for obs in observations]
self._conn.executemany(_INSERT_OBSERVATION, rows)
self._conn.commit()
return len(rows)
@staticmethod
def _observation_row(run_id: int, obs: Observation) -> tuple:
"""The values of one ``observations`` row, in the order of :data:`_INSERT_OBSERVATION`."""
chan_hash = cipher_mac = crypted = None
raw = obs.raw if isinstance(obs.raw, dict) else {}
# The payload class identifies a relayed 'packet' that names no origin node. Keep
@@ -1082,39 +1155,31 @@ class Repository:
transport_code = raw.get("transport_code") if route in SCOPED_ROUTES else None
body = raw_scope_body(raw) if route == "TC_FLOOD" and transport_code else None
scope_body = body.hex() if body is not None else None
self._conn.execute(
"INSERT INTO observations "
"(run_id, node, public_key, name, kind, node_type, snr, rssi, lat, lon, path, "
"observed_at, chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag, "
"trace_snrs, route, transport_code, scope_body) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
run_id,
obs.node,
obs.public_key,
obs.name,
obs.kind,
obs.node_type,
obs.snr,
obs.rssi,
obs.lat,
obs.lon,
obs.path,
obs.observed_at.isoformat(),
chan_hash,
cipher_mac,
crypted,
typename,
dest,
src,
tag,
trace_snrs,
route,
transport_code,
scope_body,
),
return (
run_id,
obs.node,
obs.public_key,
obs.name,
obs.kind,
obs.node_type,
obs.snr,
obs.rssi,
obs.lat,
obs.lon,
obs.path,
obs.observed_at.isoformat(),
chan_hash,
cipher_mac,
crypted,
typename,
dest,
src,
tag,
trace_snrs,
route,
transport_code,
scope_body,
)
self._conn.commit()
def observation_count(self) -> int:
"""Return the total number of overheard packets stored in all the runs.
+8 -4
View File
@@ -274,13 +274,17 @@ PICOCALC_LYRA = Platform(
#: The Cardputer Zero of M5Stack. Its display is 320×170 pixels, drawn in cells of 6×12
#: (53×14). Under it is a keyboard of 46 keys, and the number keys 4 to 8 are right below
#: the display. The hardware was not in hand on 2026-10-04. Thus only ``--platform
#: cardputer-zero`` or ``MESHTERM_PLATFORM`` chooses this platform. There is no
#: device-tree auto-detection until the device reports its own model string.
#: the display. Both models are this platform: the Cardputer Zero and the Cardputer Zero
#: Lite. They differ only in parts that MeshTerm does not use (a camera, motion sensors,
#: and a microSD card in the box). Only ``--platform cardputer-zero`` or
#: ``MESHTERM_PLATFORM`` chooses this platform. There is no device-tree auto-detection,
#: because the model string of the handheld is the generic string of its compute module
#: ("Raspberry Pi Compute Module 0 Rev 1.0", seen on the device on 2026-10-05). On the
#: handheld, MeshTerm's own console (:mod:`meshterm.emulator`) supplies the platform.
#:
#: It has the same 53 columns as the PicoCalc, so most of the flavour of the PicoCalc
#: applies. The new constraint is the number of rows. It has its own F-key lane deck (Fn+4…8,
#: with the chips centred over their keys). For now, the deck deals the lanes of the PicoCalc
#: with the chips placed as on the PicoCalc). For now, the deck deals the lanes of the PicoCalc
#: (JP, 2026-09-30). MeshTerm draws the display itself in RGB565 (:mod:`meshterm.emulator`),
#: so the 16-slot palette does not limit it (``truecolor``). These values are provisional
#: until MeshTerm measures them on the device: the cadences, which come from the PicoCalc,
+101 -65
View File
@@ -59,7 +59,8 @@ if TYPE_CHECKING:
#: The time (seconds) between two battery reads. The charge of a pack changes slowly, thus a
#: long interval is sufficient. The interval is also short enough that the charging trend
#: (measured over :data:`_TREND_WINDOW_S`) has several samples to use.
#: (measured over :data:`_TREND_WINDOW_S`) has several samples to use. After a failed read of
#: the host pack, the next read comes sooner (refer to :data:`_HOST_RETRY_S`).
POLL_S = 20.0
#: The minimum level (millivolts) at which a reading counts as a real battery. A device with
@@ -96,26 +97,34 @@ _TREND_SAMPLES = int(_TREND_WINDOW_S / POLL_S) + 2
#: ``bq27220-0``, both qualify by their type, not by a name in a list.
_POWER_SUPPLIES = Path("/sys/class/power_supply")
#: The number of times that a poll asks for the percent of the pack before it stops, and
#: the pause between two requests. The BQ27220 of the Cardputer Zero shares I2C bus 1 with
#: the keyboard and the clock. While the Cap on its header has power, that bus is noisy.
#: Measurements on the handheld gave these results: one quarter to three quarters of the
#: reads of the gauge fail with an I/O error (one in ten of the reads of the clock). With
#: the header off, no read fails. Some reads that "succeed" are garbled, for example a
#: ``capacity`` of 56414. The ``bq27xxx`` driver of the kernel caches what it read for five
#: seconds, a failure or a garbled value the same. Thus a new request before that time gets
#: the same answer, and the pause is a little longer than five seconds.
_HOST_READ_ATTEMPTS = 3
#: The pause (seconds) before the next read of the host pack, after a read that failed or
#: that gave a change that a second read must confirm. The BQ27220 of the Cardputer Zero
#: shares I2C bus 1 with the keyboard and the clock. While the Cap on its header has power,
#: that bus is noisy. The first measurements on the handheld (2026-10-05) gave these
#: results: one quarter to three quarters of the reads of the gauge fail with an I/O error
#: (one in ten of the reads of the clock). With the header off, no read fails. Some reads
#: that "succeed" are garbled, for example a ``capacity`` of 56414. The ``bq27xxx`` driver
#: of the kernel caches what it read for five seconds, a failure or a garbled value the
#: same. Thus a new read before that time gets the same answer, and the pause is a little
#: longer than five seconds.
#:
#: A failed read does not wait for the full :data:`POLL_S`. On battery with the Cap on, 79%
#: of 791 reads failed (2026-10-19). The failures came in groups of up to 110 s, with short
#: good periods between them. A poll every 20 s missed most of these periods, and the gauge
#: went away. A read every 5.5 s finds them.
_HOST_RETRY_S = 5.5
#: The number of consecutive polls that can fail to read a host pack before its gauge is
#: removed. A pack does not change in two minutes. Thus the last reading stays, and the
#: gauge does not blink out.
_HOST_MISSES_KEPT = 6
#: The time (seconds) that the last good reading of the host pack stays while the reads
#: after it fail. After this time, the gauge goes away, because the pack is gone, not only
#: difficult to read. On 2026-10-19, with the Cap on, the pack of the Cardputer Zero went
#: from 96% to 83% in 26 minutes. Thus a reading from ten minutes before is still correct to
#: approximately five points. The limit was once 7 failed polls (approximately 3.5 minutes).
#: On battery with the Cap on, a group of failures was that long, and the gauge went away.
_HOST_STALE_S = 600.0
#: The maximum change of a percent between two polls that MeshTerm accepts immediately. A
#: The maximum change of a percent between two reads that MeshTerm accepts immediately. A
#: pack does not gain or lose this much in :data:`POLL_S`. MeshTerm holds a larger step
#: until the next poll gives the same value. This catches a garbled read that is in the
#: until the next read gives the same value. This catches a garbled read that is in the
#: range 0–100 by chance.
_HOST_STEP_PCT = 5
@@ -131,27 +140,22 @@ async def _sysfs(path: Path) -> str:
async def _read_percent(path: Path) -> int:
"""The percent of the pack from ``capacity``, asked again while it is not read or garbled.
"""The percent of the pack from ``capacity``, read one time.
Here, an I/O error and a percent outside 0–100 have the same meaning: no reading. The
function asks again after :data:`_HOST_RETRY_S`. At that time, the driver reads the
gauge again.
Here, an I/O error, a value that is not a number, and a percent outside 0–100 have the
same meaning: no reading. The poll reads again after :data:`_HOST_RETRY_S`, when the
driver reads the gauge again.
Raises:
OSError: When :data:`_HOST_READ_ATTEMPTS` tries gave no percent.
OSError: When the read gave no percent.
"""
for attempt in range(_HOST_READ_ATTEMPTS):
if attempt:
await asyncio.sleep(_HOST_RETRY_S)
try:
value = int(await _sysfs(path))
except FileNotFoundError:
raise # the value does not exist: a new request cannot make it
except (OSError, ValueError):
continue
if 0 <= value <= 100:
return value
raise OSError(f"{path} gave no percent")
try:
value = int(await _sysfs(path))
except ValueError as exc:
raise OSError(f"{path} gave no number") from exc
if not 0 <= value <= 100:
raise OSError(f"{path} gave {value}, which is not a percent")
return value
def host_supply(root: Path = _POWER_SUPPLIES) -> Path | None:
@@ -271,12 +275,15 @@ class BatteryService:
self._reading: BatteryReading | None = None
#: The recent ``(monotonic_time, millivolts)`` samples, for the charging trend.
self._history: deque[tuple[float, int]] = deque(maxlen=_TREND_SAMPLES)
#: The number of consecutive reads of the host pack that failed (refer to
#: :data:`_HOST_MISSES_KEPT`).
self._host_misses = 0
#: The monotonic time of the last good read of the host pack (refer to
#: :data:`_HOST_STALE_S`).
self._host_read_at = 0.0
#: A host percent that is too far from the shown percent to accept yet
#: (:data:`_HOST_STEP_PCT`).
self._host_unconfirmed: int | None = None
#: A host charging flag that is not the shown flag, and that the next read must
#: confirm before the gauge shows it.
self._host_status_unconfirmed: bool | None = None
@property
def active(self) -> bool:
@@ -310,17 +317,22 @@ class BatteryService:
await self.stop()
async def _run(self) -> None:
"""Read one time immediately, then tick with no end: a best-effort pass, then a sleep."""
"""Read one time immediately, then tick with no end: a best-effort pass, then a sleep.
Each pass returns the time until the next pass. A pass that raised an error waits
for the full :data:`POLL_S`.
"""
while True:
delay = POLL_S
try:
await self._poll()
delay = await self._poll()
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - a bad read must not stop the loop
self._ctx.log.debug("battery poller: read failed: %s", exc)
await asyncio.sleep(POLL_S)
await asyncio.sleep(delay)
async def _poll(self) -> None:
async def _poll(self) -> float:
"""One pass: read the pack, estimate the charge, and calculate the charging trend again.
The platform (``Platform.battery``) sets which pack the pass reads. The PicoCalc
@@ -328,13 +340,16 @@ class BatteryService:
driver of the kernel. That driver gives a percent and a real charging flag, and no
estimate is necessary. The regular platform polls the connected companion over the
mesh link.
Returns:
The seconds until the next pass: :data:`POLL_S`, or a shorter time when the host
pack must be read again soon (refer to :meth:`_poll_host`).
"""
if get_platform().battery == "host":
await self._poll_host()
return
return await self._poll_host()
ctx = self._ctx
if not ctx.is_connected:
return
return POLL_S
device = await ctx.device()
info = await device.get_battery()
mv = int(info.get("level") or 0)
@@ -344,7 +359,7 @@ class BatteryService:
# different device.
self._reading = None
self._history.clear()
return
return POLL_S
now = time.monotonic()
self._history.append((now, mv))
# When the device gives a charging flag from its firmware (the standard BLE Battery
@@ -357,45 +372,65 @@ class BatteryService:
percent=battery_percent(mv),
charging=charging,
)
return POLL_S
async def _poll_host(self) -> None:
async def _poll_host(self) -> float:
"""Read the pack of the host from sysfs (:func:`host_supply`).
The driver is a standard ``power_supply``: ``capacity`` is a true percent, and
``status`` is a real charging flag (checked in P0). Thus the voltage curve and the
trend estimate of the companion path do not apply. A supply that cannot be read
(no driver, or no permission) reports *absent*, and the header draws no gauge. But
this occurs only after :data:`_HOST_MISSES_KEPT` consecutive polls failed. A failed
poll after a good poll keeps the good reading.
trend estimate of the companion path do not apply. When there is no such supply, the
reading is absent at once, and the header draws no gauge.
The method asks for the percent again while it cannot be read or is out of range
(:func:`_read_percent`). A step that is larger than :data:`_HOST_STEP_PCT` waits
for the next poll to confirm it. ``status`` is read from the same driver snapshot
as the percent. When ``status`` cannot be read, the charging verdict of the last
good reading stays. The percent is the important value, and the flag is only
extra information.
On a noisy bus (refer to :data:`_HOST_RETRY_S`), a read can fail or give a wrong
value. The method guards the reading in three ways:
* After a failed read, the next read comes after :data:`_HOST_RETRY_S`, not after
:data:`POLL_S`. The last good reading stays for :data:`_HOST_STALE_S`, then it
goes.
* A change of the percent that is larger than :data:`_HOST_STEP_PCT` waits for a
second read that gives the same value.
* A change of the charging flag waits for a second read that gives the same flag. A
garbled read can say ``Charging`` while the pack discharges (seen on 2026-10-19).
When ``status`` cannot be read, the flag of the last good reading stays. The
percent is the important value, and the flag is only extra information.
Returns:
The seconds until the next pass: :data:`_HOST_RETRY_S` after a failed read or
while a change waits for its second read, and :data:`POLL_S` in all other cases.
"""
supply = host_supply(_POWER_SUPPLIES)
if supply is None:
self._reading = None
return POLL_S
now = time.monotonic()
shown = self._reading
try:
if supply is None:
raise OSError("no battery power_supply")
percent = await _read_percent(supply / "capacity")
except OSError:
self._host_misses += 1
if self._host_misses > _HOST_MISSES_KEPT:
if shown is not None and now - self._host_read_at > _HOST_STALE_S:
self._reading = None
return
self._host_misses = 0
shown = self._reading
return _HOST_RETRY_S
if shown is not None and abs(percent - shown.percent) > _HOST_STEP_PCT:
if percent != self._host_unconfirmed:
self._host_unconfirmed = percent # accepted when the next poll gives it too
return
self._host_unconfirmed = percent # accepted when the next read gives it too
return _HOST_RETRY_S
self._host_unconfirmed = None
self._host_read_at = now
delay = POLL_S
try:
charging = await _sysfs(supply / "status") == "Charging"
except OSError:
charging = shown.charging if shown is not None else False
else:
if shown is None or charging == shown.charging:
self._host_status_unconfirmed = None
elif charging == self._host_status_unconfirmed:
self._host_status_unconfirmed = None # the second read agrees: accept it
else:
self._host_status_unconfirmed = charging # accepted when the next read agrees
charging = shown.charging
delay = _HOST_RETRY_S
try: # only for information. The header draws the percent and the charging flag.
mv = int(await _sysfs(supply / "voltage_now")) // 1000
except (OSError, ValueError):
@@ -405,6 +440,7 @@ class BatteryService:
percent=percent,
charging=charging,
)
return delay
def _charging(self, now: float) -> bool:
"""Infer from the recent trend of the terminal voltage if the pack is charging.
+70 -12
View File
@@ -19,11 +19,14 @@ MeshTerm always stores them while a device listens.
from __future__ import annotations
import asyncio
import time
from collections import OrderedDict
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ..core.channels import channel_identity
from ..core import reactions
from ..core.channels import channel_identity, split_channel_sender
from ..core.connection import Unsubscribe
from ..core.events import EventKind, MeshEvent
from ..core.models import (
@@ -49,6 +52,29 @@ _AWAITING_CAP = 16
#: Called with a message when its ack arrives after the send stopped its wait for the ack.
LateAck = Callable[[ChatMessage], None]
#: The highest try number that a direct message can carry. The protocol keeps it in two bits.
_LAST_ATTEMPT = 3
def _stamp() -> tuple[int, datetime]:
"""The timestamp for a message that MeshTerm is about to send, and the same instant as a date.
MeshTerm gives the timestamp to the device, and does not let the library read the clock
a second time. Thus the stored time of the message is exactly the time that the
recipients see. A reaction names its target by that time (refer to
:mod:`~meshterm.core.reactions`), so the two must agree to the second.
"""
seconds = int(time.time())
return seconds, datetime.fromtimestamp(seconds, tz=timezone.utc)
def _is_reaction(message: Message) -> bool:
"""Whether an inbound message is a reaction. A room post never is one (rooms have none)."""
if message.is_post:
return False
body = split_channel_sender(message.text)[1] if message.is_channel else message.text
return reactions.parse(body.strip(" \t"), channel=message.is_channel) is not None
def _fallback_channel_id(idx: int | None) -> str:
"""The identity of a channel that MeshTerm cannot read.
@@ -103,6 +129,9 @@ class ChatService:
# that wants to know when its ack arrives (refer to :meth:`_settle_ack`).
self._awaiting: OrderedDict[str, tuple[ChatMessage, LateAck | None]] = OrderedDict()
self._ack_unsubscribe: Unsubscribe | None = None
# The next try number of each direct message that was sent (by its row id). A
# retry with ^R sends the same message again: the same timestamp, the next number.
self._next_attempt: dict[int, int] = {}
@property
def active(self) -> bool:
@@ -408,6 +437,9 @@ class ChatService:
that comes from the slot.
* A muted channel (refer to :class:`~meshterm.core.mute_store.MuteStore`). This is
the one suppression that the user asks for, not one that the device implies.
* A reaction (refer to :mod:`~meshterm.core.reactions`). The transcript shows it on
the message that it reacts to, not as a new message. Thus a badge for it would
point at nothing new.
The method asks the device through the session cache. Thus this rule costs nothing
for each message, more than the channel read that the identity already made.
@@ -422,6 +454,8 @@ class ChatService:
Returns:
``True`` to increase the unread count of the conversation.
"""
if _is_reaction(message):
return False
if message.is_channel:
store = getattr(self._ctx, "mute_store", None)
if store is not None and store.is_muted(channel_id):
@@ -481,7 +515,9 @@ class ChatService:
return is_direct_messageable(contact.node_type)
return False
async def _deliver_direct(self, contact: Contact, text: str) -> tuple[bool, list[str]]:
async def _deliver_direct(
self, contact: Contact, text: str, *, timestamp: int, first_attempt: int = 0
) -> tuple[bool, list[str], int]:
"""Transmit a direct message, and try again softly until it is acknowledged.
One logical send makes one first transmission, plus up to
@@ -496,13 +532,22 @@ class ChatService:
stores the message as unacknowledged. Then Ctrl-R in the chat screen is the retry
that the user starts, in addition to these automatic retries.
Each try carries the same timestamp and the next try number, as the protocol
specifies. Thus the recipient sees one message with one timestamp, whichever try
arrives. A reaction to it names that timestamp. The try number makes each packet
different, so a repeater does not discard a retry as a copy of the first packet. The
number stops at 3 (the protocol has two bits for it).
Args:
contact: The recipient.
text: The message body.
timestamp: The timestamp of the message, the same for each try.
first_attempt: The try number of the first transmission.
Returns:
Whether an attempt was acknowledged in its wait, and the ack code of each
attempt. A late ack can still carry any of these codes (the device keeps them).
Whether an attempt was acknowledged in its wait, the ack code of each attempt (a
late ack can still carry any of these codes, because the device keeps them),
and the try number for the next retry.
Raises:
Exception: A hard send failure (the companion rejects the send) goes to the
@@ -512,12 +557,16 @@ class ChatService:
device = await self._ctx.device()
attempts = max(0, self._ctx.preferences.direct_message_soft_retries) + 1
codes: list[str] = []
number = first_attempt
for attempt in range(1, attempts + 1):
delivery: Delivery = await device.send_direct_message(contact, text)
delivery: Delivery = await device.send_direct_message(
contact, text, timestamp=timestamp, attempt=min(number, _LAST_ATTEMPT)
)
number += 1
if delivery.code:
codes.append(delivery.code)
if delivery.acked:
return True, codes
return True, codes, number
if attempt < attempts:
self._ctx.log.debug(
"chat: DM to %s unacked on try %d/%d; soft-retrying",
@@ -525,7 +574,7 @@ class ChatService:
attempt,
attempts,
)
return False, codes
return False, codes, number
async def send_direct(
self, contact: Contact, text: str, *, on_late_ack: LateAck | None = None
@@ -549,7 +598,8 @@ class ChatService:
The stored outbound :class:`ChatMessage`. Its ``acked`` shows whether a
delivery acknowledgement arrived in the wait, until now.
"""
acked, codes = await self._deliver_direct(contact, text)
seconds, sent_at = _stamp()
acked, codes, number = await self._deliver_direct(contact, text, timestamp=seconds)
chat = ChatMessage(
text=text,
outbound=True,
@@ -557,10 +607,11 @@ class ChatService:
peer=contact.key_prefix or contact.public_key[:12] or None,
peer_name=contact.name,
acked=acked,
created_at=utcnow(),
created_at=sent_at,
)
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
if not acked:
self._next_attempt[chat.row_id] = number
self._await_ack(chat, codes, on_late_ack)
return chat
@@ -583,7 +634,13 @@ class ChatService:
Returns:
The same ``message``, with :attr:`~ChatMessage.acked` updated.
"""
acked, codes = await self._deliver_direct(contact, message.text)
seconds = int(message.created_at.timestamp())
first = self._next_attempt.get(message.row_id, 1) if message.row_id is not None else 1
acked, codes, number = await self._deliver_direct(
contact, message.text, timestamp=seconds, first_attempt=first
)
if message.row_id is not None:
self._next_attempt[message.row_id] = number
if message.acked:
return message # a late ack for an earlier attempt settled it during this send
message.acked = acked
@@ -756,7 +813,8 @@ class ChatService:
device = await self._ctx.device()
channel_id = await self.channel_id_for(index)
asked = scope if scope is not None else self.channel_scope(channel_id)
await device.send_channel_in_scope(index, text, asked)
seconds, sent_at = _stamp()
await device.send_channel_in_scope(index, text, asked, timestamp=seconds)
chat = ChatMessage(
text=text,
outbound=True,
@@ -764,7 +822,7 @@ class ChatService:
channel_id=channel_id,
channel_idx=index,
peer_name=label,
created_at=utcnow(),
created_at=sent_at,
scope=await self._sent_under(asked),
)
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
+30 -7
View File
@@ -29,6 +29,8 @@ the header comes on when a delivery during the night is at last complete.
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
from typing import TYPE_CHECKING
@@ -81,8 +83,11 @@ class CourierService:
#: The time when MeshTerm last heard each node id in this session (the freshness
#: map).
self._heard: dict[str, datetime] = {}
#: Re-entry guard: only one try at a time is in progress, from a pass or forced.
self._sending = False
#: Re-entry guard: only one try at a time is in progress, from a pass or forced. It
#: is the id of the entry that the try sends, or ``None`` when no try runs.
self._in_flight: int | None = None
#: The ids of the entries that the user edits now. A pass does not try them.
self._held: set[int] = set()
@property
def active(self) -> bool:
@@ -93,6 +98,24 @@ class CourierService:
"""How many messages wait in the outbox."""
return self._ctx.courier_store.pending_count()
def in_flight(self, ident: int) -> bool:
"""Whether a delivery try of the entry ``ident`` is in progress now."""
return self._in_flight == ident
@contextmanager
def holding(self, ident: int) -> Iterator[None]:
"""Keep the passes off one entry while the user edits its body.
Without the hold, a pass can send the old body while the edit prompt is open. Then
the edit changes the text of a message that already went out. A forced try
(:meth:`attempt_now`) does not read the hold, because the user asks for it.
"""
self._held.add(ident)
try:
yield
finally:
self._held.discard(ident)
# --- lifecycle ---------------------------------------------------------------------
async def start(self) -> None:
@@ -191,11 +214,11 @@ class CourierService:
async def _pass(self) -> None:
"""One pass: try the oldest eligible entry, if there is one (one for each pass)."""
if not self._ctx.is_connected or self._sending:
if not self._ctx.is_connected or self._in_flight is not None:
return
now = utcnow()
for message in self._ctx.courier_store.pending():
if self.eligible(message, now):
if message.ident not in self._held and self.eligible(message, now):
await self._attempt(message)
return
@@ -212,14 +235,14 @@ class CourierService:
message = self._ctx.courier_store.get(ident)
if message is None or message.status != QUEUED:
return "gone"
if self._sending:
if self._in_flight is not None:
return "busy"
return await self._attempt(message)
async def _attempt(self, message: QueuedMessage) -> str:
"""Run one delivery try, and set the new state of the entry."""
store = self._ctx.courier_store
self._sending = True
self._in_flight = message.ident
try:
contact = await self._resolve(message)
if contact is None:
@@ -258,7 +281,7 @@ class CourierService:
)
return "no ack"
finally:
self._sending = False
self._in_flight = None
def _delivered_late(self, ident: int) -> None:
"""The ack of a try arrived after the try stopped its wait: the message was delivered.
+10 -35
View File
@@ -16,6 +16,13 @@ from typing import TYPE_CHECKING, Any
from ..core import exitcodes
# The wrapper for a secret parameter, and its reader. They live with the other rules that
# keep secrets out of the logs (refer to meshterm.core.secrets). The tools import them
# from here, next to Tool and ToolResult.
from ..core.secrets import Secret as Secret
from ..core.secrets import loggable
from ..core.secrets import reveal as reveal
if TYPE_CHECKING: # do not import typer or the context at module load, for a fast start
import typer
@@ -23,40 +30,6 @@ if TYPE_CHECKING: # do not import typer or the context at module load, for a fa
from ..ui.report import Report
class Secret:
"""A parameter of a run that the run log must not keep, for example a private key.
:meth:`Tool.execute` stores the parameters of each run in the ``runs`` table, and logs
them at debug level. A secret in the parameters was thus stored as plain text: the key
of ``config import-key`` stayed in the database. A secret goes into the parameters in
this wrapper. The JSON of the run log and the log line show ``<secret>``, and the tool
reads :attr:`value`.
The class does not derive from ``str``, because ``json.dumps`` writes the value of a
``str`` subclass and never calls its ``default`` for it.
Attributes:
value: The secret itself.
"""
__slots__ = ("value",)
def __init__(self, value: str) -> None:
"""Keep ``value`` out of each text form of this object."""
self.value = value
def __repr__(self) -> str:
"""``<secret>``, never the value."""
return "<secret>"
__str__ = __repr__
def reveal(value: Any) -> Any:
"""The value inside a :class:`Secret`, or ``value`` itself if it is not a secret."""
return value.value if isinstance(value, Secret) else value
@dataclass(slots=True)
class ToolResult:
"""The result of a run of a tool.
@@ -202,7 +175,9 @@ class Tool(ABC):
from ..core.selection import DeviceSelectionError
run_id = ctx.repo.start_run(self.name, params, ctx.profile_name)
ctx.log.debug("run %s start: tool=%s params=%s", run_id, self.name, params)
# The copy with each secret hidden: a password, a channel secret, or a PIN in the
# parameters must never reach the log (refer to meshterm.core.secrets).
ctx.log.debug("run %s start: tool=%s params=%s", run_id, self.name, loggable(params))
try:
result = await self.run(ctx, {**params, "_run_id": run_id})
except (
+116 -42
View File
@@ -27,7 +27,7 @@ from rich.cells import cell_len
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes
from ..core import exitcodes, reactions
from ..core.channels import (
CHANNEL_SLOT_PROBE_CAP,
DEFAULT_PUBLIC_SECRET,
@@ -58,19 +58,19 @@ from ..services.trace_runner import (
from ..ui import renderers
from ..ui.fields import ChannelRef, NodeRef
from ..ui.menus import Lane, column_header, fit_cells, section_heading
from ..ui.pathline import SELF_GLYPH, PathHop
from ..ui.termfont import powerline_enabled
from ..ui.theme import name_style
from ..ui.theme import emoticon, name_style
from ..ui.tui import Choice, DeleteRequest, Separator
from ..ui.widgets import (
DESTINATION,
NODE_GLYPHS,
ORIGIN,
age_seconds,
author_chip,
channel_glyph,
format_age,
hop_chip,
name_chip,
mention_chip,
mention_mark,
self_chip,
)
from .base import Tool, ToolResult, register
@@ -230,6 +230,10 @@ class ChatTool(Tool):
stored_names, named_at = ctx.repo.node_names_with_times()
key_of = make_name_key_resolver(contacts, stored_names, heard=named_at)
name_of = make_node_resolver(contacts, stored_names)
# Our name and key, for our own messages and the mentions of us in the previews.
from ..ui.chat import our_node
us = await our_node(ctx)
# List the contacts by recency: first the contacts with messages, with the newest
# exchange at the top. Then the contacts with no messages, in alphabetical order
@@ -248,23 +252,25 @@ class ChatTool(Tool):
# Refer to Screen.sticky_rows.
items: list = [
Separator(lambda w: _picker_header(lanes, w), pinned=True),
section_heading("📡 Channels"),
section_heading("Channels"),
]
for conversation in channels:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of, lanes),
title=_row_title(ctx, conversation, live, key_of, lanes, us=us),
value=conversation,
# ←→ move only the message. The lanes before it stay (_Lanes.head).
hscroll_from=lanes.head,
)
)
items.append(section_heading("📌 Rooms"))
items.append(section_heading("Rooms"))
for conversation in rooms:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of, lanes, name_of=name_of),
title=_row_title(
ctx, conversation, live, key_of, lanes, name_of=name_of, us=us
),
value=conversation,
deletable=lasts.get(conversation.key) is not None,
hscroll_from=lanes.head,
@@ -275,12 +281,12 @@ class ChatTool(Tool):
# a room?", and an empty section tells them where to go.
items.append(Separator(" no rooms joined — join one on the Rooms page"))
items.append(section_heading("👤 Direct"))
items.append(section_heading("Direct"))
if companions:
for conversation in direct:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of, lanes),
title=_row_title(ctx, conversation, live, key_of, lanes, us=us),
value=conversation,
# Del offers to delete the stored history of this thread, only
# where there is history to delete.
@@ -895,6 +901,7 @@ def _row_title(
lanes: _Lanes,
*,
name_of: NodeResolver | None = None,
us: tuple[str | None, str | None] = (None, None),
) -> Callable[[], str | Text]:
"""Return the row title as a *callable* that the select screen renders on each paint.
@@ -911,11 +918,13 @@ def _row_title(
key_of: Maps a sender name back to the key of its node, for the hues of the preview.
lanes: The measured lane widths of the list.
name_of: Maps the author key of a room post to a name (only for rooms).
us: The name and the key of our node (refer to
:func:`~meshterm.ui.chat.our_node`), for our own chip in the preview.
Returns:
A callable with no arguments, which makes the current row title.
"""
return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of)
return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of, us=us)
def _title(
@@ -926,6 +935,7 @@ def _title(
lanes: _Lanes,
*,
name_of: NodeResolver | None = None,
us: tuple[str | None, str | None] = (None, None),
) -> str | Text:
"""Make a picker row from lanes of fixed width, with colour codes.
@@ -935,7 +945,7 @@ def _title(
- The name of a direct contact gets the palette hue that comes from its key (a channel
label keeps the base style).
- The first dot is the standard companion pink, and its shape shows the history.
- The first dot has the colour of the companion mark, and its shape shows the history.
- The unread ``●`` badge is red, and the age is muted.
- The preview mutes its body, and lights the sender names and the ``@mentions`` in the
hues that come from their keys. These are the same colours as in the live transcript.
@@ -951,6 +961,7 @@ def _title(
node.
lanes: The measured lane widths of the list.
name_of: Maps the author key of a room post to a name (only for rooms).
us: The name and the key of our node, for our own chip in the preview.
Returns:
The row title as a styled :class:`~rich.text.Text`.
@@ -973,19 +984,27 @@ def _title(
else:
text.append(" " * _BADGE_WIDTH)
# The relative age lane (right-aligned) is between the badge and the message text. Thus
# the ages are in one neat column, and all the previews start at the same position.
# the ages are in one neat column, and all the previews start at the same position. A
# last message that names us has our white ``★`` at the left of its age, the same as
# the transcript has it at the left of the time (refer to ChatScreen._body_lines).
age = _ago(last.created_at) if last is not None else ""
text.append(" ")
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
lane = f" {age:>{_AGE_WIDTH}}"
if age and last is not None and _names_us(last, us):
mark = len(lane) - len(age) - 2 # the mark, a space, then the age
text.append(lane[:mark])
text.append_text(mention_mark(us[1]))
text.append(lane[mark + 1 :], style="muted")
else:
text.append(lane, style="muted")
text.append(" ")
if last is not None:
text.append_text(_preview_text(last, key_of, name_of))
text.append_text(_preview_text(last, key_of, name_of, us=us))
return text
#: The standard companion pink (the shared colour of the plain node ``●``) is the hue of the
#: contact dot. The shape (filled or hollow) shows the history, the colour shows "a
#: companion", and the name next to it has the hue that comes from the key of the person.
#: The colour of the companion mark (the shared colour of the plain node ``●``) is the
#: colour of the contact dot. The shape (filled or hollow) shows the history, and the name
#: next to it has the hue that comes from the key of the person.
_COMPANION_DOT_STYLE = NODE_GLYPHS[NODE_TYPE_CHAT][1]
#: The marker of a room: the ``■`` of the room server, in the colour of its type. The map
@@ -1001,7 +1020,7 @@ def _append_marker(
"""Add the first marker of the row (a channel glyph or a contact dot) in its own lane.
A channel keeps its marker for open or private (# / 🌐 / 🔒). A contact gets a small
circle in the standard companion pink. It is filled (``●``) after we exchanged
circle in the colour of the companion mark. It is filled (``●``) after we exchanged
messages, and a hollow ring (``○``) before that. Thus the shape (hollow or filled)
shows whether there is history, and the name itself has the hue that comes from the
key of the person.
@@ -1025,19 +1044,26 @@ def _append_marker(
def _preview_text(
last: ChatMessage, key_of: NameKeyResolver, name_of: NodeResolver | None = None
last: ChatMessage,
key_of: NameKeyResolver,
name_of: NodeResolver | None = None,
*,
us: tuple[str | None, str | None] = (None, None),
) -> Text:
"""A muted preview of the last message, with sender names and ``@mentions`` lit in their hue.
It is the same as the live transcript:
- Our own messages start with our author (refer to :func:`_append_author`).
- Our own messages start with our chip, ``★ Name`` in white (refer to
:func:`~meshterm.ui.widgets.self_chip`). A mention of our name has the chip that is
white label, with no star. A message that names us has our white ``★`` at the
left of the age of the row (refer to :func:`_title`).
- In a received channel message, the inline ``Name:`` sender is the author, in the hue
that comes from its key (the node grey when no known node has the name).
- A room post starts with its author, named by key in the same way as the room screen
names authors (:func:`~meshterm.ui.room.author_label`).
- Each ``@[Name]`` mention shows as ``@Name``, in the same way (refer to
:func:`_append_body`).
- Each ``@[Name]`` mention shows as the chip of the node that it names, in the same
way (refer to :func:`_append_body`).
Thus the list and the chat use the same colour language, and where the terminal draws
chips, the same chips: the author closes on the point and a mention opens on the notch.
@@ -1047,32 +1073,64 @@ def _preview_text(
was cut before to approximately the visible lane has nothing more to show. The list
cuts it at the right edge, the same as any other row, and the scroll goes past that.
"""
body_raw = last.text.replace("\n", " ")
body_raw = _reaction_line(last) or last.text.replace("\n", " ")
text = Text()
if last.outbound:
_append_author(text, PathHop(SELF_GLYPH, you=True), Text("you: ", style="accent"))
_append_body(text, body_raw, key_of)
name, key = us
chip = self_chip(name, key, role=ORIGIN)
# Where no chips are drawn, the chip is a padded block, white on our fill. Our node
# with no name keeps the old byline.
byline = chip + Text(" ") if name and key else Text("you: ", style="accent")
_append_author(text, chip, byline)
_append_body(text, body_raw, key_of, us)
elif last.is_channel:
name, body = split_channel_sender(body_raw)
if name is not None:
key = key_of(name)
_append_author(text, PathHop(name, key=key), _byline(name, key))
_append_body(text, body, key_of)
_append_author(text, _origin(name, key), _byline(name, key))
_append_body(text, body, key_of, us)
else:
_append_body(text, body_raw, key_of)
_append_body(text, body_raw, key_of, us)
elif last.author and name_of is not None:
from ..ui.room import author_label
name, key = author_label(last.author, name_of)
_append_author(text, PathHop(name, key=key), _byline(name, key))
_append_body(text, body_raw, key_of)
_append_author(text, _origin(name, key), _byline(name, key))
_append_body(text, body_raw, key_of, us)
else:
_append_body(text, body_raw, key_of)
_append_body(text, body_raw, key_of, us)
return text
def _append_author(text: Text, hop: PathHop, byline: Text) -> None:
"""Append the author of a preview: an origin chip where chips are drawn, else ``byline``.
def _reaction_line(last: ChatMessage) -> str | None:
"""The preview body of a reaction, ``reacted 👍``, or ``None`` for a usual message.
A reaction is a message on the wire, and it can be the latest message of a
conversation. Its raw body (a mention, a newline, and a hash) means nothing to a person.
The name of the node keeps its place in front of the body, as for each message.
"""
if last.author is not None:
return None # a room post: rooms have no reactions
body = (
split_channel_sender(last.text)[1] if last.is_channel and not last.outbound else last.text
)
reaction = reactions.parse(body.strip(" \t"), channel=last.is_channel)
if reaction is None:
return None
line = f"reacted {emoticon(reaction.emoji)}"
if last.is_channel and not last.outbound:
name = split_channel_sender(last.text)[0]
return f"{name}: {line}" if name else line
return line
def _origin(name: str, key: str | None) -> Text:
"""The chip of the author of a preview: it closes on the point, as in the transcript."""
return author_chip(name, key)
def _append_author(text: Text, chip: Text, byline: Text) -> None:
"""Append the author of a preview: its origin ``chip`` where chips are drawn, else ``byline``.
The chip closes on the point, the same as the sender chip of the transcript, and the
body follows it after one space. The point says what the colon of ``Name:`` says, so
@@ -1080,7 +1138,7 @@ def _append_author(text: Text, hop: PathHop, byline: Text) -> None:
``byline`` (``Name: ``, or ``you: `` for us), as it always was (JP, 2026-10-10).
"""
if powerline_enabled():
text.append_text(hop_chip(hop, role=ORIGIN))
text.append_text(chip)
text.append(" ")
else:
text.append_text(byline)
@@ -1093,24 +1151,40 @@ def _byline(name: str, key: str | None) -> Text:
return text
def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> None:
def _append_body(
text: Text,
body: str,
key_of: NameKeyResolver,
us: tuple[str | None, str | None] = (None, None),
) -> None:
"""Append ``body`` to ``text``, muted, and light the names that it mentions.
Each ``@[Name]`` is drawn in the hue that comes from the key of that node, as a chip
that opens on the notch where the terminal draws chips (the same as in the transcript).
It takes the node grey when the name resolves to no node that we know.
that opens on the notch where the terminal draws chips (the same as in the transcript,
refer to :func:`~meshterm.ui.widgets.mention_chip`). It takes the node grey when the
name resolves to no node that we know. A mention of our own name (``us``) has the chip
of our key with a white label, as in the transcript.
"""
pos = 0
for match in MENTION.finditer(body):
if match.start() > pos:
text.append(body[pos : match.start()], style="muted")
name = match.group(1)
text.append_text(name_chip(f"@{name}", key_of(name), role=DESTINATION))
if us[0] and name == us[0]:
text.append_text(mention_chip(name, us[1], you=True))
else:
text.append_text(mention_chip(name, key_of(name)))
pos = match.end()
if pos < len(body):
text.append(body[pos:], style="muted")
def _names_us(last: ChatMessage, us: tuple[str | None, str | None]) -> bool:
"""Whether the message ``last`` has a mention of our name (``us``)."""
name = us[0]
return bool(name) and any(match.group(1) == name for match in MENTION.finditer(last.text))
def _ago(when: Any) -> str:
"""The column age for a message time, through the only grammar (``format_age``).
+4 -4
View File
@@ -31,7 +31,7 @@ from ..core.device_config import (
parse_value,
settings_by_category,
)
from ..core.privatekey import normalize, parse_private_key, read_private_key_file
from ..core.privatekey import checked, parse_private_key, read_private_key_file
from ..services.clock_sync import set_clock
from .base import Secret, Tool, ToolResult, register, reveal
@@ -320,9 +320,9 @@ async def apply_ops(
elif kind == "export_key":
report.append(await _export_key(ctx, device, op[1] if len(op) > 1 else None, artifacts))
elif kind == "import_key":
# The callers check the key before they confirm. This expands a seed that a
# caller gives as it is, because a firmware companion accepts only 64 bytes.
await device.import_private_key(normalize(bytes.fromhex(reveal(op[1]))).hex())
# The callers check the key before they confirm. The key goes on in the form
# that the user gave: each device takes the form that it keeps.
await device.import_private_key(checked(bytes.fromhex(reveal(op[1]))).hex())
if device.import_restarts:
ctx.ui.ack("[ok]✓[/ok] private key imported — the node uses it from its next start")
else:
+2 -2
View File
@@ -17,8 +17,8 @@ On the CLI, subcommands give script access to the same outbox:
- ``send`` forces one delivery try now.
- ``list``, ``cancel``, and ``clear`` examine the outbox and remove entries from it.
These subcommands are the script form of the Send-now, Cancel, and Clear-finished actions
of the outbox screen.
These subcommands are the script form of the actions of the outbox screen: Send now
(``^R``), Delete (``cancel`` deletes only a waiting entry), and Clear finished.
"""
from __future__ import annotations
+95 -7
View File
@@ -28,10 +28,18 @@ The tool asks the device, but the device is not necessary. A report about a radi
does not connect is the report that it is most necessary to file. Thus, when the tool
cannot get to the device, that failure becomes a fact in the block (``connected`` and
``error``), and not an error that replaces the block.
**Anonymized on request.** Some facts are about the machine of the user, and not about a
secret: the home folder in ``config_dir``, and the address of a radio or a host that an
``error`` can repeat. The ``anonymize`` preference masks them, and keeps a tidbit of each
one (refer to :mod:`meshterm.core.anonymize`). ``--anonymize`` and ``--no-anonymize``
override the preference for one run, and the page has a key that does the same. The
``anonymized`` fact tells the person who reads the report which form it is.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -43,6 +51,9 @@ from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Report
#: The fact that tells whether the personal details of the report are masked.
ANONYMIZED = "anonymized"
#: The name of the saved file, in the config directory, next to the log that it goes with
#: in a bug report. The name includes the app name, and is not only ``diagnostics.md``.
#: The reason: the only thing that occurs to this file is that a person moves it to a
@@ -63,13 +74,17 @@ class DiagnosticsTool(Tool):
order = 7 # after Preferences (the row that changes MeshTerm), and before the pages
def register_cli(self, app: Any) -> None:
"""Register the command, with the one option that changes the answer into a file.
"""Register the command, with the option that writes a file and the mask override.
``--out`` has the same spelling as ``config export-key --out``. We did not make a
second word to write an answer to a path. Plain redirection continues to work, and
the option does not replace it. The option adds a file whose columns have the full
width, and not the width that the terminal had at that time. That is the
difference between an attachment that a person can read and a folded one.
``--anonymize`` and ``--no-anonymize`` override the ``anonymize`` preference for
this run, in the way that ``--absolute`` overrides ``cli_time_format``. With
neither, the preference decides.
"""
import typer
@@ -80,8 +95,13 @@ class DiagnosticsTool(Tool):
out: Path | None = typer.Option(
None, "--out", help="Write the block to this file instead of printing it"
),
anonymize: bool | None = typer.Option(
None,
"--anonymize/--no-anonymize",
help="Mask the home folder and addresses (default: the anonymize preference)",
),
) -> None:
run_tool_command(self, {"out": out})
run_tool_command(self, {"out": out, "anonymize": anonymize})
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""Open the page in the menu. The return value ``None`` ends with no run row.
@@ -91,6 +111,10 @@ class DiagnosticsTool(Tool):
person looked at their own version number, the log gets a line for each look. A
save from the page is also not a run.
The page gets both forms of the document, so that its key can change from one to
the other with no second read of the device. It opens in the form that the
``anonymize`` preference gives.
Args:
ctx: The shared application context.
@@ -99,8 +123,15 @@ class DiagnosticsTool(Tool):
"""
from ..ui.diagnostics import open_diagnostics_page
report = await build_report(ctx)
await open_diagnostics_page(ctx, markdown_source(report), save_path=default_path(ctx))
report = await build_report(ctx, anonymize=False)
when = datetime.now().astimezone()
await open_diagnostics_page(
ctx,
markdown_source(report, when=when),
save_path=default_path(ctx),
anonymized_source=markdown_source(anonymized(report), when=when),
anonymized=_anonymizes(ctx),
)
return None
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
@@ -113,11 +144,13 @@ class DiagnosticsTool(Tool):
Args:
ctx: The shared application context.
params: ``out``: a destination path, or ``None`` to print the block.
``anonymize``: ``True`` or ``False`` to override the preference, or
``None`` to obey it.
Returns:
A :class:`ToolResult` that holds the block, or the path of the written file.
"""
report = await build_report(ctx)
report = await build_report(ctx, anonymize=params.get("anonymize"))
out = params.get("out")
if out is None:
return ToolResult(summary={"tool": self.name}, report=report)
@@ -131,7 +164,7 @@ class DiagnosticsTool(Tool):
)
async def build_report(ctx: AppContext) -> Report:
async def build_report(ctx: AppContext, *, anonymize: bool | None = None) -> Report:
"""Collect all the diagnostic facts, as the report that the two faces render.
Seven blocks, in the order that is most useful to the user: what this program is, the
@@ -147,11 +180,13 @@ async def build_report(ctx: AppContext) -> Report:
Args:
ctx: The shared application context.
anonymize: ``True`` to mask the personal details (refer to :func:`anonymized`),
``False`` to keep them, or ``None`` to obey the ``anonymize`` preference.
Returns:
The report of seven blocks.
"""
return (
report = (
_app_facts(),
_host_facts(),
_terminal_facts(),
@@ -160,6 +195,55 @@ async def build_report(ctx: AppContext) -> Report:
_table_listing(ctx),
_preference_listing(ctx),
)
masks = _anonymizes(ctx) if anonymize is None else anonymize
return anonymized(report) if masks else report
def anonymized(report: Report) -> Report:
"""The same report, with each personal detail masked and the ``anonymized`` fact on.
The mask goes through each text value of each block, and does not choose fields. A
field that someone adds later is masked on the day that it arrives, and nobody must
remember to add it to a list. A value keeps its type: a path stays a
:class:`~pathlib.Path`, so its lane renders it as before. Each value that is not text
(a count, a flag, an instant) stays as it is.
Args:
report: A report from :func:`build_report`.
Returns:
The masked report. ``report`` itself does not change.
"""
from ..core.anonymize import anonymize
from ..ui.report import Facts, Listing
def mask(value: Any) -> Any:
if isinstance(value, Path):
return Path(anonymize(str(value)))
if isinstance(value, str):
return anonymize(value)
return value
blocks: list[Any] = []
for block in report:
if isinstance(block, Facts):
values = {key: mask(value) for key, value in block.values.items()}
if ANONYMIZED in values:
values[ANONYMIZED] = True
blocks.append(replace(block, values=values))
elif isinstance(block, Listing):
rows = [{key: mask(value) for key, value in row.items()} for row in block.rows]
blocks.append(replace(block, rows=rows))
else:
blocks.append(block)
return tuple(blocks)
def _anonymizes(ctx: AppContext) -> bool:
"""Whether the ``anonymize`` preference asks for the masked report."""
from ..core.preferences import current
return bool((ctx.preferences or current()).anonymize)
def markdown_source(report: Report, *, when: datetime | None = None) -> str:
@@ -443,6 +527,7 @@ def _mesh_facts(ctx: AppContext) -> Any:
caption="Storage",
fields=(
fields.path("config_dir", "config_dir"),
fields.flag(ANONYMIZED, ANONYMIZED),
fields.integer("db_size_kb", "db_size_kb"),
fields.word("log_level", "log_level"),
fields.integer("log_size_kb", "log_size_kb"),
@@ -452,6 +537,9 @@ def _mesh_facts(ctx: AppContext) -> Any:
),
values={
"config_dir": ctx.settings.config_dir,
# Next to the value that it changes most. :func:`anonymized` sets it, so that a
# maintainer who reads `j*********u` knows that it is a mask and not a fault.
ANONYMIZED: False,
"db_size_kb": _size_kb(db_path),
"log_level": preferences.log_level,
# How much the log holds. Thus a maintainer who asks for it knows what to expect.
+1 -1
View File
@@ -190,7 +190,7 @@ async def _status_panel(device: Device, snapshot: dict) -> Panel:
body.append_text(value)
if not rows:
body = Text("no status reported", style="muted")
return Panel(body, title="[accent]Status[/accent]", border_style="accent", expand=False)
return Panel(body, title="[title.border]Status[/]", border_style="border", expand=False)
def _firmware(info: dict) -> str:
+594 -75
View File
File diff suppressed because it is too large. Load diff
+134
View File
@@ -0,0 +1,134 @@
# SPDX-License-Identifier: Apache-2.0
"""The compose line of a chat: a line editor that shows each mention as a chip.
The reply flow puts an ``@[Name]`` token in the compose line at the cursor (refer to
:meth:`~meshterm.ui.chat.ChatScreen._begin_reply`), and the user can also type one. When
the ``]`` completes a token, the line shows it as the chip of the node that it names: the
same chip as in the transcript (refer to :func:`~meshterm.ui.widgets.mention_chip`). After
that, the chip is one atom. The cursor moves over it in one step, and Backspace or Delete
removes all of the mention. The text of the message does not change. The line sends the
tokens as they are, and the byte gauge counts them.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import replace
from rich.text import Text
from .pathline import PathHop
from .tui.prompt import LineEditor
from .widgets import DESTINATION, hop_chip
#: A complete mention in the compose line. It is :data:`~meshterm.core.channels.MENTION`
#: with one more rule: the name has no ``[``. Thus an ``@[`` that the user types in front
#: of a chip does not join the chip into one long token, and the chip stays as it is.
ATOM = re.compile(r"@\[([^\[\]]{1,20})\]")
#: The actions that move the cursor to the left. A cursor that one of them puts inside a
#: chip goes to the start of the chip. Each other action puts it at the end.
_BACKWARD = frozenset({"left", "ctrl_left"})
class MentionEditor(LineEditor):
"""The line editor of a chat. Each complete ``@[Name]`` token in it is one atom.
The cursor is never inside an atom. ← and → go over an atom in one step, and a word
jump (^← and ^→) that stops inside one goes to its start or to its end. Backspace at
the end of an atom, and Delete at its start, remove all of it. A typed ``]`` that
completes a token makes it an atom immediately, with the cursor after it.
"""
def atoms(self) -> list[tuple[int, int, str]]:
"""The atoms of the line, from left to right, as ``(start, end, name)``."""
return [(m.start(), m.end(), m.group(1)) for m in ATOM.finditer(self.text)]
def insert(self, run: str) -> None:
"""Insert ``run`` at the cursor, as typed text, and put the cursor after it."""
self.edit("text", run)
def edit(self, action: str, data: str = "") -> bool:
"""Apply an edit action, with each atom as one character. Return ``True`` if it acted.
Args:
action: The normalized key action.
data: The typed character, or the pasted run.
Returns:
``True`` if the action was an edit action that this method handled.
"""
atoms = self.atoms()
if action == "backspace":
for start, end, _name in atoms:
if end == self.cursor:
return self._remove(start, end)
elif action == "delete":
for start, end, _name in atoms:
if start == self.cursor:
return self._remove(start, end)
elif action == "left":
for start, end, _name in atoms:
if end == self.cursor:
self.cursor = start
return True
elif action == "right":
for start, end, _name in atoms:
if start == self.cursor:
self.cursor = end
return True
if not super().edit(action, data):
return False
# The edit can complete a token around the cursor (a typed ``]``, or an ``@`` in
# front of ``[Name]``), and a word jump can stop at a space inside a name. Thus the
# cursor goes out of the atom, in the direction of the action.
for start, end, _name in self.atoms():
if start < self.cursor < end:
self.cursor = start if action in _BACKWARD else end
return True
def _remove(self, start: int, end: int) -> bool:
"""Remove the atom at ``[start, end)`` and put the cursor where it was."""
self.text = self.text[:start] + self.text[end:]
self.cursor = start
return True
def render_line(
self, hop_of: Callable[[str], PathHop], *, overflow_at: int | None = None
) -> Text:
"""Render the line, with each atom as the chip of the node that it names.
The text has the ``compose`` white, and the ``›`` has the ``cursor`` white. When the
cursor is at the start of an atom, the chip is the white chip of an insertion slot
(refer to :attr:`~meshterm.ui.pathline.PathHop.cursor`). It is the cursor block of
the line, as wide as the atom: Delete removes it all. A chip that goes past the byte
limit is red, the same as each character past the limit.
Args:
hop_of: Makes the hop of a mention from its name (refer to
:func:`~meshterm.ui.widgets.mention_hop`).
overflow_at: The index of the first character past the byte limit, or ``None``.
Returns:
The line as one :class:`Text`. Each chip in it has the
:data:`~meshterm.ui.pathline.PATH_INK` span, so that a wrap can keep it whole.
"""
# The style goes on the ``›`` alone. The base style of a Text applies to all of the
# line, and the typed text took the colour of the ``›`` when it was there.
text = Text()
text.append("› ", style="cursor")
pos = 0
for start, end, name in self.atoms():
self._append_run(text, self.text, pos, start, overflow_at, "compose")
hop = hop_of(name)
if self.cursor == start:
hop = replace(hop, cursor=True, white_ink=False)
elif overflow_at is not None and end > overflow_at:
hop = replace(hop, style="err", white_ink=False)
text.append_text(hop_chip(hop, role=DESTINATION))
pos = end
self._append_run(text, self.text, pos, len(self.text), overflow_at, "compose")
if self.cursor >= len(self.text): # the cursor after the last character
text.append(" ", style="reverse")
return text
+5 -13
View File
@@ -38,7 +38,7 @@ from typing import Any
from rich.cells import cell_len
from rich.text import Text
from .marks import SELF_MARK
from .marks import LOCK_ICON, SELF_MARK
from .menus import fit_cells
from .theme import glyph, name_style
from .tui.select import Choice, SelectScreen, Separator
@@ -134,14 +134,6 @@ TRACE_LANES: tuple[ContactLane, ...] = (TRACED_LANE, HEARD_LANE, PKTS_LANE)
#: what the contact still transmits. It is about when the contact left.
ARCHIVED_LANES: tuple[ContactLane, ...] = (ARCHIVED_LANE,)
#: The padlock at the start of the row of a locked contact (refer to
#: :attr:`ContactRow.locked`). It is the private-channel ``🔒`` of the icon lexicon, because
#: it means the same thing (this item is closed to something). On the PicoCalc,
#: :func:`~meshterm.ui.theme.glyph` gives the hand-drawn padlock ``⚿`` of the font. An
#: unlocked contact draws nothing. The mark is an exception that the user must notice, and a
#: lane of open padlocks makes the few closed padlocks hard to see.
_LOCK_ICON = "🔒"
def _lock_lane_width(rows: list[ContactRow]) -> int:
"""The width of the lock column in cells: the platform padlock and its gap, or ``0``.
@@ -152,7 +144,7 @@ def _lock_lane_width(rows: list[ContactRow]) -> int:
"""
if not any(row.locked for row in rows):
return 0
return cell_len(glyph(_LOCK_ICON)) + 1
return cell_len(glyph(LOCK_ICON)) + 1
def _lock_cell(row: ContactRow, lock_w: int) -> str:
@@ -161,7 +153,7 @@ def _lock_cell(row: ContactRow, lock_w: int) -> str:
return ""
if not row.locked:
return " " * lock_w
mark = glyph(_LOCK_ICON)
mark = glyph(LOCK_ICON)
return mark + " " * (lock_w - cell_len(mark))
@@ -244,7 +236,7 @@ class ContactRow:
a muted ``?``.
node_type: The node type of the contact, for the leading glyph (``None`` = the
plain node ``●``). The row of our node ignores it, and always starts with the
yellow ``★``.
white ``★``.
last_seen: When MeshTerm last heard the contact (aware UTC). It fills the heard
lane, in the colour of the recency heat. ``None`` shows ``never`` in the cold
style.
@@ -361,7 +353,7 @@ def _you_lane(
"""The lane of our node, with the same layout as the usual rows of :func:`_lane`.
It is drawn as the map and the static table draw our node: the ``★`` self marker
(yellow), the name in the pure-white ``you`` style with a muted ``(you)`` tag, and our
(white), the name in the pure-white ``you`` style with a muted ``(you)`` tag, and our
key with its hash lit at the routing width, in the flexible key lane. Our 64-hex key is
longer than any lane, so it shows as many digits as fit, and then an ellipsis. **Each
middle lane shows a faint ``—``**, whatever lanes the list declares. MeshTerm never
+102 -21
View File
@@ -9,7 +9,8 @@ type-to-filter. Thus the whole app has one contact list, for each source of data
highlighted node (:mod:`~meshterm.ui.node_detail_screen`). That page shows the identity of
the node, a preview of its location, the routes that MeshTerm observed to it, and the ways
to go further (trace, map, Time Machine). When the user backs out of that page, the list is
as it was. ^L opens the map on the highlighted node, when it has a location. The one-shot
as it was. ^L opens the map on the highlighted node, when it has a location. ^K locks the
highlighted contact against the archive sweep, or unlocks it. The one-shot
CLI (``meshterm contacts --sort …``) still renders the static
:func:`~meshterm.ui.widgets.contacts_table`. Only the menu gets the live list.
@@ -44,7 +45,7 @@ from typing import TYPE_CHECKING
from .archived_screen import open_archived
from .contactlist import ContactListScreen, ContactRow
from .menus import icon_lane, marked_label
from .menus import icon_lane, marked_label, splice_if_room
from .sweep_screen import archive_contacts
from .tui import Choice, KeyRequest, Separator
from .tui.screen import CANCEL
@@ -79,32 +80,31 @@ class ContactsScreen(ContactListScreen):
Enter resolves the highlighted row, our node (:data:`YOU`) or a
:class:`~meshterm.core.models.Contact`, so that :func:`open_contacts` opens it in the
Node detail page. ^L resolves a :class:`~meshterm.ui.tui.select.KeyRequest` with the
marker of the highlighted node, so that :func:`open_contacts` opens the map on it. The
shared list handles the sort, the filter, and the navigation.
marker of the highlighted node, so that :func:`open_contacts` opens the map on it. ^K
resolves one with the highlighted contact, so that :func:`open_contacts` toggles its
lock. The shared list handles the sort, the filter, and the navigation.
"""
@property
def picocalc_lyra_lane(self):
"""The lane of the contact list, with ``Map`` (^L) on the Shift half of F1.
"""The lane of the contact list, with ``Map`` (^L) and ``Lock`` (^K) on the Shift bank.
This is the key of ``Map`` in the chat too. F1 itself keeps ``Sect ↑`` when the
list has sections (the contacts, and the archive rows under them), and it stays
empty when the list has none. ``Map`` is lit while the highlighted node has a
location.
``Map`` is on the Shift half of F1. F1 itself keeps ``Sect ↑`` when the list has
sections (the contacts, and the archive rows under them), and it stays empty when
the list has none. ``Map`` is lit while the highlighted node has a location.
``Lock`` is on the Shift half of F2, behind ``Sect ↓``: the same key as in a channel
and a DM. It toggles the lock of the highlighted contact, so it reads ``Unlock`` on
a locked contact. It is lit on a contact that can have a lock, and dim on our node
and on the rows of the tail. It is empty where MeshTerm cannot store a lock.
"""
from .tui.fkeys import FPair
lane = list(super().picocalc_lyra_lane)
if self._locator is not None:
sect = lane[0]
lane[0] = FPair(
sect.label if sect else "",
sect.action if sect else "",
"Map",
"locate",
enabled=sect.enabled if sect else False,
opp_enabled=self._highlighted_marker() is not None,
)
lane[0] = _with_opp(lane[0], "Map", "locate", self._highlighted_marker() is not None)
if self._can_lock:
target = self._lock_target()
label = "Unlock" if target is not None and target[1] else "Lock"
lane[1] = _with_opp(lane[1], label, "lock", target is not None)
return lane
def __init__(
@@ -118,6 +118,7 @@ class ContactsScreen(ContactListScreen):
archived: int = 0,
locked: frozenset[str] = frozenset(),
locator: Locator | None = None,
can_lock: bool = False,
) -> None:
"""Create the contacts screen over contact data that MeshTerm already got.
@@ -142,9 +143,13 @@ class ContactsScreen(ContactListScreen):
archiving. Each of those rows ends its name lane with a padlock.
locator: The located nodes, for ^L: the map on the highlighted node (refer to
:class:`~meshterm.ui.map_screen.Locator`). With ``None``, ^L does nothing.
can_lock: Whether the caller can store a lock, for ^K. With ``False``, ^K does
nothing.
"""
self._self_name, self._self_key = self_name, self_key
self._locator = locator
self._can_lock = can_lock
self._locked = locked
self._contacts, self._counts = contacts, counts
rows = self._contact_rows_for(locked)
# The maintenance actions close the list, after each contact, for each sort. First
@@ -224,13 +229,46 @@ class ContactsScreen(ContactListScreen):
current = self._current_choice()
return self._marker_of(current.value) if current is not None else None
def _lock_target(self) -> tuple[Contact, bool] | None:
"""The contact that ^K locks or unlocks, and whether it is locked now.
``None`` when ^K does nothing: on our node, on a row of the tail, on a contact with
no full key (the store attaches a lock to the full key), or when the caller cannot
store a lock.
"""
current = self._current_choice()
if not self._can_lock or current is None:
return None
contact = current.value
key = (getattr(contact, "public_key", None) or "").lower().removeprefix("0x")
if contact == YOU or len(key) != 64:
return None
return contact, key in self._locked
@property
def footer_hint(self) -> str:
"""The hint of the list, with ``^K lock`` or ``^K unlock`` where it has room for it."""
hint = super().footer_hint
target = self._lock_target()
if target is None:
return hint
return splice_if_room(hint, "^K unlock" if target[1] else "^K lock")
def handle(self, action: str, data: str = "") -> None:
"""^L asks :func:`open_contacts` to open the map. The list handles all other actions."""
"""^L asks :func:`open_contacts` to open the map, and ^K to toggle the lock.
The list handles all other actions.
"""
if action == "locate":
marker = self._highlighted_marker()
if marker is not None:
self.resolve(KeyRequest("locate", marker))
return
if action == "lock":
target = self._lock_target()
if target is not None:
self.resolve(KeyRequest("lock", target[0]))
return
super().handle(action, data)
def refresh_locks(self, locked: frozenset[str]) -> None:
@@ -241,9 +279,28 @@ class ContactsScreen(ContactListScreen):
new screen. The highlight, the sort, the scroll, and a typed filter all stay where
they were.
"""
self._locked = locked
self.update_rows(self._contact_rows_for(locked))
def _with_opp(slot, label: str, action: str, enabled: bool): # noqa: ANN001, ANN202 - an FPair
"""``slot`` (an F-key slot, or ``None``) with ``label`` and ``action`` as its Shift half.
The plain half stays as the list gave it: ``Sect ↑`` or ``Sect ↓`` when the list has
sections, or an empty half when it has none.
"""
from .tui.fkeys import FPair
return FPair(
slot.label if slot else "",
slot.action if slot else "",
label,
action,
enabled=slot.enabled if slot else False,
opp_enabled=enabled,
)
async def open_contacts(
ctx: AppContext,
self_name: str,
@@ -302,6 +359,7 @@ async def open_contacts(
archived=_archived_count(ctx, self_key),
locked=_locked_keys(ctx, self_key),
locator=locator,
can_lock=_can_lock(ctx, self_key),
)
screen = build()
@@ -312,6 +370,13 @@ async def open_contacts(
chosen = await visit.result()
if chosen is CANCEL or chosen is None: # Esc
return
if isinstance(chosen, KeyRequest) and chosen.action == "lock":
# ^K: no confirm and no notice, as on the node page. The next ^K
# reverses it, and the padlock that the row gains or loses is the
# acknowledgement. The highlight, the sort, and the filter stay.
_toggle_lock(ctx, self_key, chosen.value)
screen.refresh_locks(_locked_keys(ctx, self_key))
continue
if isinstance(chosen, KeyRequest):
# ^L: the map goes on the stack above the list, and Esc on the map comes
# back to the same row.
@@ -327,6 +392,9 @@ async def open_contacts(
if await archive_contacts(ctx, self_key):
rebuild = True
break
# A sweep can lock contacts and archive none. Then the rows stay, and
# only their padlocks are drawn again.
screen.refresh_locks(_locked_keys(ctx, self_key))
continue
if chosen == _ARCHIVED:
# The archived list is a screen and not a section. It nests above this
@@ -364,6 +432,19 @@ def _archived_count(ctx: AppContext, self_key: str) -> int:
return len(store.archived(dev_pub))
def _can_lock(ctx: AppContext, self_key: str) -> bool:
"""Whether a lock can be stored on this device: a contact store, and the key of the device."""
store = getattr(ctx, "contact_store", None)
return store is not None and bool((self_key or "").lower().removeprefix("0x"))
def _toggle_lock(ctx: AppContext, self_key: str, contact: Contact) -> None:
"""Lock ``contact`` on this device, or unlock it when it is locked (^K)."""
store = ctx.contact_store
dev_pub = self_key.lower().removeprefix("0x")
store.set_locked(dev_pub, contact, not store.is_locked(dev_pub, contact.public_key))
def _locked_keys(ctx: AppContext, self_key: str) -> frozenset[str]:
"""The keys of the contacts locked on this device. These rows draw a padlock."""
store = getattr(ctx, "contact_store", None)
+377 -118
View File
@@ -4,13 +4,17 @@
The interactive face of the ``courier`` tool. One select-list screen holds the whole
feature (the persistent-backdrop pattern). It has these parts:
- The waiting outbox. Each entry shows its live state: it waits to hear the contact, it is
scheduled for a time, or it backs off between retries.
- The finished history (delivered or given up).
- The waiting outbox. Each row shows its target (the recipient), its trigger (what the
entry waits for before it goes out), and its message. It waits to hear the contact, it
is scheduled for a time, or it backs off between retries.
- The finished history (delivered or given up), with the outcome of each entry.
- The queueing flow: select a contact, write the message, choose when to send it.
Enter on a waiting entry offers *Send now* (one forced attempt, and the outcome is in a
dialog) and *Cancel*.
The entries are a table that ←→ pan as one unit, under the column headers. Nothing in a
row is shortened: a long name or a long message is all there, and the pan brings it into
view. Enter on a waiting entry opens the message to edit, and Enter on a finished entry
opens it to read. ``^R`` on a waiting entry is *Send now* (one forced attempt, and the
outcome is in a dialog). Delete removes an entry of either section, after a confirm.
The outbox is **live** while it is open. Each row is a callable title that the code
computes again at each paint, so "retry in ~N m" counts down in real time. A ticker
@@ -28,6 +32,7 @@ from __future__ import annotations
import asyncio
import re
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
@@ -37,9 +42,16 @@ from rich.text import Text
from ..core.courier_store import DELIVERED, QUEUED, QueuedMessage
from ..core.models import Contact, is_direct_messageable, utcnow
from .contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING, ContactListScreen, ContactRow
from .menus import icon_lane, marked_label, run_steps, section_heading
from .menus import (
Lane,
column_header,
icon_lane,
marked_label,
run_steps,
section_heading,
)
from .pathline import PathHop
from .tui import CANCEL, DM_BYTE_LIMIT, Choice, SelectScreen, Separator
from .tui import CANCEL, DM_BYTE_LIMIT, Choice, DeleteRequest, SelectScreen, Separator
from .watchtower_screen import contact_watch_key
from .widgets import (
DESTINATION,
@@ -58,6 +70,9 @@ if TYPE_CHECKING:
_QUEUE = ("queue",)
_CLEAR = ("clear",)
#: The first item of the value with which ``^R`` resolves the outbox: ``("send", ident)``.
_SEND = "send"
#: The status marks that an outbox entry starts with: waiting, delivered, gave up. They are
#: the outcome of the row and not decoration. Thus, unlike the icon of a command row, they
#: are drawn on each platform. The code measures them raw, because the render fold of the
@@ -75,14 +90,19 @@ _COMMAND_ICONS = ("📨", "🗑")
#: The seconds between the refresh ticks of the open outbox (shape check and paint).
_REFRESH_S = 1.0
#: The maximum width at which a message body renders in a row, before the row gets an
#: ellipsis.
_TEXT_W = 36
#: The cells between two lanes of the table.
_GAP = 2
#: How many finished entries the history section shows. The store keeps more (refer to the
#: ``courier_history_kept`` preference).
_DONE_SHOWN = 15
def _shorten(text: str, width: int = _TEXT_W) -> str:
"""The message body, shortened for the display in a row."""
return text if len(text) <= width else text[: width - 1] + "…"
#: The footer of the outbox. :meth:`CourierOutboxScreen._key_atoms` adds the atoms of the
#: keys that act on an entry, only while the highlight is on such an entry. The select
#: list adds ``←→ scroll`` while the table is wider than the screen.
_OUTBOX_HINT = "↑↓ move · type to filter · Enter open · Esc back"
_SEND_HINT = "^R send"
_DELETE_HINT = "Del delete"
def _local_stamp(when: datetime) -> str:
@@ -150,7 +170,7 @@ async def open_courier(ctx: AppContext) -> dict[str, Any] | None:
# how to compose its sections again around the highlight
# (:meth:`CourierOutboxScreen.refresh`). The ticker calls this method once each second
# while the list is open. The code runs the screen through a visit. Thus each sub-flow
# (queue a message, the actions of an entry, the clear confirm) floats over the list and
# (queue a message, the message of an entry, a send, a confirm) floats over the list and
# returns to the row from which it opened. Also, the code starts the ticker one time,
# and not for each round.
menu = CourierOutboxScreen(ctx)
@@ -182,8 +202,12 @@ async def open_courier(ctx: AppContext) -> dict[str, Any] | None:
destructive=True,
):
store.clear_done()
elif isinstance(choice, DeleteRequest):
await _delete_entry(ctx, int(choice.value[1]))
elif isinstance(choice, tuple) and choice[0] == _SEND:
await _send_now(ctx, int(choice[1]))
elif isinstance(choice, tuple) and choice[0] == "msg":
await _entry_actions(ctx, int(choice[1]))
await _open_entry(ctx, int(choice[1]))
# Take in the effect of the flow at once, and do not wait for a tick. Thus
# the list to which the user returns already shows what the user did.
menu.refresh()
@@ -212,8 +236,23 @@ class CourierOutboxScreen(SelectScreen):
store. It composes the sections again in place only when the fingerprint changed, and
the highlight stays on its entry (the rebuild idiom of
:class:`~meshterm.ui.contactlist.ContactListScreen`).
Three keys act on the highlighted entry. Enter opens its message (refer to
:func:`_open_entry`), Delete asks to remove it, and ``^R`` sends a waiting entry now.
The footer and the F-key lane name ``^R`` and Delete only on the rows on which they act.
``^R`` is the chord that the app uses for "send again" in each conversation, and a
chord, because this list filters as the user types.
On an entry row that pans, the full footer is wider than the 72 cells of a terminal.
That is a known exception to the limit (JP, 2026-10-22: "bring the filter back, tough
luck if it doesn't fit in the footer"). The hint line then removes ``Del delete``
first (:attr:`spare_hint_atoms`, refer to :func:`~meshterm.ui.tui.frame.fitted_footer`),
because the user finds the Delete key without a hint, and keeps Esc.
"""
#: The atoms that the hint line removes first when the footer is too wide.
spare_hint_atoms = (_DELETE_HINT,)
def __init__(self, ctx: AppContext, *, default: Any = None) -> None:
"""Open the outbox on the current entries of the store, and keep their shape.
@@ -226,9 +265,69 @@ class CourierOutboxScreen(SelectScreen):
"Courier — store-and-forward outbox",
_menu_items(ctx, ctx.courier_store.entries()),
default=default,
footer_hint="↑↓ move · Enter select · Esc back",
footer_hint=_OUTBOX_HINT,
key_hint=self._key_atoms,
)
def _waiting(self, value: Any) -> int | None:
"""The id of the entry that ``value`` names, if that entry waits in the outbox."""
ident = _entry_ident(value)
if ident is None:
return None
message = self._ctx.courier_store.get(ident)
return ident if message is not None and message.status == QUEUED else None
def _highlighted_waiting(self) -> int | None:
"""The id of the highlighted entry, if it waits in the outbox."""
current = self._current_choice()
return self._waiting(current.value) if current is not None else None
def _key_atoms(self, value: Any) -> str:
"""The footer atoms of the keys that act on the row of ``value``.
``^R`` acts only on a waiting entry, because a finished entry has left the outbox.
Delete acts on each entry, and on no command row.
"""
atoms = []
if self._waiting(value) is not None:
atoms.append(_SEND_HINT)
if _entry_ident(value) is not None:
atoms.append(_DELETE_HINT)
return " · ".join(atoms)
@property
def picocalc_lyra_lane(self):
"""The lane of the list, with ``Send`` on F3 and ``Delete`` behind it.
F1/F2 are the section jumps, and F4/F5 are the pager. F3 is the slot that a select
list uses for its own verb. Send and Delete have the same subject (the highlighted
entry). Thus they are the two halves of that slot, as Read and Remove are in the
region editor. Each is dim where it does not act. On a command row, both are dim,
not empty: the two actions exist on this screen, but not for that row.
"""
from .tui.fkeys import FPair
lane = list(super().picocalc_lyra_lane)
current = self._current_choice()
lane[2] = FPair(
"Send",
"retry",
opp_label="Delete",
opp_action="delete",
enabled=self._highlighted_waiting() is not None,
opp_enabled=current is not None and current.deletable,
)
return lane
def handle(self, action: str, data: str = "") -> None:
"""Send the highlighted waiting entry now on ``^R``. Other keys act as in each list."""
if action == "retry":
ident = self._highlighted_waiting()
if ident is not None:
self.resolve((_SEND, ident))
return
super().handle(action, data)
def _fingerprint(self) -> tuple:
"""The shape of the store: which entries exist and the status of each."""
return tuple((m.ident, m.status) for m in self._ctx.courier_store.entries())
@@ -254,14 +353,29 @@ class CourierOutboxScreen(SelectScreen):
self._index = max(0, min(self._index, len(choices) - 1)) if choices else 0
def _entry_ident(value: Any) -> int | None:
"""The id of the entry that a row value names (``("msg", ident)``), or ``None``."""
if isinstance(value, tuple) and len(value) == 2 and value[0] == "msg":
return int(value[1])
return None
def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
"""Build the rows of the screen: the waiting outbox, then the finished history.
The entry rows are callables with no argument, so that their live state renders again at
each paint. The fixed action rows stay plain strings.
Each section is a table under its own column header: the status mark, the TARGET (the
recipient), a second lane, and the MESSAGE. In the outbox, the second lane is the
TRIGGER: what the entry waits for before it goes out. In the history, it is the
OUTCOME. The rows and the headers pan as one unit (:attr:`Choice.pans`), so each lane
is at its full width and nothing is shortened. The two command rows do not pan.
The entry rows are callables with no argument, so that their live state renders again
at each paint. The second lane is as wide as its widest text at that paint (refer to
``second_w`` below), in both sections. Thus MESSAGE starts in the same cell in each
section, and it moves when a countdown changes its number of digits.
"""
waiting = [m for m in entries if m.status == QUEUED]
done = [m for m in entries if m.status != QUEUED]
done = [m for m in entries if m.status != QUEUED][:_DONE_SHOWN]
# One icon column for the whole list, across both sections. The terminal draws ⏳ and
# 📨 in two cells, but ✓ ✗ 🗑 in one cell. Thus rows that were written as
@@ -271,25 +385,82 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
# command icons go where the platform draws no icon lane, and marked_label leaves those
# two labels bare. It does not pad them for a mark that is not there.
lane = max(icon_lane(_COMMAND_ICONS), _MARK_LANE)
# One TARGET lane for both sections, so that the lane after it starts in the same cell
# in each. It is never narrower than its label, so the label never runs into the next.
target_w = max([cell_len("TARGET")] + [_target_chip(m).cell_len for m in waiting + done])
def second_w() -> int:
"""The width of the second lane at this paint: its widest label, trigger, or outcome.
The rows hold the live records of the store, so a trigger that changes (a
countdown, a contact that was heard) gives its new width at the next paint.
"""
cells = [cell_len("TRIGGER"), cell_len("OUTCOME")]
cells += [_trigger(ctx, m).cell_len for m in waiting]
cells += [cell_len(_outcome(m)) for m in done]
return max(cells)
items: list = [section_heading("Outbox")]
if not waiting:
if waiting:
items.append(_column_header("TRIGGER", lane, target_w, second_w))
else:
items.append(Separator(" empty — queued messages wait here for their moment"))
for message in waiting:
items.append(Choice(lambda m=message: _waiting_row(ctx, m, lane), ("msg", message.ident)))
items.append(
Choice(
lambda m=message: _waiting_row(ctx, m, lane, target_w, second_w()),
("msg", message.ident),
deletable=True,
pans=True,
)
)
items.append(Separator(" ")) # put space between the action and the outbox rows above it
items.append(Choice(marked_label("📨", "Queue a message…", "", lane=lane), _QUEUE))
if done:
items.append(Separator(" "))
items.append(section_heading("Finished"))
for message in done[:15]:
items.append(Choice(lambda m=message: _done_row(m, lane), ("msg", message.ident)))
items.append(_column_header("OUTCOME", lane, target_w, second_w))
for message in done:
items.append(
Choice(
lambda m=message: _done_row(m, lane, target_w, second_w()),
("msg", message.ident),
deletable=True,
pans=True,
)
)
items.append(Choice(marked_label("🗑", "Clear finished", "", lane=lane), _CLEAR))
return items
def _column_header(label: str, lane: int, target_w: int, second_w: Callable[[], int]) -> Separator:
"""The column header of one section: ``TARGET``, then ``label``, then ``MESSAGE``.
It comes immediately after the heading of its section. Thus it pins with the heading
while the rows of the section scroll. It starts after the pointer, the mark, and the
space after the mark, so that ``TARGET`` is over the first cell of each chip. It pans
with the rows, so each label stays over its lane.
Args:
label: The name of the second lane: ``TRIGGER`` in the outbox, ``OUTCOME`` in the
history.
lane: The icon column of the list in cells (refer to :func:`_menu_items`).
target_w: The width of the widest chip in cells, without the gap after it.
second_w: Gives the width of the second lane at this paint, without the gap.
"""
def header(width: int) -> str:
lanes = [
Lane("TARGET", target_w + _GAP),
Lane(label, second_w() + _GAP),
Lane("MESSAGE"),
]
return column_header(lanes, width, indent=2 + lane + 1)
return Separator(header, pans=True)
def _entry_mark(mark: str, style: str, lane: int) -> Text:
"""The status mark of an entry, with a tint, and padded to the icon column of the outbox.
@@ -303,62 +474,108 @@ def _entry_mark(mark: str, style: str, lane: int) -> Text:
lane: The icon column of the list in cells (refer to :func:`_menu_items`).
Returns:
The mark, followed by enough spaces to start the words in the column after it.
The mark, followed by enough spaces to start the words in the column after it. The
style is a span on the mark only. As the base style of the row, it made each lane
after the mark bold.
"""
text = Text(mark, style=style)
text = Text()
text.append(mark, style=style)
text.append(" " * (lane - cell_len(mark) + 1))
return text
def _waiting_row(ctx: AppContext, message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
"""One waiting entry: the recipient, the body, and what the entry waits for.
def _target_chip(message: QueuedMessage) -> Text:
"""The TARGET of an entry: its recipient, as a destination chip.
The recipient is the destination of the message, so it is a chip that opens on the
notch (refer to :func:`~meshterm.ui.widgets.hop_chip`), in the hue of its key.
The recipient is the destination of the message, so the chip opens on the notch (refer
to :func:`~meshterm.ui.widgets.hop_chip`). A waiting recipient has the hue of its key. A
finished entry is history, and all of its row is muted. Thus its chip is muted too.
"""
if message.status == QUEUED:
return name_chip(message.node_name, message.node_key, role=DESTINATION)
return hop_chip(PathHop(message.node_name, style="muted"), role=DESTINATION)
def _row(
mark: Text,
chip: Text,
target_w: int,
second: Text,
second_w: int,
message: QueuedMessage,
style: str,
) -> Text:
"""One entry row: the mark, the chip, the second lane, and the full message.
Each lane is padded to its width, and nothing is shortened. The pan of the list brings
a part that is past the edge into view. A line break in the message becomes a space,
because a row is one line.
"""
mark.append_text(chip)
mark.append(" " * (max(0, target_w - chip.cell_len) + _GAP))
mark.append_text(second)
mark.append(" " * (max(0, second_w - second.cell_len) + _GAP))
mark.append(" ".join(message.text.splitlines()), style=style)
return mark
def _trigger(ctx: AppContext, message: QueuedMessage) -> Text:
"""The TRIGGER of a waiting entry: what it waits for before it goes out.
The rules are those of :meth:`~meshterm.services.courier.CourierService.eligible`, in
the same order: a schedule, then the backoff after a failed try, then a sign of life.
"""
row = _entry_mark("⏳", "warn", lane)
row.append_text(name_chip(message.node_name, message.node_key, role=DESTINATION))
row.append(f" “{_shorten(message.text)}”", style="muted")
row.append(" · ", style="muted")
now = utcnow()
if message.not_before is not None and now < message.not_before:
row.append(f"scheduled {_local_stamp(message.not_before)}", style="brand")
else:
retry = ctx.courier.next_retry_s(message, now)
if retry is not None:
row.append(
f"try {message.attempts} · retry in ~{max(1, round(retry / 60))} m",
style="warn",
)
elif message.attempts > 0:
row.append(f"try {message.attempts} · waiting to hear it again", style="muted")
elif ctx.courier.heard_recently(message.node_key, now):
row.append("contact is fresh — next pass", style="ok")
else:
row.append("waiting to hear the contact", style="muted")
return row
return Text(f"at {_local_stamp(message.not_before)}", style="brand")
retry = ctx.courier.next_retry_s(message, now)
if retry is not None:
minutes = max(1, round(retry / 60))
return Text(f"retry in ~{minutes} m · try {message.attempts}", style="warn")
if message.attempts > 0:
return Text(f"when heard again · try {message.attempts}", style="muted")
if ctx.courier.heard_recently(message.node_key, now):
return Text("next pass — contact is fresh", style="ok")
return Text("when heard", style="muted")
def _done_row(message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
"""One finished entry: the outcome mark, the recipient, the body, and when it ended.
The recipient is a destination chip, the same as in the outbox. But a finished entry is
history, and all of its row is muted. Thus the chip is muted too.
"""
if message.status == DELIVERED:
row = _entry_mark("✓", "ok", lane)
else:
row = _entry_mark("✗", "err", lane)
row.append_text(hop_chip(PathHop(message.node_name, style="muted"), role=DESTINATION))
row.append(f" “{_shorten(message.text)}”", style="muted")
def _outcome(message: QueuedMessage) -> str:
"""The OUTCOME of a finished entry: how it ended, when, and after how many tries."""
when = message.finished or message.created
verb = "delivered" if message.status == DELIVERED else "gave up"
row.append(
f" · {verb} {format_ago(age_seconds(when))}"
f" · {message.attempts} attempt{'s' if message.attempts != 1 else ''}",
style="muted",
)
return row
tries = f"{message.attempts} attempt{'s' if message.attempts != 1 else ''}"
return f"{verb} {format_ago(age_seconds(when))} · {tries}"
def _waiting_row(
ctx: AppContext,
message: QueuedMessage,
lane: int = _MARK_LANE,
target_w: int = 0,
second_w: int = 0,
) -> Text:
"""One waiting entry: the mark, the TARGET, the TRIGGER, and the MESSAGE.
The message has the colour of plain text, because it is the content of the row.
"""
mark = _entry_mark("⏳", "warn", lane)
trigger = _trigger(ctx, message)
return _row(mark, _target_chip(message), target_w, trigger, second_w, message, "")
def _done_row(
message: QueuedMessage, lane: int = _MARK_LANE, target_w: int = 0, second_w: int = 0
) -> Text:
"""One finished entry: the outcome mark, the TARGET, the OUTCOME, and the MESSAGE.
A finished entry is history, so each lane after the mark is muted.
"""
if message.status == DELIVERED:
mark = _entry_mark("✓", "ok", lane)
else:
mark = _entry_mark("✗", "err", lane)
outcome = Text(_outcome(message), style="muted")
return _row(mark, _target_chip(message), target_w, outcome, second_w, message, "muted")
# --- the flows --------------------------------------------------------------------------
@@ -368,6 +585,10 @@ def _done_row(message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
#: an Enter that commits. Esc cancels the queueing step in which the picker is.
_PICK_HINT = "↑↓ move · ^←→↑↓ sort · type to filter · Enter select · Esc cancel"
#: The instruction above the message field: the same when the user writes the message and
#: when the user edits it.
_MESSAGE_PROMPT = "Delivered as a normal direct message when its moment comes."
class CourierRecipientScreen(ContactListScreen):
"""The recipient picker on the shared contact list.
@@ -468,8 +689,9 @@ async def _queue_flow(ctx: AppContext, contacts: list[Contact]) -> None:
lambda vals: _next_recipient(visit),
lambda vals: session.text(
f"Message for {vals[0].name}",
prompt="Delivered as a normal direct message when its moment comes.",
prompt=_MESSAGE_PROMPT,
byte_limit=DM_BYTE_LIMIT,
compose=True,
default=vals[1] or "",
),
lambda vals: _schedule_step(ctx, vals[0].name),
@@ -570,60 +792,97 @@ async def _pick_schedule(ctx: AppContext, name: str):
return when
async def _entry_actions(ctx: AppContext, ident: int) -> None:
"""Float the action menu of one entry: send now, cancel, or only look at it."""
def _not_blank(text: str) -> bool | str:
"""The validator of an edited message: an empty message is refused, not sent."""
return True if text.strip() else "The message is empty — Esc keeps it as it was."
async def _open_entry(ctx: AppContext, ident: int) -> None:
"""Open the message of one entry: to edit while it waits, to read after it finished.
The edit holds the entry (refer to
:meth:`~meshterm.services.courier.CourierService.holding`). Without the hold, a pass
can send the old text while the prompt is open. A try that is already in progress
cannot be stopped, so the message then opens to read, with a note.
"""
session = ctx.ui.session
store = ctx.courier_store
message = store.get(ident)
if message is None:
return
if message.status != QUEUED:
# A finished entry has no actions. Show its full text instead.
title = f"Message for {message.node_name}"
if message.status != QUEUED or ctx.courier.in_flight(ident):
body = Text(message.text)
body.append(
f"\n\n{message.status} · {message.attempts} attempt"
f"{'s' if message.attempts != 1 else ''}",
style="muted",
)
await session.message_dialog(body, title=message.node_name)
if message.status != QUEUED:
body.append(f"\n\n{_outcome(message)}", style="muted")
else:
body.append("\n\nsending now — edit it after this attempt", style="muted")
await session.message_dialog(body, title=title)
return
# One measured column for both rows. 📤 draws two cells and ✗ draws one cell. The Cancel
# row measured only its own mark, so its words started one column to the left of the
# words of Send now. Where the platform draws no icon lane, both rows have no icon, and
# the err tint of Cancel moves onto its words.
lane = icon_lane(("📤", "✗"))
items = [
Choice(marked_label("📤", "Send now — one forced attempt", "", lane=lane), "send"),
Choice(marked_label("✗", "Cancel this message", "err", lane=lane), "cancel"),
]
picked = await session.select(
f"{message.node_name} — “{_shorten(message.text, 28)}”",
items,
filterable=False,
footer_hint="↑↓ move · Enter select · Esc back",
)
if picked == "cancel":
store.cancel(ident)
elif picked == "send":
try:
async with ctx.ui.busy_overlay(f"sending to {message.node_name}…"):
outcome = await ctx.courier.attempt_now(ident)
except Exception as exc: # noqa: BLE001 - show the failure, and keep the queue
await session.message_dialog(Text(f"send failed: {exc}", style="err"), title="Courier")
return
notes = {
"delivered": Text("✓ delivered — acknowledged by the contact", style="ok"),
"no ack": Text(
"sent, but no acknowledgement — it stays queued and the courier "
"will retry with backoff",
style="warn",
),
"gave up": Text("no acknowledgement — the retry budget is spent", style="err"),
"unknown contact": Text(
"the device's contact list doesn't know this contact yet; it stays queued",
style="warn",
),
"busy": Text("another delivery is in flight — try again in a moment", style="muted"),
"gone": Text("this entry is no longer queued", style="muted"),
}
await session.message_dialog(notes.get(outcome, Text(outcome)), title="Courier")
with ctx.courier.holding(ident):
text = await session.text(
title,
prompt=_MESSAGE_PROMPT,
default=message.text,
validate=_not_blank,
byte_limit=DM_BYTE_LIMIT,
compose=True,
)
if text is None or text == message.text:
return
if not store.edit(ident, text):
# A late acknowledgement can finish the entry while the prompt is open.
await session.message_dialog(
Text("this message left the outbox during the edit — it was not changed", style="warn"),
title="Courier",
)
async def _delete_entry(ctx: AppContext, ident: int) -> None:
"""Ask to delete one entry (waiting or finished), with the red confirm of a delete."""
store = ctx.courier_store
message = store.get(ident)
if message is None:
return
if message.status == QUEUED:
prompt = f"Delete the message for {message.node_name}? It will not be sent."
else:
prompt = f"Delete the message for {message.node_name} from the history?"
if await ctx.ui.dialog(
prompt,
[("Cancel", False), ("Delete", True)],
title="Delete message",
default=1,
destructive=True,
):
store.delete(ident)
async def _send_now(ctx: AppContext, ident: int) -> None:
"""Force one delivery try of a waiting entry, and show the outcome in a dialog."""
session = ctx.ui.session
message = ctx.courier_store.get(ident)
if message is None or message.status != QUEUED:
return
try:
async with ctx.ui.busy_overlay(f"sending to {message.node_name}…"):
outcome = await ctx.courier.attempt_now(ident)
except Exception as exc: # noqa: BLE001 - show the failure, and keep the queue
await session.message_dialog(Text(f"send failed: {exc}", style="err"), title="Courier")
return
notes = {
"delivered": Text("✓ delivered — acknowledged by the contact", style="ok"),
"no ack": Text(
"sent, but no acknowledgement — it stays queued and the courier "
"will retry with backoff",
style="warn",
),
"gave up": Text("no acknowledgement — the retry budget is spent", style="err"),
"unknown contact": Text(
"the device's contact list doesn't know this contact yet; it stays queued",
style="warn",
),
"busy": Text("another delivery is in flight — try again in a moment", style="muted"),
"gone": Text("this entry is no longer queued", style="muted"),
}
await session.message_dialog(notes.get(outcome, Text(outcome)), title="Courier")
+109 -18
View File
@@ -25,6 +25,13 @@ page never shows it in its own body, because the body belongs to the report. The
answers in a dialog, because the answer is a path that the user may have to read
carefully, and because a write can fail. The page under the dialog does not change, and
it is still there when the dialog closes.
A second verb, **anonymize** (:data:`ANONYMIZE_KEY`), changes the page between the
report as it is and the report with its personal details masked. It is the menu half of
``meshterm diagnostics --anonymize``. The ``anonymize`` preference chooses the form in
which the page opens, and the key changes only this visit. The save writes the form that
is visible, so the user saves what the user reads. The title says ``anonymized`` while the
masks are visible.
"""
from __future__ import annotations
@@ -50,17 +57,34 @@ SAVE_KEY = "s"
#: is not a second implementation of the keyboard key.
SAVE_ACTION = "save"
#: The keyboard key that changes the page between the report and the anonymized report. A
#: bare letter, for the same reason as :data:`SAVE_KEY`.
ANONYMIZE_KEY = "a"
#: The action that :data:`ANONYMIZE_KEY` and its chip on the lane both dispatch.
ANONYMIZE_ACTION = "anonymize"
_TITLE = "Diagnostics"
class DiagnosticsPage(AboutPage):
"""The written diagnostics page, plus the one verb that writes it to a file.
"""The written diagnostics page, plus the verbs that anonymize it and write it to a file.
All of the behaviour for reading comes from :class:`~meshterm.ui.about.AboutPage`: the
pager, the sticky ``##`` headings, the section jumps, and the lane that dims when there
is nothing to page. This class adds only the save, on the one slot that the About pages
leave free.
is nothing to page. This class adds the save, on the one slot that the About pages
leave free, and the anonymize key, on the Shift half of that slot.
"""
def __init__(self, source: str, *, session: TuiSession, save_path: Path) -> None:
def __init__(
self,
source: str,
*,
session: TuiSession,
save_path: Path,
anonymized_source: str | None = None,
anonymized: bool = False,
) -> None:
"""Show ``source`` as the page, and save it to ``save_path`` when the user asks.
Args:
@@ -68,24 +92,48 @@ class DiagnosticsPage(AboutPage):
:func:`~meshterm.tools.diagnostics.markdown_source`.
session: The session, for the dialog in which the save answers.
save_path: The file to which :data:`SAVE_KEY` writes.
anonymized_source: The same document with its personal details masked, from
:func:`~meshterm.tools.diagnostics.anonymized`. ``None`` gives a page with
no anonymize key.
anonymized: Whether the page opens on ``anonymized_source``.
"""
super().__init__("Diagnostics", render_markdown(source))
self._source = source
self._sources = {False: source, True: anonymized_source}
self._anonymized = anonymized and anonymized_source is not None
super().__init__(self._title(), render_markdown(self._source))
self._session = session
self._save_path = save_path
self._saving = False
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The keys of the read-only page, with the save between the pager and Esc.
def _source(self) -> str:
"""The document that the page shows now, and that the save writes."""
return self._sources[self._anonymized] or self._sources[False] or ""
The hint has the standard sentence shape: navigation first, then the action, then
@property
def _can_anonymize(self) -> bool:
"""Whether the page has the second form of the document to change to."""
return self._sources[True] is not None
def _title(self) -> str:
"""The title, with ``· anonymized`` while the masks are visible."""
return f"{_TITLE} · anonymized" if self._anonymized else _TITLE
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The keys of the read-only page, with the verbs between the pager and Esc.
The hint has the standard sentence shape: navigation first, then the actions, then
Esc. The pager atom is present only while :attr:`~Screen.content_overflows` is
true, as on each read-only page. Thus the line never names a keyboard key that does
nothing.
nothing. The anonymize atom names what the key does next: ``anonymize`` on the
report as it is, and ``reveal`` on the masked report.
"""
scroll = "↑↓ PgUp/PgDn scroll · " if self.content_overflows else ""
return f"{scroll}{SAVE_KEY} save · Esc back"
toggle = ""
if self._can_anonymize:
verb = "reveal" if self._anonymized else "anonymize"
toggle = f"{ANONYMIZE_KEY} {verb} · "
return f"{scroll}{toggle}{SAVE_KEY} save · Esc back"
@property
def picocalc_lyra_lane(self):
@@ -93,21 +141,41 @@ class DiagnosticsPage(AboutPage):
F1/F2 are the section pair and F4/F5 are the pager. Thus F3 is the one free primary
slot. On this platform, the lane is the only place that can show a bare-letter
keyboard key. Thus the chip is necessary, and not only a decoration.
keyboard key. Thus the chip is necessary, and not only a decoration. The anonymize
key goes on the Shift half of the same slot, because the two verbs are both about
what leaves the machine. Its label names what the chip does next.
"""
from .tui.fkeys import FPair
lane = list(super().picocalc_lyra_lane)
lane[2] = FPair("Save", SAVE_ACTION)
if self._can_anonymize:
label = "Reveal" if self._anonymized else "Anon"
lane[2] = FPair("Save", SAVE_ACTION, label, ANONYMIZE_ACTION)
else:
lane[2] = FPair("Save", SAVE_ACTION)
return lane
def handle(self, action: str, data: str = "") -> None:
"""Save on the keyboard key or the chip. Scroll, jump, or leave on all other actions."""
if action == SAVE_ACTION or (action == "text" and data.lower() == SAVE_KEY):
"""Save or anonymize on a keyboard key or a chip. Scroll, jump, or leave on all others."""
typed = data.lower() if action == "text" else ""
if action == SAVE_ACTION or typed == SAVE_KEY:
self._begin_save()
return
if self._can_anonymize and (action == ANONYMIZE_ACTION or typed == ANONYMIZE_KEY):
self._toggle_anonymized()
return
super().handle(action, data)
def _toggle_anonymized(self) -> None:
"""Show the other form of the document, at the same scroll position.
Both forms have the same lines in the same order, because a mask keeps the length
of each value. Thus the user stays at the same place in the page.
"""
self._anonymized = not self._anonymized
self._doc = render_markdown(self._source)
self.title = self._title()
def _begin_save(self) -> None:
"""Write the file and report the result in a dialog, outside the key handler.
@@ -159,13 +227,22 @@ class DiagnosticsPage(AboutPage):
return message
async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path) -> None:
async def open_diagnostics_page(
ctx: AppContext,
source: str,
*,
save_path: Path,
anonymized_source: str | None = None,
anonymized: bool = False,
) -> None:
"""Open the Diagnostics page full-screen, and keep it until the user leaves it.
Args:
ctx: The shared application context. The interactive TUI must be in use.
source: The markdown document to show and to save.
save_path: The file to which the save key of the page writes.
anonymized_source: The same document with its personal details masked.
anonymized: Whether the page opens on ``anonymized_source``.
Raises:
RuntimeError: If the call is outside the interactive menu (there is no
@@ -176,7 +253,21 @@ async def open_diagnostics_page(ctx: AppContext, source: str, *, save_path: Path
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the menu-only caller guards this
raise RuntimeError("the Diagnostics page is only available in the menu")
session = ctx.ui.session
await session.run_screen(DiagnosticsPage(source, session=session, save_path=save_path))
page = DiagnosticsPage(
source,
session=session,
save_path=save_path,
anonymized_source=anonymized_source,
anonymized=anonymized,
)
await session.run_screen(page)
__all__ = ["SAVE_ACTION", "SAVE_KEY", "DiagnosticsPage", "open_diagnostics_page"]
__all__ = [
"ANONYMIZE_ACTION",
"ANONYMIZE_KEY",
"SAVE_ACTION",
"SAVE_KEY",
"DiagnosticsPage",
"open_diagnostics_page",
]
+29 -58
View File
@@ -25,7 +25,6 @@ platform.
from __future__ import annotations
import math
from collections import Counter
from dataclasses import dataclass, field
from operator import itemgetter
@@ -34,7 +33,7 @@ from ..core.models import MapMarker
from ..core.mvt import GEOM_LINE, GEOM_POLYGON, Layer
from ..platforms import Platform, on_platform
from .mapcanvas import MapCanvas, Raster
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK, parse_hex
from .marks import NODE_MARK, REPEATER_MARK, RGB, SELF_MARK, UNKNOWN_MARK
from .theme import mark_rgb
# The marker palette, shared across all the app. The canonical tuples are in ui.marks.
@@ -59,20 +58,20 @@ _ROAD_STYLE: dict[str, tuple[str, int]] = {
"trunk": ("map.highway", 26),
"primary": ("#e8b24a", 25),
"secondary": ("#d7c257", 24),
"tertiary": ("#a7adb8", 23),
"minor": ("#828894", 21),
"unclassified": ("#828894", 21),
"residential": ("#828894", 21),
"living_street": ("#767c88", 20),
"service": ("#5f646e", 19),
"pedestrian": ("#5a5f69", 15),
"path": ("#4b5560", 12),
"track": ("#4b5560", 12),
"footway": ("#454e58", 11),
"cycleway": ("#455864", 12),
"tertiary": ("#adadad", 23),
"minor": ("#888888", 21),
"unclassified": ("#888888", 21),
"residential": ("#888888", 21),
"living_street": ("#7c7c7c", 20),
"service": ("#646464", 19),
"pedestrian": ("#5f5f5f", 15),
"path": ("#545454", 12),
"track": ("#545454", 12),
"footway": ("#4d4d4d", 11),
"cycleway": ("#555555", 12),
}
_ROAD_DEFAULT = ("#5f646e", 18)
_RAIL = ("#8a8f98", 22)
_ROAD_DEFAULT = ("#646464", 18)
_RAIL = ("#8f8f8f", 22)
# Waterway class -> (colour, priority).
_WATERWAY_STYLE: dict[str, tuple[str, int]] = {
@@ -90,16 +89,16 @@ _GREEN_CLASSES = {"wood", "forest", "grass", "park", "meadow", "scrub", "wetland
# with a lower rank get screen space first. The minimum zoom hides the fine detail until
# you zoom in.
_PLACE_STYLE: dict[str, tuple[str, bool, int, int]] = {
"city": ("#f8fafc", True, 0, 0),
"town": ("#e6ebf2", True, 1, 9),
"village": ("#cdd6e2", False, 2, 12),
"suburb": ("#9fb0c4", False, 3, 12),
"quarter": ("#94a5ba", False, 4, 13),
"neighbourhood": ("#8496ab", False, 5, 13),
"hamlet": ("#8496ab", False, 6, 14),
"city": ("#fafafa", True, 0, 0),
"town": ("#eaeaea", True, 1, 9),
"village": ("#d5d5d5", False, 2, 12),
"suburb": ("#aeaeae", False, 3, 12),
"quarter": ("#a3a3a3", False, 4, 13),
"neighbourhood": ("#949494", False, 5, 13),
"hamlet": ("#949494", False, 6, 14),
}
_WATER_LABEL = ("#7dd3fc", False, 2)
_STREET_LABEL = ("#9aa0aa", False, 7)
_STREET_LABEL = ("#9f9f9f", False, 7)
#: The zoom at which street names appear. Below it, a canvas 53 cells wide cannot hold one
#: name for each street. The few names that fit label a grid that is too coarse to show
@@ -126,7 +125,7 @@ _FILL_LAYERS = ("water", "landcover", "landuse", "park")
#: Below the zoom gate, a typical footprint is approximately two dots wide (less than half
#: a cell). Thus the full layer becomes a haze that makes the streets hard to read, and it
#: shows nothing that the street pattern does not already show.
_BUILDING_FILL = ("#4a4f5a", 8)
_BUILDING_FILL = ("#4f4f4f", 8)
_BUILDING_STIPPLE = 2
_BUILDING_MIN_ZOOM = 15
@@ -759,30 +758,6 @@ def _marker_style(marker: MapMarker) -> tuple[str, str]:
return _NODE
#: The number of piled nodes at which the glyph of a marker gets full brightness.
_PILE_FULL = 8
def _pile_color(color: RGB, count: int, *, cap: int = _PILE_FULL) -> RGB:
"""Make the colour of a marker brighter, by the number of nodes that share its cell.
One cell can show only one glyph. Thus, where many nodes are at the same position, the
map would hide the crowd behind one marker. Instead, we keep the glyph of the top node,
and move its colour toward white as the pile grows. Thus a bright marker shows a busy
cluster. The ramp is logarithmic (2 nodes → a clear increase, then saturation near
``cap``). Thus it stays informative, and one extra node does not look like a crowd.
"""
if count <= 1:
return color
t = min(1.0, math.log2(count) / math.log2(cap))
r, g, b = color
return (
round(r + (255 - r) * t),
round(g + (255 - g) * t),
round(b + (255 - b) * t),
)
#: How much a node outside an active find filter dims (a multiplier for the glyph colour).
_FIND_DIM = 0.35
@@ -801,9 +776,10 @@ def _draw_nodes(
"""Draw the mesh nodes on top: each glyph, then labels by importance until they collide.
The glyphs are drawn lowest priority first, so that our node and the repeaters are on
top of the leaf nodes. Where several nodes share a cell, the glyph that stays is made
brighter by the pile size (:func:`_pile_color`). Thus crowded positions glow, and do
not silently hide the crowd. Then the labels are placed highest priority first (our
top of the leaf nodes. One cell shows one glyph, so where several nodes share a cell,
the glyph of the top node stays. (A crowd once made that glyph brighter. Each marker
is white now, and white cannot get brighter, so JP removed that signal on
2026-10-14.) Then the labels are placed highest priority first (our
node, then repeaters, then leaf nodes), each only if it fits with no overlap. Thus, on
a crowded map, the important labels get the available space, and the other nodes show
as a bare marker (with no overlap).
@@ -823,23 +799,18 @@ def _draw_nodes(
matched = not needle or needle in marker.label.casefold()
placed.append((marker, ix, iy, matched))
# A cell is (dot_x >> 1, dot_y >> 2). Count how many nodes are in each cell.
pile = Counter((ix >> 1, iy >> 2) for _, ix, iy, _m in placed)
# Matches draw after (over) the dimmed nodes that do not match, at any rank. Thus the
# node that the user looks for is never hidden below a brighter neighbour.
for marker, ix, iy, matched in sorted(placed, key=lambda p: (p[3], p[0]._rank())):
glyph, color = _marker_style(marker)
rgb = parse_hex(color)
rgb = mark_rgb(color)
if not matched:
rgb = _dimmed(rgb, _FIND_DIM)
else:
rgb = _pile_color(rgb, pile[(ix >> 1, iy >> 2)])
canvas.marker(ix, iy, glyph, rgb)
# A node that the filter removes is context: a bare dim glyph with no label. Labels get
# the name hue of the node, which comes from its key (the colour rule for all the app).
# The label of our node is the pure ``you`` white (the ★ glyph keeps its yellow). An
# The label of our node is the pure ``you`` white, the same as the ★ glyph. An
# active find filter still makes the label of each match full white, so that the node
# that the user looks for is easy to see.
from .widgets import name_rgb # widgets imports this module, so bind late to avoid the cycle
+1 -1
View File
@@ -522,7 +522,7 @@ class MapCanvas:
:data:`_BOLD`). The kernel VT draws ``bold`` as brightness. If a bold run has a
colour that was just quantized into the dim bank, the VT changes it to the bright
partner of that slot. A light-grey unknown label then shows as the white "you", and a
purple repeater shows as pink. The colour that a marker has is the important part.
brown highway shows as yellow. The colour that a marker has is the important part.
The bold is not.
"""
from .theme import fold_text
+21 -10
View File
@@ -27,20 +27,22 @@ def parse_hex(color: str) -> RGB:
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
# The palette of markers, shared in the whole app. The basemap is blue (water), green
# (parks), and warm amber (roads). Thus the markers use the hues that a map never has,
# pink and violet, so that they are easy to see against it. Ordinary nodes are the
# loudest (bright pink). Repeaters are less loud (a calmer violet). Our node is always the
# yellow star. A node that was heard but never identified is a light grey ring. Each glyph
# is in the PicoCalc console font.
# The palette of markers, shared in the whole app. Each marker is white, and its shape
# tells what it is (JP, 2026-10-14): a ``●`` for an ordinary node and a ``▲`` for a
# repeater, in the ``type.*`` white of the theme. They were pink and violet, the hues that
# a map never has. Our node is the star, in the white of ``you`` (JP, 2026-10-14). It was
# yellow, and then the hue of our key. A node that was heard but never identified is a
# light grey ring. Each glyph is in the PicoCalc console font.
#
# The colour of a mark is any value that :func:`~meshterm.ui.theme.mark_rgb` takes: a
# literal hex, or the name of a theme style when the platform must choose the shade. The
# unknown ring has a name (``node.unknown``), so that the ring and the grey label beside
# it can never be different.
SELF_MARK = ("★", "#facc15")
REPEATER_MARK = ("▲", "#a78bfa")
NODE_MARK = ("●", "#f472b6")
# it can never be different. Our star has the name ``you``, the white of our node in each
# list, and the two type marks have their ``type.*`` names, so that the console chooses
# its own white.
SELF_MARK = ("★", "you")
REPEATER_MARK = ("▲", "type.repeater")
NODE_MARK = ("●", "type.node")
UNKNOWN_MARK = ("○", "node.unknown")
#: The only concealed character. One bullet replaces one character of a value that the
@@ -50,6 +52,15 @@ UNKNOWN_MARK = ("○", "node.unknown")
#: purpose. Those marks have a meaning, and this one means "not shown".
MASK_MARK = "•"
#: The padlock of a locked contact: at the start of its row in the Contacts list, and before
#: its name on its sender chip in a chat. It is the private-channel ``🔒`` of the icon
#: lexicon, because it means the same thing (this item is closed to something). It goes
#: through :func:`~meshterm.ui.theme.glyph`, which gives the hand-drawn padlock ``⚿`` of the
#: font on the PicoCalc. An unlocked contact draws nothing. The mark is an exception that
#: the user must notice, and a lane of open padlocks makes the few closed padlocks hard to
#: see.
LOCK_ICON = "🔒"
#: Sentinels that name the endpoints of a path on the route graph. ``\x00`` never occurs
#: in a node hash, so the sentinels can share the namespace of node ids and not collide
#: with an id.
+18 -4
View File
@@ -348,7 +348,12 @@ def _header_segments(ctx: AppContext, cache: dict) -> list[Text]:
if "device" in atoms:
device = Text()
if ctx.mock:
if getattr(ctx, "demo", False):
# The demo shows the name of its node, as a real session does, and says that
# it is the demo where a real session says how the device is connected.
device.append(_demo_name())
device.append(" (demo)", style="muted")
elif ctx.mock:
device.append("simulator", style="warn")
else:
name, where = _device_label(ctx, cache)
@@ -375,6 +380,13 @@ def _header_segments(ctx: AppContext, cache: dict) -> list[Text]:
return segments
def _demo_name() -> str:
"""The name of our node in the demo world."""
from ..demo.world import SELF
return SELF.name
def _device_label(ctx: AppContext, cache: dict) -> tuple[str, str]:
"""The device segment of the header: ``(node name, where)``, cached for each connection.
@@ -1292,9 +1304,11 @@ async def _wait_for_disconnect(ctx: AppContext) -> None:
(:meth:`AppContext.link_alive`), and does not wait for a failed command. After a
serial disconnect, the ``meshcore`` client continues to give cached data and never
raises an error (this was confirmed on hardware). Thus an active liveness check is the
only reliable way to find a disconnected cable. The same check covers Bluetooth:
there, it reads the connection flag of the BLE client (which changes immediately when
the peripheral disconnects or goes out of range). The function never resolves for the
only reliable way to find a disconnected cable. The check also reads the connection
flag of the ``meshcore`` client, which changes immediately when the transport closes.
For Bluetooth, that flag is the only signal (the peripheral disconnects or goes out of
range). For serial, it finds a port that stays listed after its link is dead (a laptop
that wakes from sleep can hang up the port). The function never resolves for the
simulator (it cannot be disconnected), or before a real device opens.
Args:
+26 -5
View File
@@ -56,6 +56,7 @@ from ..platforms import get_platform
from .theme import glyph
from .tui import Choice, Separator
from .tui.emoji_width import cut_cells, drawable
from .tui.select import splice_hint
if TYPE_CHECKING:
from ..context import AppContext
@@ -477,6 +478,22 @@ PICK_LOCATION_LABEL = "Pick location on map…"
PICK_LOCATION_HELP = "Point at the map to set both coordinates"
#: The maximum width of a footer hint, in cells (refer to the UX standards).
HINT_CELLS = 72
def splice_if_room(hint: str, atom: str) -> str:
"""``hint`` with ``atom`` before its ``Esc`` atom, but only when the result fits.
This is for an atom that is good to show but that the hint can do without: the lock
key (JP, 2026-10-19: "If footer too long, don't put it in the footer. If there's room,
put it."). The result must stay in :data:`HINT_CELLS`. No other atom leaves the hint to
make space for it, so a hint that is too full stays as it was.
"""
longer = splice_hint(hint, atom)
return longer if cell_len(longer) <= HINT_CELLS else hint
def changes_phrase(count: int) -> str:
"""``"1 staged change"`` / ``"3 staged changes"`` for dialogs and menu rows."""
return f"{count} staged change{'' if count == 1 else 's'}"
@@ -650,15 +667,19 @@ def section_heading(label: str) -> Separator:
to heading. A row built by hand does not get this behaviour. Thus always use this
function.
A heading that starts with an icon (``📡 Channels``, the mark of a trophy discipline)
loses the icon on a platform that draws no icon lane, the same as a command row. A
heading is the same type of label. Also, a substitute glyph next to the rule, which
already draws ``──``, looks like noise (refer to :func:`command_label`).
A heading is words only, with no icon. Icons go on rows. The headings of Chat
(``📡 Channels``) and of the trophy case (the mark of each discipline) had icons. We
removed them for a cleaner look (JP, 2026-10-15).
Grey, never the accent of the frame: a rule in the colour of the border looks like a
part of the frame, not like a heading in it.
The heading is also *spaced*: a blank line comes before it where the
``section_spacing_min_rows`` preference asks for one (refer to
:attr:`~meshterm.ui.tui.select.Separator.spaced`). Thus a list does not put a blank
separator before a heading for space.
"""
return Separator(f"── {command_label(label)} ──", style="heading", heading=True)
return Separator(f"── {label} ──", style="heading", heading=True, spaced=True)
def fit_cells(text: str, width: int, *, align: str = "left") -> str:
+119 -42
View File
@@ -43,7 +43,8 @@ screen is the node itself, in full:
where the sweep archives. After a red confirm, it removes this node from the contact
table of the device and from the contacts that MeshTerm remembers for the device.
Then the page closes, because the contact that the page shows does not exist any
more. An archive and a restore also close the page. A lock does not.
more. An archive and a restore also close the page. A lock does not. A page that a
conversation opens has only the lock row of this group.
* **Routes**: the routes on which the device really heard the node arrive. The shared
route graph (:mod:`~meshterm.ui.pathgraph`) draws them from the node to our node, in
the inbound direction of the packets: the contact on the left, our node on the right.
@@ -1252,29 +1253,38 @@ class ContactPages:
twin of :class:`~meshterm.ui.map_screen.Locator`, which ^L uses for the map.
The contacts come from the device one time, when the screen opens.
It is also the ^K key (``lock``) of a conversation: the lock of the author, which
:meth:`lock_state` reads and :meth:`set_locked` writes. The store keeps a lock under
the key of the device, so the pages hold that key too. The state is read from the store
at each paint and never kept here, because the node page can change it too.
"""
def __init__(self, ctx: AppContext, contacts: list[Contact]) -> None:
def __init__(self, ctx: AppContext, contacts: list[Contact], *, self_key: str = "") -> None:
"""Hold the contacts that :meth:`find` searches.
Args:
ctx: The shared application context (it must be in the interactive menu).
contacts: The contacts of the device.
self_key: The public key of the device (hex). The store keeps the locks under
it. With ``""``, no contact can be locked from here.
"""
self._ctx = ctx
self._contacts = contacts
self._self_key = self_key
self._dev_pub = self_key.lower().removeprefix("0x")
# The result of each key that find() got. A screen asks at each paint, and the
# answer for one key does not change while the contacts stay the same.
self._found: dict[str, Contact | None] = {}
@classmethod
async def gather(cls, ctx: AppContext) -> ContactPages:
async def gather(cls, ctx: AppContext, *, self_key: str = "") -> ContactPages:
"""The pages of the contacts that the device has now. With no contacts, nothing is found."""
try:
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - with no contacts, ^O opens only our own page
contacts = []
return cls(ctx, list(contacts))
return cls(ctx, list(contacts), self_key=self_key)
def find(self, key: str | None) -> Contact | None:
"""The contact with ``key`` (a full key or a key prefix), or ``None``.
@@ -1294,14 +1304,49 @@ class ContactPages:
)
return self._found[needle]
@property
def can_lock(self) -> bool:
"""Whether a lock can be written from here at all: a store, and the key of the device."""
return getattr(self._ctx, "contact_store", None) is not None and bool(self._dev_pub)
def lock_state(self, contact: Contact | None) -> bool | None:
"""Whether ``contact`` is locked, or ``None`` when it cannot have a lock.
A lock needs the full key of a live contact, as on the node page (refer to
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`). An archived contact
has no lock, because the lock exists to keep a contact out of the archive.
"""
if contact is None or not self.can_lock:
return None
key = (contact.public_key or "").lower().removeprefix("0x")
if len(key) != 64 or _is_archived(self._ctx, contact, self._self_key):
return None
return self._ctx.contact_store.is_locked(self._dev_pub, key)
def locked_keys(self) -> frozenset[str]:
"""The full keys of the contacts locked on this device, or none where no lock is stored.
A conversation compares it at each paint, to draw again the sender chips that gained
or lost their padlock (refer to :class:`~meshterm.ui.chat.ChatScreen`).
"""
if not self.can_lock:
return frozenset()
return self._ctx.contact_store.locked_keys(self._dev_pub)
def set_locked(self, contact: Contact, locked: bool) -> None:
"""Lock or unlock ``contact``. The caller asked :meth:`lock_state` first."""
if self.can_lock:
self._ctx.contact_store.set_locked(self._dev_pub, contact, locked)
async def show(self, contact: Contact | None) -> None:
"""Open the node page of ``contact``, or of our node when it is ``None``.
The page opens to look, not to manage (``manage=False``). The management verbs
(lock, archive, restore, delete) belong to the list that the contact is in, and a
conversation is not that list.
The page opens to look, not to manage (``manage=False``). The verbs that remove a
contact (archive, restore, delete) belong to the list that the contact is in, and a
conversation is not that list. The lock is the exception (``lock=True``): it removes
nothing, and a conversation can already toggle it with ^K (JP, 2026-10-19).
"""
await open_node_detail(self._ctx, contact, manage=False)
await open_node_detail(self._ctx, contact, manage=False, lock=True)
def _same_key(contact_key: str | None, needle: str) -> bool:
@@ -1311,7 +1356,7 @@ def _same_key(contact_key: str | None, needle: str) -> bool:
async def open_node_detail(
ctx: AppContext, contact: Contact | None, *, manage: bool = True
ctx: AppContext, contact: Contact | None, *, manage: bool = True, lock: bool | None = None
) -> bool:
"""Open the Node detail page for a contact (or our node) and run its action loop.
@@ -1343,6 +1388,11 @@ async def open_node_detail(
is really in. They never belong to a page opened from a list that is about to act on
all its rows together.
With ``lock``, a caller that does not manage still lets the page lock and unlock. A
conversation gives ``True`` (refer to :class:`ContactPages`): the lock removes nothing,
and the page that a chat opens is then the way to lock in a room, where the F-key lane
has no free chip for it.
Args:
ctx: The shared application context (the interactive TUI must be running).
contact: The contact to show, or ``None`` for our node (a page of identity and
@@ -1351,6 +1401,8 @@ async def open_node_detail(
manage: Whether to show the contact-management actions (lock, archive, restore,
delete). ``False`` draws none of them, and then the page can only return
``False``.
lock: Whether to show ``Lock contact`` or ``Unlock contact``. ``None`` follows
``manage``.
Returns:
``True`` if the visit ended because the user archived, restored, or deleted the
@@ -1594,48 +1646,32 @@ async def open_node_detail(
info_actions.append(_Action("regions", "🔖", "", "Ask which regions it carries"))
# -- contact management: the last group of the page, and the only actions that end the
# visit. None of them shows when the caller opened the page to look, not to act (refer
# to ``manage``). They never show for our node, or for a contact with no key at
# all, because the write has nothing to use as its address.
archived = manage and _is_archived(ctx, contact, self_key)
manageable = (
manage
and not you
and contact is not None
and bool(contact.public_key or contact.key_prefix)
)
# to ``manage``), except the lock when the caller lets the page lock (refer to
# ``lock``). They never show for our node, or for a contact with no key at all,
# because the write has nothing to use as its address.
may_lock = manage if lock is None else lock
archived = (manage or may_lock) and _is_archived(ctx, contact, self_key)
keyed = not you and contact is not None and bool(contact.public_key or contact.key_prefix)
manageable = manage and keyed
store = getattr(ctx, "contact_store", None)
dev_pub = self_key.lower().removeprefix("0x")
whole_key = contact is not None and len(contact.public_key.lower().removeprefix("0x")) == 64
# A lock is a mark of MeshTerm itself, attached to the full key in the contact store, and
# only a live contact can have one. Its purpose is to keep a contact out of the archive.
lockable = manageable and not archived and whole_key and store is not None and bool(dev_pub)
lockable = (
may_lock and keyed and not archived and whole_key and store is not None and bool(dev_pub)
)
base_actions = list(info_actions)
def management(locked: bool) -> list[_Action]:
"""The action rows of the page, with the management group for this lock state last."""
rows = list(base_actions)
if not manageable:
return rows
# The lock is first. It changes nothing on the device and ends nothing, so it is
# the safest row of the group for the highlight. Then the constructive verb, then
# the destructive verb. Thus a key press by mistake most probably goes to the row
# that the user can undo. Archive and restore are the two halves of one move that
# the user can reverse. They use the ``💾``/``📂`` pair that the lexicon already
# gives to "put away" and "bring back". Only a contact with a full key can be
# written back to the device. Thus a contact with only a key prefix never gets the
# archive, because the archive leaves it with no way back. A locked contact
# never gets the archive at all, and that is the purpose of the lock.
if lockable:
if locked:
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
else:
rows.append(_Action("lock", "🔒", "", "Lock contact"))
if archived:
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
elif whole_key and not locked:
rows.append(_Action("archive", "💾", "", "Archive contact"))
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
return rows
return base_actions + _management_rows(
locked=locked,
lockable=lockable,
manageable=manageable,
archived=archived,
whole_key=whole_key,
)
locked = bool(lockable and store.is_locked(dev_pub, contact.public_key))
info_actions = management(locked)
@@ -1747,6 +1783,47 @@ async def open_node_detail(
return contact_removed
def _management_rows(
*, locked: bool, lockable: bool, manageable: bool, archived: bool, whole_key: bool
) -> list[_Action]:
"""The contact-management group of the node page, for one lock state.
The lock is first. It changes nothing on the device and ends nothing, so it is the
safest row of the group for the highlight. Then the constructive verb, then the
destructive verb. Thus a key press by mistake most probably goes to the row that the
user can undo. Archive and restore are the two halves of one move that the user can
reverse. They use the ``💾``/``📂`` pair that the lexicon already gives to "put away"
and "bring back". Only a contact with a full key can be written back to the device.
Thus a contact with only a key prefix never gets the archive, because the archive
leaves it with no way back. A locked contact never gets the archive at all, and that
is the purpose of the lock.
A page that may lock but not manage (a page that a conversation opens) has only the
lock row.
Args:
locked: Whether the contact is locked now.
lockable: Whether the page shows the lock row.
manageable: Whether the page shows archive, restore, and delete.
archived: Whether the contact is archived. Then restore takes the place of archive.
whole_key: Whether the full key of the contact is known, which an archive must have.
"""
rows: list[_Action] = []
if lockable:
if locked:
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
else:
rows.append(_Action("lock", "🔒", "", "Lock contact"))
if not manageable:
return rows
if archived:
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
elif whole_key and not locked:
rows.append(_Action("archive", "💾", "", "Archive contact"))
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
return rows
def _is_archived(ctx: AppContext, contact: Contact | None, self_key: str) -> bool:
"""Whether the sweep removed this contact from the device, and MeshTerm keeps it.
+110 -26
View File
@@ -17,7 +17,7 @@ surface that the survey found can in the end use it.
look, and each seam uses one cell. The label of the chip of a node has the hue that
comes from its hash (:func:`~meshterm.ui.theme.node_style`). This is the colour that its
name has on each surface. The fill is that hue, but darker (:data:`CHIP_LIGHTNESS`). Our
node is the yellow ``★`` of the map on a neutral dark grey. A faded hop is dark slate. A
node is the white ``★`` of the map on a neutral dark grey. A faded hop is dark grey. A
keyless hop is light grey on grey. (On the 16-colour console of the PicoCalc, the fill
is the dim twin of the hue. Refer to :func:`_slot_colours`.)
@@ -87,7 +87,7 @@ surface that the survey found can in the end use it.
``★`` of the app. The user already knows who both ends are, and the cells are for the
hops that *are* news. These stars fade where the route is being *composed*, because our
two ends are fixtures and not choices. A surface that only *shows* a walk passes
``dim_self=False`` and keeps us in the ``you`` white.
``dim_self=False`` and keeps our stars in the ``you`` white.
The existing call sites still render through ``path_text``. A separate pass for each
surface will move them here. The insertion cursor of the composer is a hop in its own
@@ -140,8 +140,9 @@ POWERLINE_ROUND_CLOSE = ""
#: Our node, where a surface asks for it without its name and hash (``bare_self``): the
#: ``★`` of the app. It is the map's own marker, taken from
#: :data:`~meshterm.ui.marks.SELF_MARK`. Thus the star in a route and the star on the map
#: can never be different, in glyph or in hue.
SELF_GLYPH, _SELF_INK = SELF_MARK
#: can never be different, in glyph or in colour: the white of ``you``.
SELF_GLYPH = SELF_MARK[0]
#: The insertion cursor of the composer. It stands in the route as a hop of its own: the
#: empty slot into which the next chosen hop goes. It is a ``+``, because this is what
@@ -191,23 +192,33 @@ def _chip_label(fill: str) -> str:
return oklab.to_hex(oklab.fitted((min(1.0, lightness / CHIP_LIGHTNESS), a, b)))
#: The text of the composer's slot: near-black slate on the white chip.
_CHIP_FG = "#0f172a"
#: The text of the composer's slot: a grey that is almost black, on the white chip.
_CHIP_FG = "#181818"
#: The chip fill for a keyless hop: the ``faint`` grey. Colour is only for identities that
#: have a key, in chips and in arrow mode.
_KEYLESS_BG = "#64748b"
_KEYLESS_BG = "#737373"
#: The label of a keyless chip: the same pair as the chip of a node, in grey.
_KEYLESS_FG = _chip_label(_KEYLESS_BG)
#: The chip fill of our node: a neutral dark grey. Thus the yellow ``★`` on it reads as the
#: marker of the map and not as the hue of a node (JP, 2026-08-09). It is *not* a colour
#: The fill of a quiet chip (:attr:`PathHop.quiet`): the grey of plain text (``#cccccc``,
#: the default foreground of Windows Terminal and of the terminal of VS Code), darker by
#: the rule of the chip of a node (:data:`CHIP_LIGHTNESS`).
_QUIET_BG = _chip_fill("#cccccc")
#: The label of a quiet chip: black. A grey label on the grey fill read as a name that the
#: chip still lit, and black reads as a name that it does not (JP, 2026-10-14).
_QUIET_FG = "#000000"
#: The label of our chip in a chat (:attr:`PathHop.white_ink`): white, on the fill that
#: our hue gives each chip.
_WHITE = "#ffffff"
#: The chip fill of our node: a neutral dark grey. Thus the ``★`` on it reads as the
#: marker of the map and not as a chip of a node (JP, 2026-08-09). It is *not* a colour
#: of the spectrum on purpose, and it is not the white that it was before. Our end of a
#: route is a fixture that the user already knows. It stays back and lets the hops that
#: differ carry the colour.
_YOU_BG = "#3f3f46"
#: The chip of a dimmed hop: a dark slate fill with muted ink. It recedes like ``faint``
_YOU_BG = "#404040"
#: The chip of a dimmed hop: a dark grey fill with muted ink. It recedes like ``faint``
#: text.
_DIM_BG = "#334155"
_DIM_FG = "#94a3b8"
_DIM_BG = "#404040"
_DIM_FG = "#a2a2a2"
#: On the 16-colour console, the page: slot 0, black. It is the fill of a chip that the
#: console cannot fill, because the VT has no dark grey background. That chip is our ``★``,
@@ -286,9 +297,9 @@ class PathHop:
comes from the hash (its first byte, so each prefix gives the same hue).
``None`` renders muted and grey, because colour is only for identities that
have a key.
you: Our node. Arrows draw it in the pure-white ``you`` style. Chips draw it in the
own yellow of the map on the neutral dark grey :data:`_YOU_BG`, with padding
like each other chip.
you: Our node. Arrows draw it in the pure-white ``you`` style. Chips draw the white
``★`` on the neutral dark grey :data:`_YOU_BG`, with padding like each other
chip.
annotation: The hash note for traces. It is rendered ``" (3d)"`` after the label in
both modes (pass the bare ``3d``, without parentheses).
lit_bytes: For a hash label: the leading *bytes* that are drawn in the hue (the two
@@ -309,6 +320,19 @@ class PathHop:
continues again". A chip says the opposite: that a node in the route has the
name ``⋯``. Arrow mode does not need a special case, because a bare label
between two arrows is already the same thing.
white_ink: Draw the label in white, on the fill that the hue of ``key`` gives each
chip. It is our node in a chat (refer to :func:`~meshterm.ui.widgets.self_chip`):
the fill is the same as the fill of each known sender, and the white label
tells it from them (JP, 2026-10-14). Arrow mode draws the same pair, with the
padding of a chip. The PicoCalc draws it too, white on the dim twin of the hue.
On a quiet chip, it is a white label on the grey fill: a node with no key on the
selected message of a chat (refer to :func:`~meshterm.ui.widgets.picked_hop`).
quiet: A node with no key in a chat: a sender or a mention that nobody can
identify. Its chip is black on a grey fill (:data:`_QUIET_BG`), so it does not
stand out among the senders that have a colour (JP, 2026-10-14). A hop with a
key keeps its hue, and a route keeps the keyless chip. Arrow mode keeps the
``node.unknown`` grey. The PicoCalc keeps the light grey fill of its keyless chip,
with the black label.
"""
label: str
@@ -320,6 +344,8 @@ class PathHop:
style: str | None = None
cursor: bool = False
gap: bool = False
white_ink: bool = False
quiet: bool = False
def elision_hop() -> PathHop:
@@ -394,6 +420,31 @@ def _fills(line: Text) -> list[str | None]:
return fills
def _path_extent(line: Text, index: int) -> range | None:
"""The characters of the path line in ``line`` that holds character ``index``.
Each path line stamps its own extent with :data:`PATH_INK` (refer to
:meth:`PathLine._render`). A row that appends a path carries the stamp as a span, and
the cells after it (a status, an age) are not path. A line whose base style is the
stamp is all path. So is a line with no stamp at all, because then nothing can say
where its path stops.
Returns:
The range of the characters of that path, or ``None`` when ``index`` is outside
each path of a stamped row.
"""
whole = range(len(line.plain))
if str(line.style) == PATH_INK:
return whole
stamps = [span for span in line.spans if str(span.style) == PATH_INK]
if not stamps:
return whole
for span in stamps:
if span.start <= index < span.end:
return range(max(0, span.start), min(len(line.plain), span.end))
return None
def _char_of_cell(plain: str, cell: int) -> int:
"""The index of the character in which a display cell falls, limited to the string."""
if cell <= 0:
@@ -420,6 +471,12 @@ def cut_mark(line: Text, at: int, side: str) -> Text:
seam or on the closing edge still cracks in the colour of the chip that the user can
see, and not in nothing.
The scan stays inside the path that holds the cut (refer to :func:`_path_extent`). A
row can be a chip, then text: the recipient of a Courier entry, then what the entry
waits for. A cut in that text once found the chip far to its left, and drew a half
block in the fill of the chip at the right edge of the row, away from each chip. A cut
outside a path is a cut of text, so it gets the ellipsis.
Args:
line: The rendered line that MeshTerm cuts (whole, not shifted, with absolute cells).
at: The index of the nearest *visible* cell on the side of the mark. For
@@ -435,7 +492,13 @@ def cut_mark(line: Text, at: int, side: str) -> Text:
if not fills: # there is nothing drawn to shear: a caller cut an empty line
return Text(_ELLIPSIS, style="muted")
index = _char_of_cell(line.plain, at)
steps = range(index, len(fills)) if side == ELIDE_HEAD else range(index, -1, -1)
extent = _path_extent(line, index)
if extent is None: # the cut is in text after (or between) the paths of the row
return Text(_ELLIPSIS, style="muted")
if side == ELIDE_HEAD:
steps = range(index, extent.stop)
else:
steps = range(index, extent.start - 1, -1)
fill = next((fills[i] for i in steps if fills[i]), None)
if fill is None:
return Text(_ELLIPSIS, style="muted")
@@ -1001,6 +1064,9 @@ class PathLine:
text.append(hop.label, style="selected")
elif hop.dim:
text.append(hop.label, style="faint")
elif hop.white_ink and hop.key:
fill, ink, _soft = _colours_of(hop)
text.append(f" {hop.label} ", style=f"{_WEIGHT}{ink} on {fill}")
elif hop.style:
text.append(hop.label, style=hop.style)
elif hop.you:
@@ -1161,9 +1227,9 @@ class PathLine:
Each chip has padding on both sides, also the ``★`` (JP, 2026-08-09). The pads are
not there to make room for a long *word*. They are the chip's own shape. A star
that is squeezed between two chevrons reads as a glyph that is wedged into the seam,
and not as a segment of the route. The ink of our chip is the yellow of the map on
the neutral dark grey :data:`_YOU_BG`. Thus the star in the line and the star on the
map read as one mark.
and not as a segment of the route. The ink of our chip is the white of the star of
the map, on the neutral dark grey :data:`_YOU_BG`. Thus the star in the line and the
star on the map read as one mark.
The colours come from the palette of the platform (:func:`_colours_of`).
"""
@@ -1199,8 +1265,12 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
readable on it.) It is the only light chip on the ribbon. The fade is next, and it wins
over the identity, also our own. A dimmed hop is a hop that nobody composed (an
automatic return home, a mirrored return leg). Our end must recede with the rest of
that automatic half, and not keep its yellow among the greys. Plain mode says the same
that automatic half, and not keep its hue among the greys. Plain mode says the same
thing when it fades the name.
Our chip in a chat (``white_ink``) has the fill of the chip of its key, and a white
label. A quiet chip (a node with no key in a chat) is black on a grey fill, or white on
it with ``white_ink``.
"""
if hop.cursor:
return _ChipColours(_style_hex("cursor") or _KEYLESS_BG, _CHIP_FG, _CHIP_FG)
@@ -1209,11 +1279,16 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours:
return _ChipColours(_chip_fill(resolved), resolved, resolved)
if hop.dim:
return _ChipColours(_DIM_BG, _DIM_FG, _DIM_FG)
if hop.you:
return _ChipColours(_YOU_BG, _SELF_INK, _SELF_INK)
hue = _style_hex(node_style(hop.key)) if hop.key else None
if hop.white_ink and hue:
return _ChipColours(_chip_fill(hue), _WHITE, _WHITE)
if hop.you:
return _ChipColours(_YOU_BG, _WHITE, _WHITE)
if hue:
return _ChipColours(_chip_fill(hue), hue, hue)
if hop.quiet:
ink = _WHITE if hop.white_ink else _QUIET_FG
return _ChipColours(_QUIET_BG, ink, ink)
return _ChipColours(_KEYLESS_BG, _KEYLESS_FG, _KEYLESS_FG)
@@ -1229,10 +1304,14 @@ def _slot_colours(hop: PathHop) -> _ChipColours:
A keyless chip is white on light grey, the same pair one bank down. Our ``★``, a faded
hop, and the slot of the composer have no fill of their own, because the console has no
dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the star in its
yellow, a faded hop in the dark grey, and the slot as a white ``+`` in a break of the
dark grey background. They stand on the page (:data:`_SLOT_PAGE`): the star in our
hue, a faded hop in the dark grey, and the slot as a white ``+`` in a break of the
ribbon. The order of the rules is the same as on a truecolor terminal
(:func:`_spectrum_colours`).
Our chip in a chat (``white_ink``) is white on the dim twin of its hue, the same rule as
on a truecolor terminal. A quiet chip is black on the light grey of the keyless chip, or
white on it with ``white_ink``.
"""
if hop.cursor:
return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE)
@@ -1245,9 +1324,14 @@ def _slot_colours(hop: PathHop) -> _ChipColours:
if hop.dim:
return _ChipColours(_SLOT_PAGE, _SLOT_FAINT, _SLOT_FAINT)
if hop.you:
return _ChipColours(_SLOT_PAGE, _SELF_INK, _SELF_INK)
return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE)
hue = _style_hex(node_style(hop.key)) if hop.key else None
ink = hue if hue in DIM_TWIN else _SLOT_WHITE
if hop.white_ink and hue:
return _ChipColours(DIM_TWIN[ink], _SLOT_WHITE, _SLOT_WHITE)
if hop.quiet and not hue:
label = _SLOT_WHITE if hop.white_ink else _SLOT_PAGE
return _ChipColours(DIM_TWIN[ink], label, label)
return _ChipColours(DIM_TWIN[ink], ink, ink)
+10 -12
View File
@@ -88,11 +88,11 @@ _WALK_EDGE = (255, 255, 255)
#: around a scored walk. A fan grows past this number by itself, but nothing here fans.
_GRAPH_ROWS = 3
#: The one body tone of the area drawing: a plain muted slate, for the enclosed region
#: The one body tone of the area drawing: a plain muted grey, for the enclosed region
#: and for its loop. It is one shade on purpose (no dimmer fill inside a brighter edge).
#: An even-odd fill shades only the enclosed side, and the coloured node pins give the
#: colour. Thus the shape shows as one flat silhouette, not as two brightnesses.
_AREA_TONE: RGB = (148, 163, 184)
_AREA_TONE: RGB = (162, 162, 162)
#: The cell box of the area drawing, which is the full stage of the Area tab. Its width
#: is the width of the page. (The proportions are kept, thus a wider page gives a bigger
@@ -127,9 +127,11 @@ _LABEL_W = 12
def discipline_label(category: Category, lane: int) -> str:
"""The mark of one discipline, padded to ``lane`` cells, then its title.
This is the only way to write the name of a board. Thus the seven headings (and the
seven rows of the list that selects a discipline to delete) start their titles in one
column, not in two. The marks do not all have the same width. ``🛣`` and ``🕸`` are
This is the only way to write the name of a board with its mark: on the seven rows of
the list that selects a discipline to delete, and on the header of the record page.
Thus the seven rows start their titles in one column, not in two. A board heading has
no mark. It is only the title (refer to :func:`~meshterm.ui.menus.section_heading`).
The marks do not all have the same width. ``🛣`` and ``🕸`` are
not in Emoji_Presentation, and they are one cell wide, while the other five are two
cells wide. Because of this, ``🛣 Longest distance`` was one column away from
``🎯 Farthest node``. Its gap also looked closed on a terminal that paints the road
@@ -157,7 +159,7 @@ def discipline_lane() -> int:
Refer to :func:`discipline_label`. The width is measured, never written as a constant.
The marks change with the platform. If a substitution becomes narrower later, each
heading becomes one cell narrower, and nothing else moves.
label becomes one cell narrower, and nothing else moves.
"""
return max(cell_len(glyph(category.icon)) for category in CATEGORIES)
@@ -448,7 +450,7 @@ class RecordScreen(Screen):
The record page has this line where the node page has its identity header. It
reads as one sentence: ``🛣 Longest distance — 5th place: 112.0 km``. The
discipline is written the same as its board heading writes it
discipline is written the same as its row in the delete list writes it
(:func:`discipline_label`: the mark is padded to the measured lane, so that ``🛣``
and ``🎯`` start their titles in the same cell). The standing is the rank of the
record on that board. The metric is the score in the unit of the discipline
@@ -913,10 +915,6 @@ async def open_records(ctx: AppContext) -> dict:
# node is matched against these keys.
target_counts = ctx.repo.target_trace_counts()
# The mark column after which each board heading writes its title (refer to
# discipline_label).
mark_lane = discipline_lane()
# The node positions and types for the area drawing of a record. They are collected
# live, the same as the trace tools collect them to score a walk (refer to
# node_geo_lookup). The entry list does not change while the screen is open.
@@ -1001,7 +999,7 @@ async def open_records(ctx: AppContext) -> dict:
def describe(category: Category) -> list:
"""The heading and its wrapped description, as rows that the user cannot select."""
rows: list = [section_heading(discipline_label(category, mark_lane))]
rows: list = [section_heading(category.title)]
desc = category.description
if category.needs_positions and self_pos is None:
desc += " — needs your location (set it in Config) to score"
+3 -1
View File
@@ -237,7 +237,9 @@ def region_items(node: Contact, table: RegionTable, unsaved: int) -> tuple[str,
# A blank line between the table and the commands of the page, because they are two lists
# and not one. The first row of the tree is the wildcard. Code that measures icon
# columns reads its name ``*`` as a leading mark. But the name is data, and it does not
# belong to a column that the actions keep.
# belong to a column that the actions keep. This blank line is the seam between two
# lists, not the space of a section. It stays when the section spacing is off, and the
# list adds no second line after it (Separator.spaced).
items.append(Separator(" "))
items.append(section_heading("Actions"))
items.extend(
+18 -2
View File
@@ -47,7 +47,14 @@ from ..core.models import (
RoomAccess,
RoomLogin,
)
from .chat import ChatScreen, _belongs, _contact_names, _make_paths_presenter, _with_restore
from .chat import (
ChatScreen,
_belongs,
_contact_names,
_make_paths_presenter,
_with_restore,
our_node,
)
if TYPE_CHECKING:
from ..context import AppContext
@@ -141,6 +148,8 @@ class RoomScreen(ChatScreen):
key_of: Callable[[str], str | None] | None = None,
locator: Locator | None = None,
pages: ContactPages | None = None,
self_name: str | None = None,
self_key: str | None = None,
) -> None:
"""Build the room screen.
@@ -169,6 +178,8 @@ class RoomScreen(ChatScreen):
key_of: Gives the key of its node for a name, for the hues of ``@mention``.
locator: The located nodes, for ^L: the map on the author of a post.
pages: The contacts, for ^O: the node page of the author of a post.
self_name: The name of our node, on our posts and on the mentions of us.
self_key: Our public key, which gives the hue of that chip.
"""
super().__init__(
conversation,
@@ -181,6 +192,8 @@ class RoomScreen(ChatScreen):
key_of=key_of,
locator=locator,
pages=pages,
self_name=self_name,
self_key=self_key,
)
self._label = conversation.label
self._resolve = resolve
@@ -425,6 +438,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
async def join(ask: bool) -> RoomLogin | None:
return await join_room(ctx, await fresh(), ask=ask)
self_name, self_key = await our_node(ctx)
screen = RoomScreen(
conversation,
board,
@@ -440,7 +454,9 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
paths=await _make_paths_presenter(ctx, conversation, device),
key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at),
locator=await Locator.gather(ctx),
pages=ContactPages(ctx, list(contacts)),
pages=ContactPages(ctx, list(contacts), self_key=self_key or ""),
self_name=self_name,
self_key=self_key,
)
ctx.chat.set_active(conversation.key)
+20 -8
View File
@@ -20,7 +20,15 @@ from ..platforms import get_platform
from .packet_viewer import KIND_ICONS, PAYLOAD_ICONS
from .theme import _VT_SLOTS, fold_text, glyph, name_style, snr_style
from .tui.fkeys import FPair, active_deck
from .widgets import DESTINATION, ORIGIN, _recency_style, channel_glyph, name_chip
from .widgets import (
ORIGIN,
_recency_style,
author_chip,
channel_glyph,
mention_chip,
name_chip,
self_chip,
)
#: Demonstration identities for the row of name colours: ``(name, key)``. The keys are
#: synthetic. We chose them so that their first bytes are in three different sectors of
@@ -28,6 +36,9 @@ from .widgets import DESTINATION, ORIGIN, _recency_style, channel_glyph, name_ch
#: ``theme._NODE_SLOT_HEXES``).
_DEMO_NAMES = (("Alice", "a1b2"), ("Lakeside", "3d63"), ("Lounge", "cc10"))
#: The key of our own node in the specimen, which gives the hue of its chat chip.
_DEMO_SELF_KEY = "5e7f"
def _palette_row() -> Text:
"""The 16 slots as filled blocks: first the dim bank, then the bright bank.
@@ -133,20 +144,21 @@ def specimen_lines() -> list[RenderableType]:
# The same names as chips, as the chat draws them. A sender label closes on the point,
# because a message goes on from its author. The mention at the end opens on the
# notch, because the node that it names is where the message goes. The path line's
# own segment draws them, so this row is also a one-hop sample of the route language:
# the hue in the fill, the star for our end, and the keyless grey for a node that
# nobody can identify.
# notch, because the node that it names is where the message goes, and it has no ``@``.
# The path line's own segment draws them, so this row is also a one-hop sample of the
# route language: the hue in the fill, and black on grey for a node that nobody can
# identify. Our own messages go under ``★ me``, the one chip with a white label, on
# the fill of our hue.
chips = Text("chips ")
for name, key in _DEMO_NAMES[:2]:
chips.append_text(name_chip(name, key, role=ORIGIN))
chips.append(" ")
chips.append_text(name_chip("", you=True, role=ORIGIN))
chips.append_text(self_chip("me", _DEMO_SELF_KEY, role=ORIGIN))
chips.append(" ")
chips.append_text(name_chip("·", role=ORIGIN))
chips.append_text(author_chip("·", None))
chips.append(" ")
name, key = _DEMO_NAMES[2]
chips.append_text(name_chip(f"@{name}", key, role=DESTINATION))
chips.append_text(mention_chip(name, key))
lines.append(chips)
lines.append(Text("heard ").append_text(_ages_row()))
+1 -1
View File
@@ -971,7 +971,7 @@ class TuiUi(Ui):
default=default,
keys=keys,
prompt_style=tier,
border_style=tier or "accent",
border_style=tier or "border",
)
async def typed_confirm(self, warning: str, word: str, *, title: str = "Are you sure?") -> bool:
+262 -90
View File
@@ -19,14 +19,19 @@ flow gets that free space back, in three steps over the pushed Contacts list:
rungs show this list too. An age threshold cannot check itself, and to see what the
contacts that it caught did is exactly that check. ``Delete`` removes a row from the
list. Thus the user can spare one contact, and does not have to cancel the full sweep
and select a less aggressive rung. ``←→`` scroll a row that is too wide for the
terminal. Enter on a contact opens its detail page, only to examine the contact. The
page does not show its own verbs that manage contacts (refer to
and select a less aggressive rung. ``Shift+Delete`` also removes the row, and it stages
a lock on the contact. Apply writes the lock, and then no later sweep takes that
contact (refer to :meth:`~meshterm.core.contact_store.ContactStore.set_locked`). Back
and Esc discard the staged locks, after a confirm, as an editor discards its staged
values. ``←→`` scroll a row that is too wide for the terminal. Enter on a contact opens
its detail page, only to examine the contact. The page does not show its own verbs
that manage contacts (refer to
:func:`~meshterm.ui.node_detail_screen.open_node_detail`). A screen where the user
chooses what to archive must not also offer an archive or a delete of one contact from
inside its own list of candidates. The list ends with the Apply/Back pair that
:func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because this list is
that type of choice: a choice with a cost on each side, not an exit.
inside its own list of candidates. A lock is not such a verb: it gives the same answer
as ``Delete``, for all the sweeps that follow. The list ends with the Apply/Back pair
that :func:`~meshterm.ui.menus.exit_rows` draws for staged changes, because this list
is that type of choice: a choice with a cost on each side, not an exit.
3. **Confirm.** An amber Cancel/Archive dialog, not the red typed gate of a deletion,
because this operation is reversible. Each contact keeps its key, its reception history,
and its transcripts. The Archived list is one row away on the Contacts screen, and a
@@ -63,7 +68,7 @@ import math
import time
from collections.abc import Callable
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
from rich.text import Text
@@ -75,10 +80,18 @@ from ..core.contact_score import (
sweep_candidates,
)
from ..core.models import Contact
from .menus import Lane, column_header, fit_cells, section_heading
from .menus import (
Lane,
column_header,
confirm_discard,
fit_cells,
section_heading,
splice_if_room,
)
from .theme import name_style
from .tui import Choice, Separator
from .tui import Choice, SelectScreen, Separator
from .tui.screen import CANCEL
from .tui.select import DeleteRequest, KeyRequest
from .widgets import DEFAULT_GLYPH, NODE_GLYPHS, _recency_style, format_age
if TYPE_CHECKING:
@@ -98,6 +111,14 @@ KEEP_RUNGS: tuple[int, ...] = (90, 75, 60, 50, 25)
_APPLY = ("apply",)
_BACK = ("back",)
#: The token of the lock key, in the :class:`~meshterm.ui.tui.select.KeyRequest` that the
#: preview resolves with (refer to :class:`_PreviewScreen`).
_LOCK = "lock"
#: The footer atom of the lock key. The preview shows it only where the hint has room for it
#: (refer to :func:`~meshterm.ui.menus.splice_if_room`).
_LOCK_HINT = "⇧Del lock"
#: One day in seconds, for the age rungs.
_DAY = 86400
@@ -416,6 +437,7 @@ async def archive_contacts(ctx: AppContext, self_key: str) -> int:
Returns:
How many contacts were archived (``0`` if the user cancelled or no contact matched).
A sweep that only locked contacts also returns ``0``.
"""
from .surface import TuiUi
@@ -430,6 +452,9 @@ async def archive_contacts(ctx: AppContext, self_key: str) -> int:
if not sweepable:
ctx.ui.note("[muted]every contact is protected — nothing to sweep[/muted]")
return 0
# The store keeps a lock under the key of the device. Without the two, the preview has
# no lock key.
can_lock = getattr(ctx, "contact_store", None) is not None and bool(self_key)
while True:
picked = await session.run_screen(_target_screen(ranked, sweepable))
@@ -439,8 +464,9 @@ async def archive_contacts(ctx: AppContext, self_key: str) -> int:
if not victims:
ctx.ui.note("[muted]nothing matches that — no contacts to archive[/muted]")
continue
if await _preview(ctx, victims):
return await _sweep(ctx, self_key, victims)
locks: list[ScoredContact] | None = [] if can_lock else None
if await _preview(ctx, victims, locks):
return await _sweep(ctx, self_key, victims, locks or [])
def age_seconds(scored: ScoredContact) -> float | None:
@@ -495,8 +521,6 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
one heading to the next. Also, the two types of rung look like two ways to answer one
question, not like two separate features that share a screen by chance.
"""
from .tui import SelectScreen
protected = len(ranked) - len(sweepable)
sections: list[tuple[str, list[tuple[str, tuple]]]] = [
(
@@ -539,7 +563,7 @@ def _target_screen(ranked: list[ScoredContact], sweepable: list[ScoredContact]):
)
def _preview_items(victims: list[ScoredContact]) -> list:
def _preview_items(victims: list[ScoredContact], locks: list[ScoredContact] | None = None) -> list:
"""The preview's rows: a pinned column header, one line for each victim, then Apply/Back.
Each contact row is ``deletable``, so ``Delete`` removes it from the sweep. Each row also
@@ -550,39 +574,146 @@ def _preview_items(victims: list[ScoredContact]) -> list:
This function measures the lanes across all the victims. That is also why a spared row
makes this function build all the rows again, instead of a removal of one row: the
value that left may have set a width.
A locked contact has no row: its row left the list when the user locked it, the same
as a spared row. The Apply row counts the staged locks, and the Back row tells that
they go too. When the user removed each victim and some locks are staged, an empty
state stands in place of the rows, and Apply writes only the locks.
"""
widths = _lane_widths(victims)
staged = len(locks or ())
if victims:
widths = _lane_widths(victims)
rows: list = [
Separator(lambda w: _preview_header(widths, w), pinned=True),
*(
Choice(
title=_victim_row(v, widths),
value=v,
deletable=True,
hscroll_from=_MARK_W + _NAME_W,
)
for v in victims
),
]
else:
rows = [Separator("no contacts left to archive", style="muted")]
if not staged:
apply = f"Archive {_count_desc(len(victims))}"
elif victims:
apply = f"Archive {_count_desc(len(victims))} and lock {staged}"
else:
apply = f"Lock {_count_desc(staged)}"
return [
Separator(lambda w: _preview_header(widths, w), pinned=True),
*(
Choice(
title=_victim_row(v, widths),
value=v,
deletable=True,
hscroll_from=_MARK_W + _NAME_W,
)
for v in victims
),
*rows,
# The same shape that ``exit_rows`` draws, in the words of this flow. Apply has no
# keyboard key of its own, so it must have a visible partner row that tells the cost
# of the other way out. This is not ``exit_rows`` itself, because these rows are not
# staged changes to a set of values, and the pair must say what it archives.
Separator(" "),
Choice(title=Text.assemble(("✓ ", "ok"), apply), value=_APPLY),
Choice(
title=Text.assemble(("✓ ", "ok"), f"Archive {_count_desc(len(victims))}"),
value=_APPLY,
title=Text.assemble(
("✗ ", "err"), "Back — change nothing" if staged else "Back — keep them all"
),
value=_BACK,
),
Choice(title=Text.assemble(("✗ ", "err"), "Back — keep them all"), value=_BACK),
]
def _preview_screen(victims: list[ScoredContact]):
"""The preview screen for a victim list, with a size and words that agree with its rows."""
from .tui import SelectScreen
def _has_full_key(value: object) -> bool:
"""Whether a preview row holds a contact with its full key, which a lock must have.
return SelectScreen(
f"Archive contacts — {_count_desc(len(victims))} to archive",
_preview_items(victims),
The store attaches a lock to the full key (refer to
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`). The node page has the
same rule for its ``Lock contact`` row.
"""
if not isinstance(value, ScoredContact):
return False
return len((value.contact.public_key or "").lower().removeprefix("0x")) == 64
class _PreviewScreen(SelectScreen):
"""The preview list, with a lock key beside the spare key.
``Shift+Delete`` on a contact row resolves the list with a
:class:`~meshterm.ui.tui.select.KeyRequest` for :data:`_LOCK`. The caller then stages the
lock and removes the row (refer to :func:`_preview`). The key acts only when the caller
can store a lock (``can_lock``), and only on a contact with a full key.
Two surfaces show the key. On a handheld, the ``Delete`` chip on F3 gets ``Lock`` as its
Shift half. On the desktop, the footer gets the ``⇧Del lock`` atom after ``Del keep``,
but only when the full hint stays in 72 cells. No other atom leaves the hint
to make space for it.
A contact row also says ``Enter open`` instead of ``Enter select``, because Enter on it
opens the node page. The Apply and Back rows keep ``Enter select``.
"""
def __init__(self, *args: Any, can_lock: bool, **kwargs: Any) -> None:
self._can_lock = can_lock
super().__init__(*args, **kwargs)
def _locks_here(self) -> bool:
"""Whether the lock key acts on the highlighted row now."""
current = self._current_choice()
return (
self._can_lock
and current is not None
and current.deletable
and _has_full_key(current.value)
)
def handle(self, action: str, data: str = "") -> None:
"""Resolve ``Shift+Delete`` as a lock request. The list handles all other actions."""
if action == "shift_delete":
current = self._current_choice()
if current is not None and self._locks_here():
self.resolve(KeyRequest(_LOCK, current.value))
return
super().handle(action, data)
@property
def footer_hint(self) -> str:
"""The hint of the list, with ``Enter open`` and, if it fits, ``⇧Del lock`` on a contact."""
hint = super().footer_hint
current = self._current_choice()
if current is None or not current.deletable:
return hint
hint = hint.replace("Enter select", "Enter open")
if self._locks_here():
hint = splice_if_room(hint, _LOCK_HINT)
return hint
@property
def picocalc_lyra_lane(self):
"""The lane of the list, with ``Lock`` behind the ``Delete`` chip on F3."""
lane = list(super().picocalc_lyra_lane)
if self._can_lock and lane[2] is not None:
lane[2] = replace(
lane[2],
opp_label="Lock",
opp_action="shift_delete",
opp_enabled=self._locks_here(),
)
return lane
def _preview_title(victims: list[ScoredContact]) -> str:
"""The title of the preview. It counts the victims, so it changes with them."""
return f"Archive contacts — {_count_desc(len(victims))} to archive"
def _preview_screen(
victims: list[ScoredContact], locks: list[ScoredContact] | None = None
) -> _PreviewScreen:
"""The preview screen for a victim list, with a size and words that agree with its rows.
``locks`` is the list of staged locks, or ``None`` when the caller cannot store a lock.
Then the screen has no lock key.
"""
return _PreviewScreen(
_preview_title(victims),
_preview_items(victims, locks),
prompt="Archived contacts leave the device but stay here, with their history.",
# The base hint does not include the scroll atom or the keep atom. The select list
# adds each atom only when its keyboard key can act: ``←→`` only while the
@@ -594,10 +725,13 @@ def _preview_screen(victims: list[ScoredContact]):
default=_APPLY,
hscroll=True,
hscroll_hint="←→ scroll",
can_lock=locks is not None,
)
async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
async def _preview(
ctx: AppContext, victims: list[ScoredContact], locks: list[ScoredContact] | None = None
) -> bool:
"""Show exactly which contacts will go, weakest first. Return whether the user agreed.
This is the audit step, and the reason why it is safe to offer the sweep. If MeshTerm
@@ -613,18 +747,27 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
value, and the typed filter stays. The screen is not built again, because that would put
the user back at the top of a list that they were halfway down.
``Shift+Delete`` removes a contact in the same way, and it also stages a lock on it. The
lock is staged, not written, because the user decides on Apply (JP, 2026-10-19). Thus
Esc and the Back row discard the locks, and they ask first, as an editor with staged
values does (:func:`~meshterm.ui.menus.confirm_discard`). A spared row is not a staged
change: Esc discarded it before the lock existed, and it does so now.
Args:
ctx: The shared application context.
victims: The candidates, weakest first. This function **changes** the list when the
user removes rows, so the list of the caller is the list that the sweep archives.
locks: The staged locks. This function adds to the list of the caller in the same
way. ``None`` when the caller cannot store a lock: then the preview has no lock
key.
Returns:
Whether the user selected the archive of the contacts that remain after the edits.
Whether the user selected Apply for the contacts and the locks that remain after the
edits.
"""
from .node_detail_screen import open_node_detail
from .tui.select import DeleteRequest
screen = _preview_screen(victims)
screen = _preview_screen(victims, locks)
# A hub: it owns a loop, so it is pushed one time and stays for the full visit. That is
# why a spared row can refresh the rows below the user's highlight. It is also why a
# detail page can nest above this screen, and this screen keeps its place.
@@ -632,24 +775,23 @@ async def _preview(ctx: AppContext, victims: list[ScoredContact]) -> bool:
while True:
chosen = await visit.result()
if chosen is CANCEL or chosen is None or chosen == _BACK:
if locks and not await confirm_discard(ctx, len(locks), verb="applying"):
continue # continue to edit: the same list, at the same place in it
return False
if chosen == _APPLY:
return bool(victims)
if isinstance(chosen, DeleteRequest):
spared = chosen.value
if spared in victims:
victims.remove(spared)
if not victims:
return bool(victims or locks)
if isinstance(chosen, DeleteRequest | KeyRequest):
removed = chosen.value
if removed in victims:
victims.remove(removed)
if isinstance(chosen, KeyRequest) and locks is not None and removed not in locks:
locks.append(removed)
if not victims and not locks:
# The user spared all the candidates. Thus there is no sweep left to
# preview. An empty list with an "Archive no contacts" row would be a
# screen that asks the user to confirm nothing.
return False
# The title shows the count of victims, so it is content too, and it
# changes with them.
screen.replace_items(
_preview_items(victims),
title=f"Archive contacts — {_count_desc(len(victims))} to archive",
)
screen.replace_items(_preview_items(victims, locks), title=_preview_title(victims))
continue
# Each other row is a contact. Enter opens its node detail page, so a user who
# does not recognize a name can examine the node before MeshTerm archives it. The
@@ -668,7 +810,12 @@ def _count_desc(n: int) -> str:
return f"{n} contact{'' if n == 1 else 's'}"
async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -> int:
async def _sweep(
ctx: AppContext,
self_key: str,
victims: list[ScoredContact],
locks: list[ScoredContact] | None = None,
) -> int:
"""Confirm, then remove each victim from the device and archive it here. Return the count.
The confirm is **amber, and the user types nothing**. A bulk deletion has a red typed
@@ -682,52 +829,77 @@ async def _sweep(ctx: AppContext, self_key: str, victims: list[ScoredContact]) -
The store write comes after each successful removal, not in one batch at the end. Thus,
if the sweep is interrupted, the state stays consistent: each contact that is off the
device is stored as archived.
"""
if not await ctx.ui.dialog(
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
"history, and messages — under Archived contacts, and any of them can be restored.",
[("Cancel", False), ("Archive", True)],
title="Archive contacts",
default=1,
danger=True,
):
return 0
device = await ctx.device()
The staged ``locks`` are written after the confirm and before the removals. A lock is a
mark of MeshTerm, in the contact store, and nothing on the device. Thus it cannot fail
as a removal can, and it does not wait for one. When only locks are staged, there is
no confirm: a lock archives nothing, and the node page reverses it.
"""
locks = locks or []
if victims:
also = (
f" MeshTerm also locks {_count_desc(len(locks))}, so that no sweep takes them."
if locks
else ""
)
if not await ctx.ui.dialog(
f"Archive {_count_desc(len(victims))}? They come off this device's contact list, "
"freeing space for new ones. MeshTerm keeps them — with their keys, reception "
"history, and messages — under Archived contacts, and any of them can be "
f"restored.{also}",
[("Cancel", False), ("Archive", True)],
title="Archive contacts",
default=1,
danger=True,
):
return 0
dev_pub = (self_key or "").lower().removeprefix("0x")
store = ctx.contact_store
stamp = int(time.time())
archived = failed = 0
with ctx.ui.progress("Archive contacts") as progress:
task = progress.add_task("Archiving", total=len(victims))
for scored in victims:
contact = scored.contact
try:
await device.remove_contact(contact)
except ContactNotOnDeviceError:
# The contact is already absent from the device. Thus the work of the sweep
# on the device is done, and the archive here is what removes the row. This
# is not a failure.
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
except Exception as exc: # noqa: BLE001 - one bad removal must not stop the sweep
failed += 1
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
continue
finally:
progress.advance(task)
if store is not None and dev_pub:
store.archive(dev_pub, contact, when=stamp)
archived += 1
ctx.devstate.invalidate_contacts()
locked = 0
if store is not None and dev_pub:
for scored in locks:
store.set_locked(dev_pub, scored.contact, True)
locked += 1
if archived and not failed:
outcome = Text(f"✓ archived {_count_desc(archived)}", style="ok")
archived = failed = 0
if victims:
device = await ctx.device()
stamp = int(time.time())
with ctx.ui.progress("Archive contacts") as progress:
task = progress.add_task("Archiving", total=len(victims))
for scored in victims:
contact = scored.contact
try:
await device.remove_contact(contact)
except ContactNotOnDeviceError:
# The contact is already absent from the device. Thus the work of the
# sweep on the device is done, and the archive here is what removes the
# row. This is not a failure.
ctx.log.debug("archive: %s was not on the device; archiving ours", contact.name)
except Exception as exc: # noqa: BLE001 - one bad removal must not stop the sweep
failed += 1
ctx.log.debug("archive: could not remove %s: %s", contact.name, exc)
continue
finally:
progress.advance(task)
if store is not None and dev_pub:
store.archive(dev_pub, contact, when=stamp)
archived += 1
ctx.devstate.invalidate_contacts()
and_locked = f" and locked {locked}" if locked else ""
if not victims:
outcome = Text(f"✓ locked {_count_desc(locked)}", style="ok")
elif archived and not failed:
outcome = Text(f"✓ archived {_count_desc(archived)}{and_locked}", style="ok")
elif archived:
outcome = Text(
f"archived {_count_desc(archived)}; {failed} could not be removed", style="warn"
f"archived {_count_desc(archived)}{and_locked}; {failed} could not be removed",
style="warn",
)
else:
outcome = Text("no contacts could be archived", style="err")
locked_note = f"; locked {_count_desc(locked)}" if locked else ""
outcome = Text(f"no contacts could be archived{locked_note}", style="err")
await ctx.ui.session.message_dialog(outcome, title="Archive contacts")
return archived
+112 -63
View File
@@ -29,18 +29,26 @@ MESH_THEME = Theme(
{
"brand": "bold #5eead4",
"accent": "bold #818cf8",
# The border of each panel and dialog that has no caution tier: the base frame, a
# floating dialog, the title bar of a handheld, and a tab strip. It is the grey of
# ``muted``, and not a hue. A border is chrome, and a blue border was one more colour
# on each screen that had no meaning (JP, 2026-10-14). The two caution tiers keep
# their colours, because there the colour is the meaning: ``warn`` (amber) and
# ``err`` (red). It is darker than ``muted``, so the frame stays behind the text that
# it holds. JP chose it between #a2a2a2 (too light) and #7c7c7c (too dark).
"border": "#8f8f8f",
# The only reverse-video chip: the committing button of a dialog, the Abort of a
# running action, and the insertion slot of the composer where no chip fill carries
# it. It is a *grey* block, and the ink of the page shows through it. It was cyan
# before. That cyan was the teal of the wordmark with a third job (identity, a
# node hue, and now a selection). A chip is chrome. It shows where a press lands,
# and it does not claim a colour. The slate is the slate of ``muted``. It is light
# and it does not claim a colour. The grey is the grey of ``muted``. It is light
# enough that the reversed ink reads on it, in any background of the terminal. It
# must be a *single* theme name. Rich removes without a message a style string that
# mixes a theme name with an attribute (for example "reverse muted" renders as plain
# text). Thus the reverse is part of the definition here, and the call site does
# not add it.
"selected": "reverse bold #94a3b8",
"selected": "reverse bold #a2a2a2",
# The only ink for "this is the one that the user selected". It is the row that the
# ❯ points at, in each select list and in each screen that draws its own rows
# (feed, routes, records, walk, composer). It is also the lit column heading one
@@ -79,6 +87,11 @@ MESH_THEME = Theme(
# of the node hues on purpose (refer to node_style). Thus the user can always find
# "you" easily.
"you": "bold #ffffff",
# The text that the user types in the compose line of a chat: pure white, not bold.
# It is more visible than the transcript above it (JP, 2026-10-13). The ``›`` in
# front of it has the ``cursor`` white, the same as the ``❯`` of a selected
# message.
"compose": "#ffffff",
# The name of a confirmed companion on the picker for the startup device: pure
# white. Thus the devices that we talked to before are more visible than the ports
# that MeshTerm only detected.
@@ -100,7 +113,7 @@ MESH_THEME = Theme(
"ok": "bold #4ade80",
"warn": "bold #fbbf24",
"err": "bold #f87171",
"muted": "#94a3b8",
"muted": "#a2a2a2",
# A node that MeshTerm cannot identify: its ``○`` ring, and its label where a bare
# key or hash is the name (refer to the return of name_style when there is no key).
# It has its own name and does not use ``muted``, because the two styles must not
@@ -108,13 +121,13 @@ MESH_THEME = Theme(
# text. An unidentified node is *content* on which the user can still act. On the
# console this difference is the whole ladder: chrome takes the dark grey slot,
# and this style takes the light grey slot.
"node.unknown": "#94a3b8",
"node.unknown": "#a2a2a2",
# The titles of panels and dialogs: the same hue as the border that they are in,
# one shade brighter. Thus the title reads as part of its frame, and it is still
# different from the frame. There is one entry for each border style that the frame
# compositor receives (refer to theme.title_style).
"title.accent": "bold #a5b4fc",
"title.muted": "bold #cbd5e1",
"title.border": "bold #d4d4d4",
"title.muted": "bold #d4d4d4",
"title.warn": "bold #fcd34d",
"title.err": "bold #fca5a5",
"title.ok": "bold #86efac",
@@ -123,25 +136,25 @@ MESH_THEME = Theme(
# (and not bold). This is the opposite of the brightening of the ``title.*`` styles.
# The hint reads as part of the frame, and it stays behind the frame. There is one
# entry for each border style (refer to theme.hint_style).
"hint.accent": "#6366f1",
"hint.muted": "#64748b",
"hint.border": "#737373",
"hint.muted": "#737373",
"hint.warn": "#f59e0b",
"hint.err": "#ef4444",
"hint.ok": "#22c55e",
"hint.brand": "#2dd4bf",
# A step darker than ``muted``, for placeholder dashes (the missing packet count or
# age of a node). They must stay behind the real, muted values around them.
"faint": "#64748b",
"faint": "#737373",
# The ``── Label ──`` of a section heading: in a grouped list, and the day divider
# of a chat. It is a dark grey, and never the accent of the frame. When the rule of
# a heading had the colour of the border, it looked like part of the frame that it
# is in (JP, 2026-10-02). It is bold, so the label is still a landmark at that
# shade.
"heading": "bold #64748b",
# of a chat. It has the colour of the title of the frame (``title.border``, JP,
# 2026-10-15). It was a dark grey before. It is never the grey of the border itself:
# when the rule of a heading had the colour of the border, it looked like part of
# the frame that it is in (JP, 2026-10-02).
"heading": "bold #d4d4d4",
# A further step darker than ``faint``, for the unlit track of a meter (the SNR
# quality bars). It is dark enough to look like a background, and not like a
# dimmer version of the reading.
"track": "#334155",
"track": "#404040",
"snr.good": "bold #4ade80",
"snr.ok": "bold #fbbf24",
"snr.bad": "bold #f87171",
@@ -158,20 +171,19 @@ MESH_THEME = Theme(
"batt.low": "bold #f87171 on #991b1b",
"batt.flash": "not bold #000000 on #dc2626",
"batt.flash.off": "bold #f87171",
# The node-type *marks*: the only colours for ● ▲ ■ ◉ where a node with a type is
# drawn (refer to ui.widgets.NODE_GLYPHS). They are also the language of the markers
# of the map: clients are the loud pink, repeaters are the calmer violet, rooms are
# a white square, and sensors are an orange dot with a ring. They are named styles
# and not raw hex, so the 16-slot console chooses its slot *on purpose*. A naive
# downsample of the violet gives grey, and a repeater looks like an unknown node.
"type.node": "#f472b6",
"type.repeater": "#a78bfa",
# The node-type *marks*: the colours for ● ▲ ■ ◉ where a node with a type is drawn
# (refer to ui.widgets.NODE_GLYPHS), and the markers of the map. All four are white.
# The shape tells the type, so a hue for each type was a colour with no more
# information (JP, 2026-10-14). They were pink, violet, white, and orange. Each type
# keeps its own style name, so that one type can get a colour again in one place.
"type.node": "#ffffff",
"type.repeater": "#ffffff",
"type.room": "#ffffff",
"type.sensor": "#fb923c",
"type.sensor": "#ffffff",
# A region name (the scope of a flood). It is not a node, so it has no hue from the
# node wheel. It is a light slate. Its slant makes it different from prose, as a
# node wheel. It is a light grey. Its slant makes it different from prose, as a
# tag is different from a sentence.
"scope": "italic #cbd5e1",
"scope": "italic #d4d4d4",
# The features of the basemap for which the *hue* is the information: water is
# blue, parks are green, and a highway is the warm colour. A naive downsample
# damages them (the dark blue becomes grey, the dark green becomes black, and the
@@ -200,13 +212,13 @@ MESH_THEME = Theme(
"heat.hours": "#f87171", # 1 hour
"heat.days": "#b45309", # 1 day
"heat.weeks": "#dc2626", # 1 week
"heat.months": "#94a3b8", # 1 month
"heat.never": "#64748b", # 1 year, and never heard
"heat.months": "#a2a2a2", # 1 month
"heat.never": "#737373", # 1 year, and never heard
# The chip fills of the F-key lane of the PicoCalc (refer to ui.tui.fkeys): grey for
# the plain F1-F5 bank, and green while the Shift watcher reports F6-F10. The text
# is white on both. They are also defined here so that the style names resolve on
# each platform, although only footer_fkeys of the PicoCalc asks for them.
"fkey.chip": "bold #ffffff on #475569",
"fkey.chip": "bold #ffffff on #545454",
"fkey.chip.shift": "bold #ffffff on #16a34a",
# The fills of the Cardputer Zero deck, taken from its own keyboard (JP,
# 2026-09-30). The normal fill is the orange-red in which the fn key and its F4–F8
@@ -225,14 +237,14 @@ MESH_THEME = Theme(
# and links have the two hues that nothing else in a body uses. A struck-out run
# goes back to the placeholder grey. The bullet and the rail are the chrome on which
# a list or a quote hangs.
"md.strong": "bold #e2e8f0",
"md.em": "italic #cbd5e1",
"md.strong": "bold #e7e7e7",
"md.em": "italic #d4d4d4",
"md.code": "#7dd3fc",
"md.link": "underline #a5b4fc",
"md.quote": "italic #cbd5e1",
"md.strike": "strike #64748b",
"md.quote": "italic #d4d4d4",
"md.strike": "strike #737373",
"md.bullet": "#818cf8",
"md.rail": "#475569",
"md.rail": "#545454",
}
)
@@ -246,12 +258,13 @@ MESH_THEME = Theme(
#: moves it to slot N+8. Thus a style for a dim slot must say what it means about
#: bold. It cannot leave the question open, because Rich merges a *base* style into
#: each span that it wraps. Then the ``bold`` of a selected row changes the colour of
#: the span completely (a light-grey unknown hash becomes the white "you", and a purple
#: repeater becomes pink). Thus each style for a dim slot is explicitly ``not bold``
#: the span completely (a light-grey unknown hash becomes the white "you", and a brown
#: highway becomes yellow). Thus each style for a dim slot is explicitly ``not bold``
#: (the colour is necessary, so keep it) or explicitly ``bold`` (the promotion is the
#: intent, and only ``title.muted`` has it). A style is never silent. Never use bold on
#: 5 and 6, because *different* meanings use their partners here (5 purple = the
#: repeater mark, 13 pink = the node mark. 6 cyan = hint.brand, 14 bright cyan = brand).
#: intent, and only ``title.muted``, ``title.border``, and ``heading`` have it). A
#: style is never silent. Never use bold on 5 and 6, because *different* meanings use
#: their partners here (5 purple = the chip fill under a pink name, 13 pink = a node
#: hue. 6 cyan = hint.brand, 14 bright cyan = brand).
#: * Backgrounds can use only slots 0–7 (SGR 40–47).
#: * The six *chromatic bright* slots (9-14) are the place where the spectrum of node
#: names lands. They are the console rendering of the hue wheel that comes from the key
@@ -261,8 +274,8 @@ _VT_SLOTS: tuple[tuple[int, str, str], ...] = (
(1, "red (hint.err)", "#aa0000"),
(2, "green (hint.ok)", "#00aa00"),
(3, "brown/orange (hint.warn, batt.mid, heat.cool, the sensor mark)", "#aa5500"),
(4, "blue (hint.accent, bluetooth bg)", "#0000aa"),
(5, "purple (the repeater mark)", "#aa00aa"),
(4, "blue (bluetooth bg, map.water)", "#0000aa"),
(5, "purple (the chip fill of node hue 300°)", "#aa00aa"),
(6, "cyan (hint.brand)", "#00aaaa"),
(7, "light grey (default text, heat.cold)", "#aaaaaa"),
(8, "dark grey (muted, faint, track)", "#555555"),
@@ -270,9 +283,9 @@ _VT_SLOTS: tuple[tuple[int, str, str], ...] = (
(10, "bright green (ok; node hue 120°)", "#55ff55"),
(11, "bright yellow (warn, heat.warm; node hue 60°)", "#ffff55"),
(12, "bright blue (accent; node hue 240°)", "#5555ff"),
(13, "bright pink (the node mark — the map's pink, for free; node hue 300°)", "#ff55ff"),
(13, "bright pink (node hue 300°)", "#ff55ff"),
(14, "bright cyan (brand; node hue 180°)", "#55ffff"),
(15, "white (you, heat.hot, the room mark)", "#ffffff"),
(15, "white (you, heat.hot, the node-type marks)", "#ffffff"),
)
@@ -336,14 +349,15 @@ def vtrgb_lines() -> str:
#: palette. The theme is literal and not derived, so a reviewer can see each slot choice
#: next to its meaning. The rules for bold-brightness and for backgrounds, which the
#: theme must obey, are in the documentation of :data:`_VT_SLOTS`, and tests pin them.
#: The stock palette gives two important results. Bright pink (13) is the colour of the
#: clients on the map, at no cost. Dim purple (5) replaces the violet of the repeaters
#: on the map. The cost is that the three greys (muted, faint, track) all become slot 8,
#: The stock palette has a cost: the three greys (muted, faint, track) all become slot 8,
#: because the stock palette has only one dark grey.
MESH_THEME_16 = Theme(
{
"brand": "bold color(14)",
"accent": "bold color(12)",
# The border: the light grey, which is the nearest slot to the grey of the regular
# theme. It is ``not bold`` because 7 is a dim slot (refer to the bold rule).
"border": "not bold color(7)",
# Slot 7 is the only grey that the VT can put *behind* a reverse. Backgrounds stop at
# the dim bank, and the dark grey of slot 8 becomes black there. It is the same
# light grey that the F-key lane already uses to fill its chips. This is the
@@ -360,6 +374,8 @@ MESH_THEME_16 = Theme(
"pathline": "none",
"err.reverse": "reverse bold color(9)",
"you": "bold color(15)",
# Slot 15 is already bright, so it needs no bold to be white.
"compose": "color(15)",
"device.known": "bold color(15)",
"qr": "bold color(15) on color(0)",
"bluetooth": "bold color(15) on color(4)",
@@ -371,24 +387,24 @@ MESH_THEME_16 = Theme(
# Light grey, a step *above* the dark grey of muted. The console has only two greys,
# and the hash of an unidentified node is content, not chrome.
"node.unknown": "not bold color(7)",
"title.accent": "bold color(12)",
# The one promotion on purpose: bold moves slot 7 to 15. This title needs a colour
# that is almost white (the regular theme writes it as #cbd5e1).
# Two of the promotions on purpose (``heading`` is the third): bold moves slot 7 to
# 15. These titles need a colour that is almost white (the regular theme writes it
# as #d4d4d4).
"title.border": "bold color(7)",
"title.muted": "bold color(7)",
"title.warn": "bold color(11)",
"title.err": "bold color(9)",
"title.ok": "bold color(10)",
"title.brand": "bold color(14)",
"hint.accent": "not bold color(4)",
"hint.border": "color(8)",
"hint.muted": "color(8)",
"hint.warn": "not bold color(3)",
"hint.err": "not bold color(1)",
"hint.ok": "not bold color(2)",
"hint.brand": "not bold color(6)",
"faint": "color(8)",
# The one dark grey of the console. Slot 8 is already in the bright bank, so bold
# changes nothing.
"heading": "bold color(8)",
# The colour of the title of the frame (``title.border``): bold moves slot 7 to 15.
"heading": "bold color(7)",
"track": "color(8)",
"snr.good": "bold color(10)",
"snr.ok": "bold color(11)",
@@ -401,10 +417,10 @@ MESH_THEME_16 = Theme(
"batt.low": "bold color(9) on color(1)",
"batt.flash": "not bold color(0) on color(1)",
"batt.flash.off": "bold color(9)",
"type.node": "color(13)",
"type.repeater": "not bold color(5)",
"type.node": "color(15)",
"type.repeater": "color(15)",
"type.room": "color(15)",
"type.sensor": "not bold color(3)",
"type.sensor": "color(15)",
# A region name: the VT has no italic, and each chromatic slot is a node hue. Thus
# the style is the plain light grey. The word "scope" in front of it makes it
# different from other text.
@@ -476,9 +492,10 @@ def mark_rgb(colour: str) -> tuple[int, int, int]:
A canvas paints in RGB and not in styles (refer to :mod:`~meshterm.ui.mapcanvas`). A
marker that must agree with its twin that Rich draws gets the *same colour* through
this function. The function accepts both encodings that the marker language uses. One
is a literal ``#rrggbb`` (the fixed map marks in :mod:`~meshterm.ui.marks`). The other
is a theme style name (the node-type marks, which have names so that each platform
chooses its own colour, refer to ``type.*`` of the theme).
is a literal ``#rrggbb`` (for example a grey of the basemap in
:mod:`~meshterm.ui.map_render`). The other is a theme style name (the marks in
:mod:`~meshterm.ui.marks` and the node-type marks, which have names so that each
platform chooses its own colour, refer to ``type.*`` of the theme).
On the 16-slot theme, a ``color(N)`` entry gives the :data:`_VT_SLOTS` RGB of that
slot, and not the stock triple of Rich. The palette of Rich is a shade different from
@@ -538,7 +555,7 @@ def title_style(border_style: str) -> str:
Args:
border_style: The theme name in which the border of the panel is drawn
(``"accent"``, ``"warn"``, and other names).
(``"border"``, ``"warn"``, and other names).
Returns:
The matching ``title.*`` theme name. If no brighter variant is defined, it is
@@ -558,7 +575,7 @@ def hint_style(border_style: str) -> str:
Args:
border_style: The theme name in which the border of the panel is drawn
(``"accent"``, ``"warn"``, and other names).
(``"border"``, ``"warn"``, and other names).
Returns:
The matching ``hint.*`` theme name. If no variant is defined, it is ``"muted"``.
@@ -825,6 +842,36 @@ def _glyph_identity(icon: str) -> str:
return icon
#: The emoticons that stand in for the default reactions where the font has no emoji (the
#: PicoCalc and the Cardputer). Each default reaction is two cells wide, and so is each
#: emoticon. Thus a badge keeps its width. A reaction that is not here goes through the fold
#: at the render boundary, as each other emoji does.
# fmt: off
_EMOTICONS: dict[str, str] = {
"👍": "+1", "👎": "-1", "❤️": "<3", "❤": "<3", "😂": ":D", "😮": ":O", "😢": ":(",
}
# fmt: on
def emoticon(emoji: str) -> str:
"""The rendering of a reaction on the platform: the emoji itself, or its emoticon.
Args:
emoji: The emoji of a reaction (one glyph, refer to
:func:`~meshterm.core.reactions.is_reaction_emoji`).
Returns:
The emoji on a platform that draws emoji. Else its emoticon from
:data:`_EMOTICONS`, or the emoji unchanged when it has none.
"""
return _emoticon_impl(emoji)
def _emoticon_compact(emoji: str) -> str:
"""A platform with no emoji: the emoticon of a reaction, when it has one."""
return _EMOTICONS.get(emoji, emoji)
def _glyph_compact(icon: str) -> str:
"""The PicoCalc: icons become their single-cell console-font glyph."""
return _GLYPH_MAP.get(icon, icon)
@@ -915,7 +962,7 @@ def _nearest_slot_params(background: bool, r: int, g: int, b: int) -> str:
the console spells bright. Thus a bare ``31`` immediately after a ``90`` inherits the
intensity bit, and renders as ``91`` without a message. Quantized art is a run of
colour spans that are next to each other, and no style boundary between them resets
the intensity (the bevel of the wordmark has a slate span in every other span, and a
the intensity (the bevel of the wordmark has a grey span in every other span, and a
map raster has neighbouring cells). Thus the promotion happens in the middle of a row
and gives the wrong colour to half a row. The fourth row of the narrow wordmark (its
only row with a dim slot) was part red and part light red. The split was at each span
@@ -1099,6 +1146,7 @@ def snr_style(snr: float | None) -> str:
_ACTIVE_THEME: Theme = MESH_THEME
_node_impl: Callable[[str], str] = _node_style_spectrum
_glyph_impl: Callable[[str], str] = _glyph_identity
_emoticon_impl: Callable[[str], str] = _glyph_identity
_fold_impl: Callable[[str], str] = _no_fold
#: The map from a marker colour to RGB. :func:`mark_rgb` fills it, and a platform switch
@@ -1109,11 +1157,12 @@ _STYLE_RGB: dict[str, tuple[int, int, int]] = {}
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the choices of the theme that depend on the platform (runs now and at each switch)."""
global _ACTIVE_THEME, _node_impl, _glyph_impl, _fold_impl, _FOLD_TABLE
global _ACTIVE_THEME, _node_impl, _glyph_impl, _emoticon_impl, _fold_impl, _FOLD_TABLE
global _FOLD_FONT, _FOLD_ALLOWED, _FOLD_QUANTIZE
_ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16
_node_impl = _node_style_spectrum if platform.truecolor else _node_style_quantized
_glyph_impl = _glyph_identity if platform.emoji else _glyph_compact
_emoticon_impl = _glyph_identity if platform.emoji else _emoticon_compact
font = FONTS.get(platform.font)
if font is not None:
_FOLD_FONT = font
+1 -2
View File
@@ -63,7 +63,7 @@ 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
from .tui.select import Choice, Separator
from .tui.select import Choice
from .widgets import (
ContactsSort,
_recency_style,
@@ -1504,7 +1504,6 @@ class TimeMachinePickerScreen(ContactListScreen):
prompt=prompt,
lead=[
Choice(command_label("🌐 The whole mesh — days, arrivals, the ledger"), MESH),
Separator(" "),
section_heading("Nodes"),
],
)
+2
View File
@@ -364,6 +364,8 @@ _HINT_KEY_ACTIONS: dict[str, str] = {
"^Home": "ctrl_home",
"^End": "ctrl_end",
"Tab": "tab",
"Del": "delete",
"⇧Del": "shift_delete",
}
#: Marks a word as a key notation of some type, also where this module cannot map it: a
+41 -12
View File
@@ -206,7 +206,7 @@ def _panel_box(
visible: The ANSI lines of the viewport, already sliced and padded to height.
more_above: Whether content continues above the slice.
more_below: Whether content continues below the slice.
border: The name of the border style (``"accent"`` for the focused screen).
border: The name of the border style (``"border"`` for the focused screen).
caption: A short muted credit for the right end of the bottom rule (empty for none).
flush: Remove the one-cell padding inside each side border, for a body that fills
the frame (refer to :attr:`~meshterm.ui.tui.screen.Screen.flush`).
@@ -309,15 +309,17 @@ def _title_bar(
memory of it, to learn something that the row can state directly. (When only one arrow
showed, a blank cell stood in for the other.) Thus the pair is fixed at the left edge,
where the eye starts, and **colour** gives the information. An arrow whose direction
has more content takes the ``accent`` of the border, so it looks like part of the
frame. An arrow with nothing in its direction becomes ``muted``. This is the same
has more content is lit as the title is (``title.border``). An arrow with nothing in
its direction becomes ``faint``. The live arrow once had the grey of the border. When
the border was #7c7c7c, that grey was almost the grey of ``faint``, and the two arrows
looked the same. A lit arrow is clear at each border grey. This is the same
live/dim language that the F-key lane draws one row below (a dim chip keeps its label
and loses its fill). The two are the only indicators of the platform.
The title is centred in the rule between the arrows and the tail, exactly as the Rich
``Panel`` centres its own ``title``. (That panel is the frame with a border on the
desktop, which this bar replaces.) The rule itself takes ``border_style``
(``"accent"``) directly: the same bold weight that a real border has. It does not take
(``"border"``) directly: the same style that a real border has. It does not take
the muted variant of :func:`~meshterm.ui.theme.hint_style`, which is for secondary text
next to a border (the Esc atom below, a footer hint on the other platform).
@@ -350,7 +352,7 @@ def _title_bar(
status: The atoms of the header, for a platform without a header row. ``None`` (or
empty: nothing unread, no pack) draws the bar exactly as it is with a header.
"""
border = "accent"
border = "border"
head_span = 3 # the arrow pair, plus the space between it and the rule
status = status if status is not None and status.cell_len else None
stat_span = status.cell_len + 1 if status else 0 # the space before the status
@@ -371,8 +373,9 @@ def _title_bar(
label_w = heading.cell_len + 2
bar = Text()
bar.append("↑", style=border if more_above else "muted")
bar.append("↓", style=border if more_below else "muted")
lit = title_style(border)
bar.append("↑", style=lit if more_above else "faint")
bar.append("↓", style=lit if more_below else "faint")
bar.append(" ")
if title:
left = max(1, (rule_span - label_w) // 2)
@@ -453,6 +456,7 @@ def compose_base(
# (refer to :class:`~meshterm.ui.tui.screen.ListWindow`) can size its chrome to the
# frame into which it will be sliced.
base.note_viewport(viewport)
base.note_terminal_rows(rows)
body_lines = base.render_body(cols - panel_inset(base.flush))
visible, more_above, more_below = _visible_slice(base, body_lines, viewport)
# The panel wrap is a pure function of what is between its borders. Thus cache it,
@@ -472,7 +476,7 @@ def compose_base(
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(title, visible, more_above, more_below, "accent", caption, flush)
panel = _panel_box(title, visible, more_above, more_below, "border", caption, flush)
body = render_lines(Group(panel, footer_row()), cols)
if footer_lane is None:
_BASE_BOX_CACHE = (key, body)
@@ -481,6 +485,7 @@ def compose_base(
# the Panel. The body gets the full terminal width and one more row.
viewport = max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1)
base.note_viewport(viewport)
base.note_terminal_rows(rows)
body_lines = base.render_body(cols)
visible, more_above, more_below = _visible_slice(base, body_lines, viewport)
status = None if platform.header_row else header
@@ -612,6 +617,27 @@ def fit_hint(hint: str, width: int, *, shed_first: Sequence[str] = ()) -> str:
return crop_cells(Text(fitted), 0, max(1, width - 1)).plain + "…"
def fitted_footer(screen: Screen, width: int) -> str:
"""The footer hint of ``screen`` as the hint line draws it: fitted to ``width`` cells.
A hint that fits is returned as it is. A wider hint loses atoms through
:func:`fit_hint`, and keeps its Esc atom. Before, the hint line cut a wide hint at the
edge, and the cut took the Esc atom first. The width is measured on the plain text,
because a hint can carry markup (the ``[warn]offline[/warn]`` of the map).
Args:
screen: The screen whose hint the footer draws: the top screen, also when it floats.
width: The cells of the hint line.
Returns:
The hint to draw.
"""
hint = screen.footer_hint
if cell_len(Text.from_markup(hint).plain) <= width:
return hint
return fit_hint(hint, width, shed_first=getattr(screen, "spare_hint_atoms", ()))
def compose_startup(screen: Screen, cols: int, rows: int) -> str:
"""Compose a chromeless splash: a centred wordmark above a panel of content size.
@@ -642,6 +668,7 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
# measure). It is never wider than the terminal, and never narrower than the title or the
# hint that it must show.
probe = max(10, min(cols - 6, 100))
screen.note_terminal_rows(rows)
probe_lines = screen.render_body(probe)
measured = max((_ansi_width(line) for line in probe_lines), default=10)
# Size to the widest footer possible, not to the footer of this frame. A screen whose
@@ -695,16 +722,16 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
body = Text.from_ansi("\n".join(visible))
hint = hint_style("accent")
hint = hint_style("border")
subtitle = f"[{hint}]{hint_text}[/]"
if more_above or more_below:
arrow = ("↑" if more_above else " ") + ("↓" if more_below else " ")
subtitle = f"[{hint}]{arrow} · {fit_hint(hint_text, inner_w - 5)}[/]"
panel = Panel(
body,
title=f"[{title_style('accent')}]{screen.title}[/]" if screen.title else None,
title=f"[{title_style('border')}]{screen.title}[/]" if screen.title else None,
subtitle=subtitle,
border_style="accent",
border_style="border",
padding=(vpad, 1),
width=inner_w + 4,
)
@@ -752,6 +779,7 @@ def compose_bare(screen: Screen, cols: int, rows: int) -> str:
# share screen fits its code to the rows). Thus the height is recorded before the body
# is requested, not after, as the composers with a frame also do.
screen.note_viewport(rows)
screen.note_terminal_rows(rows)
body_lines = screen.render_body(cols)
while body_lines and not _ansi_width(body_lines[-1]):
body_lines.pop() # a trailing blank row pushes the block off its anchor
@@ -780,6 +808,7 @@ def _dialog_layout(screen: Screen, cols: int, rows: int) -> tuple[int, int, int,
# with a list window (the path composer) can size to what it can use. After that, the
# slice records the real viewport, sized to the body.
screen.note_viewport(budget)
screen.note_terminal_rows(rows)
body_lines = screen.render_body(max_w - 4)
# A grow-only screen (the packet viewer, when it pages between packets) sizes to the
# tallest body that it has shown, not to this body. Thus a shorter body keeps the larger
@@ -865,7 +894,7 @@ def compose_dialog(screen: Screen, cols: int, rows: int) -> str:
"""
max_w, vpad, viewport, body_lines = _dialog_layout(screen, cols, rows)
visible, more_above, more_below = _visible_slice(screen, body_lines, viewport)
border = getattr(screen, "border_style", "accent")
border = getattr(screen, "border_style", "border")
# The resolved hint, not the hint of the screen: it is what the border draws. It changes
# with the lane below, and also with the hint of the screen (refer to _dialog_hint).
hint = _dialog_hint(screen)
+4 -4
View File
@@ -31,16 +31,16 @@ from rich.text import Text
from .render import render_to_ansi
from .spinner import Spinner
#: The colour of the title line (the theme's ``title.accent``, in hex so that the fade can
#: dim it).
_TITLE_COLOR = "bold #a5b4fc"
#: The colour of the title line (the theme's ``title.border``, in hex so that the fade can
#: dim it). It is the title of a dialog, which the card stands in for.
_TITLE_COLOR = "bold #d4d4d4"
#: The colour of the working chip (the theme's ``accent``). The chip has the same one-cell
#: spinner as the other dialogs.
_CHIP_COLOR = "bold #818cf8"
#: The colour of the caption (the theme's ``muted``, in hex so that the fade can dim it).
_CAPTION_COLOR = "#94a3b8"
_CAPTION_COLOR = "#a2a2a2"
class BusyOverlay:
+45 -10
View File
@@ -187,7 +187,12 @@ class LineEditor:
return i
def render(
self, mask: bool = False, *, overflow_at: int | None = None, slots: int | None = None
self,
mask: bool = False,
*,
overflow_at: int | None = None,
slots: int | None = None,
compose: bool = False,
) -> Text:
"""Render the current line with a reverse-video cursor cell.
@@ -200,21 +205,47 @@ class LineEditor:
slots: When set, render exactly this number of fixed positions (a masked PIN
field). A typed position shows a bullet, and a blank position shows a muted
centre dot. This argument has priority over ``mask`` and ``overflow_at``.
compose: When ``True``, draw the line as the compose line of a chat draws it:
the ``›`` in the ``cursor`` white and the text in the ``compose`` white
(refer to :meth:`~meshterm.ui.compose.MentionEditor.render_line`). Thus a
message that the user writes outside a chat looks the same as in a chat.
"""
if slots is not None:
return self._render_slots(slots)
shown = MASK_MARK * len(self.text) if mask else self.text
text = Text("› ", style="accent")
for i, ch in enumerate(shown):
over = overflow_at is not None and i >= overflow_at
if i == self.cursor:
text.append(ch, style="err.reverse" if over else "reverse")
else:
text.append(ch, style="err" if over else None)
if compose:
# The style goes on the ``›`` alone, as in the chat. As the base style of the
# line, it also colours the typed text, and the compose white is then bold.
text = Text()
text.append("› ", style="cursor")
else:
text = Text("› ", style="accent")
self._append_run(text, shown, 0, len(shown), overflow_at, "compose" if compose else None)
if self.cursor >= len(shown): # cursor past the last character → trailing block
text.append(" ", style="reverse")
return text
def _append_run(
self,
text: Text,
shown: str,
start: int,
end: int,
overflow_at: int | None,
style: str | None = None,
) -> None:
"""Append the characters ``shown[start:end]`` to ``text``, one cell for each character.
The character at the cursor is in reverse video. Each character at or after
``overflow_at`` is in the error style. Each other character has ``style``.
"""
for i in range(start, end):
over = overflow_at is not None and i >= overflow_at
if i == self.cursor:
text.append(shown[i], style="err.reverse" if over else "reverse")
else:
text.append(shown[i], style="err" if over else style)
def _render_slots(self, slots: int) -> Text:
"""Render a fixed-width masked field: filled bullets and centred dots for blanks.
@@ -277,6 +308,7 @@ class TextScreen(_KeylessDialog):
help_text: str = "",
password: bool = False,
byte_limit: int | None = None,
compose: bool = False,
footer_hint: str = "Enter accept · Esc cancel",
) -> None:
"""Build a text prompt.
@@ -296,6 +328,8 @@ class TextScreen(_KeylessDialog):
submission when the text is longer than the limit. Use it for a field that
feeds a packet with a size limit (a direct message, a courier entry). Keep
``None`` for a field with no limit.
compose: When ``True``, the field is a message, and it has the colours of the
compose line of a chat (refer to :meth:`LineEditor.render`).
footer_hint: The key hint in the footer.
"""
super().__init__()
@@ -307,6 +341,7 @@ class TextScreen(_KeylessDialog):
self._help = help_text
self._password = password
self._byte_limit = byte_limit
self._compose = compose
self._error = ""
@property
@@ -342,7 +377,7 @@ class TextScreen(_KeylessDialog):
if self._prompt:
parts.append(Text(self._prompt))
parts.append(Text(""))
field = self._editor.render(mask=self._password)
field = self._editor.render(mask=self._password, compose=self._compose)
if self._byte_limit is not None:
# The gauge pins to the right edge of the field: a steady fuel gauge in the corner,
# exactly as the chat compose bar draws it (both through ``byte_counter``).
@@ -523,7 +558,7 @@ class ButtonDialog(_KeylessDialog):
prompt_style: str = "",
button_style: str = "selected",
button_idle_style: str = "muted",
border_style: str = "accent",
border_style: str = "border",
lane: Sequence[FPair | None] | None = None,
) -> None:
"""Build a button dialog.
+11 -2
View File
@@ -301,7 +301,9 @@ def query_text(query: str) -> Text:
return Text(f"/{query}", style="warn", no_wrap=True)
def right_aligned_tail(body: Text, tail: Text, width: int) -> Text:
def right_aligned_tail(
body: Text, tail: Text, width: int, *, whole: Sequence[tuple[int, int]] = ()
) -> Text:
"""Lay out ``body`` with ``tail`` pinned to the right edge of its last line.
``body`` wraps at ``width`` as usual. ``tail`` is a short status, for example the byte
@@ -315,13 +317,20 @@ def right_aligned_tail(body: Text, tail: Text, width: int) -> Text:
cursor block).
tail: The short run to pin to the right edge.
width: The total render width, in cells.
whole: ``(start, end)`` offsets into ``body.plain`` of the runs that the wrap must
not break at a space inside them: the chip of a mention in the compose line.
A run moves to the next line as one word (refer to :func:`render_hanging`).
Returns:
A single :class:`Text` with embedded newlines, ready for :func:`render_lines`.
"""
width = max(1, width)
console = _console(width)
lines = list(body.wrap(console, width)) or [Text("")]
held, stand_in = _held(body, whole)
lines = list(held.wrap(console, width)) or [Text("")]
if stand_in:
for line in lines:
line.plain = line.plain.replace(stand_in, " ")
combined = Text()
for line in lines[:-1]:
combined.append_text(line)
+36 -1
View File
@@ -43,7 +43,7 @@ POP_ALL = object()
#: anything.
_EDGE_STEPS = {"up": -1, "down": 1}
_EDGE_ENDS = {"home": -1, "ctrl_home": -1, "end": 1, "ctrl_end": 1}
_SNAP_BACK_ACTIONS = frozenset({"enter", "delete"})
_SNAP_BACK_ACTIONS = frozenset({"enter", "delete", "shift_delete"})
class PopToMenu(BaseException):
@@ -155,6 +155,9 @@ class Screen:
#: first and the last row, and the page follows to its ends. It is off where these keys
#: move a text caret instead: the compose line of the chat, the field of a prompt.
home_end_jumps: bool = True
#: The rows of the terminal at the last paint, or ``None`` before the first paint. The
#: frame records it before it asks for the body (refer to :meth:`note_terminal_rows`).
_terminal_rows: int | None = None
@property
def fkey_lane(self):
@@ -537,6 +540,38 @@ class Screen:
"""Record only the viewport height (for callers that do not know the body total)."""
self._scroll_viewport = max(1, viewport)
def note_terminal_rows(self, rows: int) -> None:
"""Record the rows of the terminal, before the body of this paint renders.
The frame calls this method, together with :meth:`note_viewport`, for each type of
frame. :meth:`section_spacing` reads it. The rows must come before the body: rows
recorded after a paint come one paint late, and the first paint of a screen then
comes out without its blank lines and then jumps.
"""
self._terminal_rows = rows
def section_spacing(self) -> bool:
"""Whether a blank line comes before each section of the page, at this paint.
The ``section_spacing_min_rows`` preference decides. A terminal of that many rows
or more gets the line. The line costs one row for each section, so a short
terminal (the Cardputer Zero, or a small window) keeps its rows for content. A
screen that the frame has not painted yet uses the design minimum of the platform
(:attr:`~meshterm.platforms.Platform.readable_rows`). The preference is read at
each paint, so a change on the Preferences page applies when the user comes back.
One rule for each place that has sections: the section headings of a select list
(refer to :attr:`~meshterm.ui.tui.select.Separator.spaced`), and the day dividers
and the authors of a chat.
"""
from ...core.preferences import current as current_preferences
from ...platforms import get_platform
rows = self._terminal_rows
if rows is None:
rows = get_platform().readable_rows
return rows >= current_preferences().section_spacing_min_rows
@property
def _page_step(self) -> int:
"""The lines that PageUp/PageDown move: the viewport, less one line for continuity."""
+40
View File
@@ -255,6 +255,16 @@ class Separator:
nothing, it takes a content line, and it hides the heading above it. It is set
through :func:`~meshterm.ui.menus.section_heading`, or by
hand on a column header that is the only landmark of its block.
spaced: Whether a blank line comes before this row, when the
``section_spacing_min_rows`` preference asks for one at this paint (refer to
:meth:`~meshterm.ui.tui.screen.Screen.section_spacing`). It is set through
:func:`~meshterm.ui.menus.section_heading`, so that each section of each list
gets the same space. A list does not put a blank separator before a heading
for space, because then the space shows also where the preference says no. A
blank separator there is only the seam between two different lists, which
keep their own icon columns (the table and the actions of the region editor).
The line never comes at the top of the list, after a blank line, or under
another heading or a column header, because there it separates nothing.
pans: The column header of a table that ←→ pan as one unit (refer to
:attr:`Choice.pans`). It is drawn in its fullest form, and it slides with the
rows under it. Thus each label stays above its lane. Its layout is the same as a
@@ -267,6 +277,7 @@ class Separator:
style: str = "muted"
pinned: bool = False
heading: bool = False
spaced: bool = False
pans: bool = False
def text(self, width: int) -> str | Text:
@@ -288,6 +299,23 @@ _UNBOUNDED = 10_000
_POINTER = 2
def _spaces(previous: Choice | Separator | None) -> bool:
"""Whether a blank line before a section separates it from ``previous``.
The line separates a section from the content above it: a row, or a note. At the top
of the list there is nothing to separate. After a blank line, the space is already
there. Under a heading or a column header, the heading belongs with the rows below it.
"""
if previous is None:
return False
if isinstance(previous, Choice):
return True
if previous.heading or previous.pinned:
return False
title = previous.title
return callable(title) or bool(str(title).strip())
def _pans(items: list) -> bool:
"""Whether one of ``items`` is a table row or a header that pans (:attr:`Choice.pans`)."""
return any(getattr(item, "pans", False) for item in items)
@@ -830,7 +858,15 @@ class SelectScreen(Screen):
# Thus the code follows the line of the highlight while it draws the rows, and does
# not calculate it from the row index.
cursor_at: int | None = None
spacing = self.section_spacing()
previous: Choice | Separator | None = None # the row drawn before this one, if any
for item in rows:
if isinstance(item, Separator) and item.spaced and spacing and _spaces(previous):
# The blank line before a section. It is not an item, so it never joins
# the block of the heading above it, and the heading still pins at its
# own line.
lines.append("")
previous = item
if isinstance(item, Separator):
# A Text title has its own spans (a column header with two colours). A plain
# string is drawn all in the style of the separator. A separator scrolls
@@ -849,6 +885,10 @@ class SelectScreen(Screen):
elif item.heading:
block = list(drawn)
self._sticky_headers.append((len(lines), block))
elif block is not None:
# The column header of a section, under its heading: it pins
# with the heading, as the preamble below does.
block.extend(drawn)
elif item.pinned:
# A pinned header is drawn outside the body slice and is exactly one
# line tall. Thus it crops like a row and does not wrap. A column header
+19 -6
View File
@@ -102,9 +102,16 @@ from .spinner import spinner_interval
#: ``IXON``/``IXOFF``, so ^Q is not XON here.
_CTRL_LETTER_CHORDS: dict[str, str] = {
"c": "quit",
# ^E reacts to the selected message with an emoji. The raw mode of prompt_toolkit has no
# line editing, so ^E is not "end of line" here (the End key moves the caret).
"e": "react",
# ^G logs in to the room that a room screen shows. In the input, ^G is not a special key:
# it is the bell only in output, and no terminal or multiplexer takes it.
"g": "login",
# ^K locks or unlocks the author of the selected message in a conversation (JP,
# 2026-10-19). No terminal or multiplexer takes it, and the raw mode of prompt_toolkit
# has no binding for it.
"k": "lock",
"l": "locate",
# ^O opens the node page of the author of the selected message. The raw mode of
# prompt_toolkit clears IEXTEN, so a terminal on macOS or BSD does not take ^O to
@@ -157,6 +164,7 @@ _KEY_ACTIONS: dict[Any, str] = {
Keys.Escape: "escape",
Keys.Backspace: "backspace",
Keys.Delete: "delete",
Keys.ShiftDelete: "shift_delete",
Keys.Tab: "tab",
Keys.BackTab: "shift_tab",
# The function keys, for the F-key lane of a handheld. This table binds all of them,
@@ -488,8 +496,8 @@ def _message_border(message: Text | str) -> str:
A note about a result has its severity as theme spans (``[err]``, ``[warn]``, ``[ok]``).
Thus the border of the dialog can show the same severity. An error message gets the
``err`` border. A warning (for example "device rebooting") gets the caution border. All
the other messages, also the success messages, get the standard accent. A plain string
has no spans, and it is always neutral.
the other messages, also the success messages, get the grey of the standard border. A
plain string has no spans, and it is always neutral.
Args:
message: The message of the dialog, with styles or plain.
@@ -503,7 +511,7 @@ def _message_border(message: Text | str) -> str:
return "err"
if "warn" in styles:
return "warn"
return "accent"
return "border"
class Visit:
@@ -1521,6 +1529,7 @@ class TuiSession:
help_text: str = "",
password: bool = False,
byte_limit: int | None = None,
compose: bool = False,
floating: bool = False,
) -> str | None:
"""Show a text prompt. Return the string, or ``None`` if the user cancelled.
@@ -1534,6 +1543,9 @@ class TuiSession:
``byte_limit`` puts the shared UTF-8 byte gauge on the field, and blocks a submission
that is longer than the limit. Use it for a field that goes into a packet with a
maximum size (refer to :class:`TextScreen`).
``compose`` gives the field the colours of the compose line of a chat. Use it for a
field that holds a message.
"""
screen = TextScreen(
title,
@@ -1543,6 +1555,7 @@ class TuiSession:
help_text=help_text,
password=password,
byte_limit=byte_limit,
compose=compose,
)
runner = self.run_dialog if floating else self.run_screen
result = await runner(screen)
@@ -1565,7 +1578,7 @@ class TuiSession:
prompt_style: str = "",
button_style: str = "selected",
button_idle_style: str = "muted",
border_style: str = "accent",
border_style: str = "border",
lane: Sequence[fkeys.FPair | None] | None = None,
) -> Any:
"""Show a centred button dialog. Return the chosen value, or ``None`` if cancelled.
@@ -1654,7 +1667,7 @@ class TuiSession:
("✓ flood advertisement sent") is too small for a full result screen. Thus it floats
as a small dialog over the screen below it. Enter (OK) or Esc closes it. The border
takes the strongest tone of the message (refer to :func:`_message_border`). Thus an
error shows in red, and a success stays in the standard accent. The method calls
error shows in red, and a success stays in the grey of the standard border. The method calls
:meth:`button_dialog`, which floats the dialog over a blank base when the stack is
empty (a tool that runs directly from the menu, which is popped while the tool
runs).
@@ -2209,7 +2222,7 @@ class TuiSession:
# centred below its banner, instead of stretched across the terminal.
if not base.chrome:
return self._emit(frame.compose_startup(base, cols, rows))
footer = self.top.footer_hint if self.top else base.footer_hint
footer = frame.fitted_footer(self.top or base, cols)
lane = self._fkey_lane(self.top or base)
return self._emit(
frame.compose_base(self._header(cols), base, footer, cols, rows, footer_lane=lane)
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more