Author SHA1 Message Date
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 MartineauandClaude Opus 5.5 488db7f278 MeshTerm 0.11.1
Installers / cardputer-zero-deb (push) Canceled after 0s
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / send the Cardputer package to the app store (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 00:06:30 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b434cb1643 The changelog names #26 under the identity key fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 23:53:36 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 723f7bdfef The identity key shows, imports from a file, and imports on an SPI radio
Show private key drew its warning and no key in the menu since 0.10.0.
On the uConsole and the Cardputer Zero, MeshTerm now writes an imported
key into the node's identity.key and restarts the node. An import also
reads a file (hex text or raw bytes), expands a 32-byte seed, refuses a
key of the wrong length before the confirmation, and names a firmware
that has its import turned off. The run log no longer stores the key of
config import-key (a migration removes the stored ones), and an exported
key file is written with mode 0600. Refer to #26.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-12 23:51:03 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 7209d89911 The store job brings JP's fork of the store up to date before it publishes
czdev starts its branch from the fork's default branch, and the store's
check compares the PR branch with the store's main directly. After
M5Stack removed every app on 2026-10-12, the fork was 10 commits behind,
so the 0.11.0 PR (#176) carried all 40 removed apps as new files, and
the check failed on the first of them. store-publish.py now calls
GitHub's merge-upstream for the fork first, and publishes nothing if
that fails. PR #176 was repaired by hand with "Update branch".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:57:20 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 1853e5e603 MeshTerm 0.11.0
Installers / cardputer-zero-deb (push) Canceled after 0s
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
GitHub release / send the Cardputer package to the app store (push) Canceled after 0s
GitHub release / put the version on meshterm.net (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:49:12 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 b4b75008d2 The Cardputer Zero is supported, and its page says how to install it from the store
The page no longer warns that the handheld is not supported. It now gives
three ways to install MeshTerm: M5Stack's app store first, then the same
package from a GitHub release, then the source code for developers. The
README, the docs index, and the hardware guide say the same, and the
hardware guide says that the launcher selects the platform.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 10:49:12 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 9ae0947717 A shared name goes to the newest key unless two keys used it within a week
Greying every name that two keys ever used was too strict: a reflash with an
erase makes a new key, and 34 names on this mesh have one, JP's own among
them. Smog went grey this morning because of two keys silent since September.

The newest key now gets the name, and the name is grey only when another key
used it within name_overlap_days (a new Display preference, "Shared names",
default 7) of it: two live claimants. A contact that was never heard never
wins. On this machine's real data, LOG.IC 3R goes to its active key 47368a7c,
Smog to fa331c83, and 8 of 883 names stay grey.

The stored names come with their times from the one scan node_names() already
ran (node_names_with_times), so opening a chat costs no second scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 09:49:21 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 28529d6b62 The node page shows its map preview again, except on a short frame
A contact's page has enough action rows that, on a 24-row terminal, they left
the preview under its five-row floor, and it stepped aside everywhere. It now
keeps its floor and the page scrolls. Only a short frame (the Cardputer's
11-row viewport) leaves it out.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-11 09:34:53 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 ad8792f34b A name that two nodes use resolves to no key, so it is grey
A name is a label any key can claim, and a channel message carries only its
sender's name. When two nodes use one name, the resolver used to let the last
contact win, which handed one node's hue, map, and page to a message that
nothing tied to it: a second "LOG.IC 3R" with a new key took the first one's
green and location. Such a name now resolves to None, like any unplaced name.
A contact's full key and the stored id of the same node still count as one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 04:02:17 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2d2128718a ^O opens the author's node page, and ^G logs in to a room
In a channel, a DM, or a room, ^O on a selected message opens the node page
of its author, the same page the Contacts list opens: our own for a message
of ours, the contact the author's key finds otherwise (ContactPages, the twin
of the map's Locator). It opens to look, without the management verbs, which
belong to the list a contact is in. The hint names ^O only where it acts; the
handheld chip is Author, on Shift+F3 behind Map, since F1-F3 are full.

The room's login moves from ^O to ^G.

The Unreleased changelog now also says where the Map chip went, and covers
the shared lane and the route-end chips.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 00:25:35 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 00a7b82347 The conversations' F2 chip reads URL→QR
It names what the key does with the selection: its link, shown as a code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 23:17:18 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 444b0ea669 A channel, a DM, and a room share one F-key lane, and the day jump leaves it
F1-F3 hold what the three conversations share, in the hint's order: Paths,
QR, Map. The Shift bank holds each screen's own verbs: ^R on Shift+F1 (Retry
in a DM and a room, Resend in a channel) and the room's Log in on Shift+F2.
F4/F5 stay the pager with Latest/Oldest behind Shift.

Day ↑ and Day ↓ are gone from the lane; the desktop keeps the day jump on
^PgUp/^PgDn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 22:06:50 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 f07bb9e1dd The ends of a route are origin and destination chips in four more places
The Messages list's preview draws its author as an origin chip (no colon: the
point says it) and its mentions as destination chips, as the transcript does.
The packet viewer's from rows close on the point and its to row opens on the
notch, around the via chain that already opens and closes on both. The live
feed's addressed subject is an origin chip and a destination chip facing each
other across one cell of page (widgets.route_ends): a seam would claim the two
are neighbours. A pair too wide for the lane cracks instead of eliding. The
courier's recipient is a destination chip, in the hue of its key while it
waits and muted once finished.

Arrow mode reads exactly as before everywhere. A lone hop there carries no
PATH_INK stamp, so a highlighted list row whitens it like any other name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:45:24 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 87baa480a9 A message's author chip closes on the point, and a mention is a chip that opens on the notch
A message goes from its author toward the nodes it names, so the two chips are
the two ends of one route that is not drawn. name_chip takes a role (ORIGIN or
DESTINATION); arrow mode ignores it, so a degraded terminal draws exactly what it
did before. A chip in a message body never breaks across lines: render_hanging
holds each whole run together, spaces included. _sender_style lost its last
caller and is gone; its tests now read the rendered chips.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-10 17:22:59 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 125ddb4003 ^L shows a message's author or a contact on the map, and ^O logs in to a room
locate moves from ^Y to ^L, and the room's login from ^L to ^O, under the
chord rule of 3675226. On the map and in the mesh walk, ^L still goes to
our node. In a chat and on a room's board, ^L opens the map on the author
of the selected message (our own message: our node); on Contacts, on the
highlighted node. map_screen.Locator holds the markers that
gather_markers returns when the screen opens and answers find() at each
paint, so the "^L map" hint atom and the Shift+F1 Map chip show only
where the key acts. A room keeps Log in on Shift+F1, so ^L has no chip
on a room's board yet.

The chat's hints for a selected message go through _fit_hint, which
drops ↑↓ and then ^P only when the line passes 72 cells; the room now
reuses them. LaneDeck.action_for returns None for a slot that has only
a Shift companion.

Right Ctrl with a letter chord still does nothing on the Canadian
Multilingual layout: JP chose not to repair that.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:20:42 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 3675226a40 A Ctrl letter may carry another verb on a screen that does not use its own
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 16:19:17 -04:00
Jean-Pierre MartineauandClaude Haiku 5.5 db62ddf701 Enter on the live feed's pin opens the packet viewer on the pin
The viewer now opens on the stop the feed has. On the pin (^), the card
follows the stream and the feed stays pinned; on a selected packet, the
viewer holds that packet as before. Enter no longer drops the pin.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-10 01:36:53 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c8f7e7ec48 The store listing has its title, summary, and description in four languages
The Store app on the Cardputer knows English, simplified and traditional
Chinese, and Japanese, and shows English for any other language. It falls
back to en and then to zh-CN, so the locales carry en too: without it, an
English user would see the Chinese text. PR #164 already has the same
locales, so later releases keep them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:29:05 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0d08e4bb5e The PicoCalc's radio recovers from a broken frame, and its flash path works as the guide says
The XIAO stopped answering on 10-07 ("No response from meshcore
node" for many starts in a row). MeshCore's serial parser reads '<'
and a two-byte length, then waits for that many bytes with no time
limit, so one lost or damaged byte can make it swallow up to 65,535
bytes, and MeshTerm sends only 16 per start. Reproduced on the
PicoCalc: a half frame blocked two APP_STARTs until 600 filler bytes
arrived. meshcore-frame-timeout.patch drops a frame after 200 ms of
silence on the line (bytes queued while the loop was busy still
count), and build-firmware.sh applies it after meshcore-uart1.patch.
Upstream dev and main still have the old parser.

Flashing the XIAO with the new firmware, following the guide as
written, found these problems, now corrected:

- Step 11: `pip install platformio` failed (no pip outside a venv on
  this PC), and it fails on externally managed Pythons or lands in an
  active venv. The guide now uses the official installer only, and
  build-firmware.sh and flash.py find pio in ~/.platformio/penv, so
  nobody edits the PATH. uf2conv runs on PlatformIO's Python, because
  a bare `python` is missing on many Linux systems.
- Step 12: the script's last line said to double-tap reset first;
  flash.py touches the XIAO itself, and a wired XIAO's reset button is
  inside the case.
- Step 13: this XIAO's bootloader has only a serial port, and flash.py
  printed a pio command for the user to run "from your MeshCore
  checkout", with pio not on the PATH. flash.py now does the serial
  DFU upload itself (54 s here). Without pyserial, it runs itself
  again with PlatformIO's Python instead of skipping the touch.
- A wired XIAO's USB power goes backward into the Lyra through the
  3V3 wire (JP confirmed it on the bench). Step 13 now applies only
  before wiring, and a new Day-to-day procedure updates the firmware
  of a wired radio: poweroff, switch off, open, batteries out, flash.

After the flash, the radio kept its identity, MeshTerm's info command
connected, and an APP_START after a half frame and 0.5 s of silence
got its reply at once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:26:39 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 2bb563605f The style and licence tests skip the files that git ignores
Both tests walk scripts/ with rglob, so they also read the MeshCore
checkout that build-firmware.sh makes in scripts/picocalc-lyra/
xiao-radio/_meshcore-build. Its third-party code and libraries have
contractions and no SPDX lines, and every developer who followed the
PicoCalc guide got three failures. conftest.not_ignored asks git
check-ignore which paths it ignores and removes them; a tracked file
is never ignored, and without git the tests examine everything, as
before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:26:27 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 3f7180b866 The store job waits while a PR for an earlier version is open
A release before M5Stack merges the first store PR would open a second
PR that adds the same files, so the two would conflict and both would
wait for a person. store-publish.py now publishes nothing while any
open PR of ours is for the package, and in CI it shows a warning. The
release itself goes out as usual. After the merge, a rerun of that
release's store job publishes its version; the release skill says how.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:40:49 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 c5e7ce5a62 A release sends the Cardputer Zero package to M5Stack's app store
installers.yml gains a cardputer-zero job: build-deb.sh in a Debian 13
container on an arm64 runner, the same system as the Cardputer. The
release attaches the .deb with the other downloads and lists it in
SHA256SUMS, and its notes say where Cardputer users get MeshTerm.

github-release.yml gains a store job after the release. It runs
scripts/cardputer-zero/store-publish.py, which calls czdev publish with
two corrections: the manifest's email is johnputer@meshterm.net, not
the GitHub profile's, and the branch name stays the same between
czdev's two calls (for a fork, the second call named a branch that was
never pushed, and GitHub refused the PR). It also sends czdev's git
over HTTPS with the token, and it publishes nothing when the store has
the version or a PR for it is open, so a rerun opens no second PR.
AppBuilder is pinned to the commit whose functions it corrects.

The job needs the CARDPUTER_STORE_TOKEN secret (public_repo scope). The
release skill now checks the store PR and counts fifteen assets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 04:32:35 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 cc626160d4 The store listing has its six screenshots, taken on the Cardputer
scripts/cardputer-zero/screenshot.py reads the display's framebuffer
over SSH and writes it as a PNG of 320x170, the size the store wants.
It only reads, so it does not disturb the app. JP set up each screen
on the device: the splash, the menu, the Public channel, a contact's
routes, the map, and a packet from the live feed. The listing passes
the store's own metadata check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 03:00:19 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 0b764e4a04 A script builds the Debian package for the Cardputer Zero's app store
scripts/cardputer-zero/build-deb.sh runs on arm64 Debian 13 (the
Cardputer itself) and makes meshterm_<version>-<revision>_arm64.deb.
MeshTerm and all its Python packages, the spi extra included, go in
/usr/lib/meshterm, compiled to bytecode. The package depends only on
python3: the store app installs a missing dependency with apt-get but
never runs apt-get update, so a Debian dependency can fail. For the
same reason the package carries Terminus beside its OFL, because an app
from the launcher has no terminal for --fetch-fonts.

packaging/notices.py gets a site-directory mode, because the walk of
the closure asks for no extras and so misses the spi packages.

scripts/cardputer-zero/store holds the listing that czdev publish reads
and a 256-pixel icon. The screenshots are still to make.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:39:27 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 93ed530fd7 The checkout's launcher entry has its own name and a negative icon
The store package will own meshterm.desktop and meshterm.png. The
entry that launcher-entry.sh makes is now meshterm-dev.desktop with a
negative of the logo, so that the two look different and can be
installed together.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 02:39:02 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 a594b38f21 The framebuffer test ignores the CI runner's own fb0
test_the_panel_is_written_through_its_mapping named its stand-in file fb0
and trusted that no sysfs node would answer for it. A Linux CI runner has
a real /sys/class/graphics/fb0, so Framebuffer took that device's stride,
mapped far more than the 32-byte file held, and mmap refused. It failed on
every Ubuntu job of the 0.10.3 push; Windows skips it and macOS has no
/sys. The test now stubs the sysfs read, as its comment always assumed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:54:31 -04:00
Jean-Pierre MartineauandClaude Opus 5.5 d3d2dc33b7 The Records screen docstring records the guess for a short hop
A hop that the topology could not resolve keeps its short hash, and the
area drawing places it at the first node that matches. The score leaves
such a hop out. JP chose on 2026-10-07 to keep the guess: the probable
shape is more useful than a shape that has lost a point.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:50:04 -04:00
93 changed files with 5519 additions and 634 deletions

No files matched your search

+30 -4
View File
@@ -10,8 +10,9 @@ point of keeping an `[Unreleased]` section. The one part that takes real work is
the entry, because the changelog is prose someone reads, not a list of commit subjects.
Pushing the tag fires [github-release.yml](../../../.github/workflows/github-release.yml),
which calls `installers.yml`, builds all five platforms, attaches them with a combined
`SHA256SUMS`, and takes the release notes **from that version's changelog section**. So the
which calls `installers.yml`, builds all five platforms and the Cardputer Zero's `.deb`,
attaches them with a combined `SHA256SUMS`, sends the `.deb` to M5Stack's app store (the
`store` job), and takes the release notes **from that version's changelog section**. So the
changelog is not documentation of the release; it *is* the release notes. An empty section
publishes "No changelog entry for X.Y.Z."
@@ -171,6 +172,30 @@ 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:
```
gh pr list -R CardputerZero/packages --author jpmartineau --state all --limit 3
```
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
refuses a czdev without them. The script also fast-forwards JP's fork of the store before
czdev branches from it; it stops if that fails (the fork has commits of its own), because
a stale fork makes the store's check fail on every file the store has removed since. A
rerun is safe — the script publishes nothing when the store
already has the version or a PR for it is open. While a PR for an **earlier** version is
still open (a first release waits for a maintainer and a video), the job only warns: a
second PR would add the same files and conflict. The release itself is not held back. Once
that PR is merged, rerun this release's store job and it publishes:
`gh run rerun <run-id> --job <store-job-id>` (the job id is in `gh run view <run-id>
--json jobs`). The manifest's email must be
`johnputer@meshterm.net`; the script sets it, so check it once on the PR's files.
The notes open with an install block the workflow writes from the tag — the same commands
as the README, but pinned to *this* release's own assets — and the changelog follows under
`## What changed`. **Check that second half actually came out of the changelog**, because a
@@ -184,8 +209,9 @@ If that reads "No changelog entry for X.Y.Z.", the extraction failed rather than
changelog being empty. Fix the workflow, then repair the published notes in place with
`gh release edit vX.Y.Z --notes-file` — the binaries are fine and the tag does not move.
Fourteen assets is the right count — five version-stamped binaries, five version-less
copies of them, `SHA256SUMS`, and the three licence files:
Fifteen assets is the right count — five version-stamped binaries, five version-less
copies of them, the Cardputer Zero's `meshterm_X.Y.Z-1_arm64.deb`, `SHA256SUMS`, and the
three licence files:
```
gh release view vX.Y.Z --json assets --jq '.assets | length'
+49
View File
@@ -187,6 +187,9 @@ jobs:
.\meshterm-@VERSION@-windows-x64.exe
```
**Cardputer Zero** — install MeshTerm from M5Stack's app store on the device. The
same package is attached below as `meshterm_@VERSION@-1_arm64.deb`.
Every binary below is listed in `SHA256SUMS`. The version-less copies —
`meshterm-macos-arm64` and its four siblings — are the same binaries under a name
that never changes, for anything that wants whichever release is newest without
@@ -223,6 +226,52 @@ jobs:
$PRERELEASE
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
# The Cardputer Zero's package goes to M5Stack's app store. `czdev publish` puts it in a
# pull request on CardputerZero/packages, and the store merges an update when its checks
# pass. scripts/cardputer-zero/store-publish.py runs czdev with two corrections and stops
# if the store already has this version or a pull request for it is open. Thus a second
# run of a release opens no second pull request.
store:
name: send the Cardputer package to the app store
needs: publish
if: ${{ !inputs.prerelease }}
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# A token of jpmartineau with the public_repo scope: it uploads to the fork of
# CardputerZero/packages and opens the pull request as its author.
CARDPUTER_STORE_TOKEN: ${{ secrets.CARDPUTER_STORE_TOKEN }}
# The czdev that store-publish.py corrects. Change the two together.
APPBUILDER_COMMIT: 64edc2292628b436d3e624b150faeed2d60c31af
steps:
- name: Check for the token
run: |
if [ -z "$CARDPUTER_STORE_TOKEN" ]; then
echo "::error::CARDPUTER_STORE_TOKEN is not set; scripts/cardputer-zero/store-publish.py says what it needs."
exit 1
fi
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- uses: actions/download-artifact@v4
with:
name: meshterm-cardputer-zero
path: deb
- name: Get czdev
run: |
git init -q appbuilder
git -C appbuilder fetch -q --depth 1 \
https://github.com/m5stack/CardputerZero-AppBuilder.git "$APPBUILDER_COMMIT"
git -C appbuilder checkout -q FETCH_HEAD
- name: Publish
run: |
python3 scripts/cardputer-zero/store-publish.py --czdev appbuilder \
--deb deb/meshterm_*_arm64.deb
website:
name: put the version on meshterm.net
needs: publish
+36
View File
@@ -198,3 +198,39 @@ jobs:
path: |
meshterm-*
if-no-files-found: error
# The Debian package for the Cardputer Zero's app store. It holds compiled code for arm64
# and Python 3.13, so it is built in Debian 13 on an arm64 runner: the same system as the
# Cardputer. The release attaches it with the binaries above, and its `store` job sends it
# to M5Stack's app store.
cardputer-zero:
name: cardputer-zero-deb
runs-on: ubuntu-24.04-arm
container: debian:trixie
timeout-minutes: 30
steps:
# gcc and the headers compile spidev, which has no arm64 wheel on PyPI.
- name: Install the build tools
run: |
apt-get update
apt-get install -y --no-install-recommends \
python3 python3-venv python3-dev gcc libc6-dev ca-certificates
- uses: actions/checkout@v4
- name: Build
run: sh scripts/cardputer-zero/build-deb.sh
- name: Checksum it
working-directory: dist
run: |
for deb in meshterm_*_arm64.deb; do sha256sum "$deb" > "$deb.sha256"; done
cat ./*.sha256
- uses: actions/upload-artifact@v4
with:
name: meshterm-cardputer-zero
path: |
dist/meshterm_*_arm64.deb
dist/meshterm_*_arm64.deb.sha256
if-no-files-found: error
+113 -1
View File
@@ -9,6 +9,116 @@ can change how things behave. It does not only add new things.
## [Unreleased]
## [0.11.1] - 2026-10-13
**Show private key shows the key again, and the uConsole and the Cardputer Zero can import
an identity key.** On each device, the menu showed the warning of Show private key and no
key. On the handhelds that MeshTerm drives over SPI, an import of a key failed. An import
now also reads a file and accepts a 32-byte seed. MeshTerm no longer keeps the private key
in its history of commands.
### Added
- **You can import an identity key from a file.** On Device config, **Identity key…** has
a new row, **Import from a file…**. On the command line, `config import-key --from PATH`
does the same. The file can hold the key as hex text, as `export-key --out` writes it, or
as raw bytes, as the `identity.key` of a node keeps it.
- **An import accepts a 32-byte seed.** The node that MeshTerm runs on an SPI radio keeps
its key as a seed. A firmware companion accepts only the full 64-byte key. Thus MeshTerm
makes the full key from the seed, in the same way as the firmware, before it sends it.
A key of the wrong length now stops at the prompt, before you confirm the import.
### Fixed
- **Show private key shows the key again.** On Device config, **Identity key…** → **Show
private key** showed its warning and no key. This occurred on each device since 0.10.0.
On the command line, `meshterm config export-key` always printed the key.
([#26](https://github.com/jpmartineau/MeshTerm/issues/26))
- **You can import a key on the uConsole and on the Cardputer Zero.** On these handhelds,
MeshTerm runs the node itself, and that node refused each import. Now MeshTerm writes
the key into the node's `identity.key`, and then restarts the node to use it.
([#26](https://github.com/jpmartineau/MeshTerm/issues/26))
- **A firmware that does not accept an imported key says so.** Some firmware builds turn
the import off. MeshTerm waited until the command timed out, and then showed a refusal
with no cause.
- **The private key no longer stays in MeshTerm's history.** MeshTerm stores the arguments
of each command that it runs. The key that you gave to `config import-key` was stored
there as plain text. Now MeshTerm stores `<secret>` in its place, and when this version
opens your database for the first time, it removes the keys that earlier versions
stored.
- **Only you can read an exported key file.** `config export-key --out` and **Export to a
file…** now write the file with mode 0600, as MeshTerm writes each other file that holds
a secret. Before, the other users of the computer could read it.
## [0.11.0] - 2026-10-12
**MeshTerm supports the Cardputer Zero, and from a message you can open its author on the
map or on a node page.** Each release now has a package for the Cardputer Zero, and the
release sends it to M5Stack's app store. In a chat or on the board of a room, ^L opens the
map on the author of a message, and ^O opens the page of that node. A name that two nodes
use at the same time is now grey, because MeshTerm cannot tell which node wrote the
message. On the handhelds, a channel, a direct chat, and a room have the same F-key chips.
### Added
- **MeshTerm supports the Cardputer Zero.** Each release has a Debian package for the
handheld, `meshterm_<version>-1_arm64.deb`, and the release also sends it to M5Stack's
app store. The package has all that MeshTerm needs, which includes the radio library for
the Cap LoRa-1262 and the font of the display. Install it from the store on the
handheld, or from the release page. The store shows a new app after M5Stack examines
it. The [Cardputer Zero
page](https://github.com/jpmartineau/MeshTerm/blob/main/docs/devices/cardputer-zero.md)
has the details.
- **^L shows a node on the map from a chat and from Contacts.** In a chat or on the board
of a room, select a message and press ^L. The map opens on the node that wrote it. On
the Contacts page, ^L opens the map on the highlighted contact. The key works only when
MeshTerm knows where the node is, and the hint at the bottom of the screen names it only
then. On the handhelds, the chip is `Map`, on F3.
- **^O opens the page of the node that wrote a message.** In a chat or on the board of a
room, select a message and press ^O. The node page opens, the same page that the
Contacts list opens. For a message of yours, the page of your own node opens. The key
works only when the author is one of your contacts. On the handhelds, the chip is
`Author`, on Shift+F3.
### Changed
- **^L is now the map key, and ^G logs in to a room.** On the map and in the mesh walk, ^L
goes back to your own node. That key was ^Y. On the board of a room, ^G logs in. That
key was ^L.
- **A channel, a direct chat, and a room have the same F-key chips.** On the handhelds,
F1, F2, and F3 are `Paths`, `URL→QR`, and `Map` on the three screens. With Shift, these
keys hold what only one screen has: `Retry` or `Resend`, and `Log in` on a room. The
`Day ↑` and `Day ↓` chips are gone. On the desktop, ^PgUp and ^PgDn still move from one
day to the next.
- **A name that two nodes use at the same time is grey.** A channel message names its
sender only by the name that the sender typed, and any node can use any name. When two
nodes used the same name within a week, MeshTerm cannot know which one wrote the
message. Thus the name is grey, as an unknown node is, and ^L and ^O do not open
anything for it. When the old node has been quiet for longer than that, the name goes
to the node heard most recently, because a radio that is flashed again gets a new
identity. Before, MeshTerm chose one of the two nodes, often the old one. The new
**Shared names** preference on the Preferences page sets the week.
- **Chips show where a route starts and where it ends.** Where your terminal draws path
chips, the author of a message has a point on the right of its chip. A mention is a chip
with a notch on its left. The packet viewer, the live feed, the Messages list, and the
courier show the sender and the recipient in the same way.
- **Enter on the live feed keeps it on the newest packet.** When the live feed follows new
packets (the pin at the top), Enter opens the packet viewer, and the viewer also follows
new packets. The feed stays on the pin. Before, Enter stopped the feed on one packet.
Enter on a packet that you selected still opens that packet and holds it.
### Fixed
- **The page of a contact shows its map preview again.** On a terminal of 24 rows, the
action rows left too little space, and the preview was gone. Now the preview keeps its
size and the page scrolls. Only the short display of the Cardputer Zero leaves it out.
- **The PicoCalc's radio recovers when a byte is lost on its serial line.** One lost or
damaged byte could make the XIAO wait for data that never came. Then each start of
MeshTerm said "No response from meshcore node". The firmware that the PicoCalc guide
builds now drops an incomplete command after 200 ms of silence. To get this repair, build
and flash the firmware again, as the guide says. The steps of the guide that install
PlatformIO and flash the XIAO are also correct now.
## [0.10.3] - 2026-10-07
**You can join room servers, move your channels to a new device, and find the public
@@ -430,7 +540,9 @@ 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.10.3...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.11.1...HEAD
[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
[0.10.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.2
[0.10.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.10.1
+41 -8
View File
@@ -280,8 +280,13 @@ deliberately, one at a time, and say why in the code.
- 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 (`^P` paths,
`^U` URL, `^Y` you, `^L` log in). A screen that takes no typing may spend a bare letter instead (the
`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,
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 —
`^A`/`^B` are screen's and tmux's prefixes, and `^M`/`^I`/`^H`/`^J` *are* Enter, Tab,
Backspace and LF. A screen that takes no typing may spend a bare letter instead (the
Time Machine's `w`, the dashboard's `s`, the device splash's `h`). Either way the key
is named where it acts: in the hint, and on the PicoCalc's lane.
- Empty states are lowercase muted, optionally `— explanation`, never parenthesized.
@@ -458,6 +463,20 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
where the route began or ended (`PathLine(from_origin=…, to_destination=…)`, defaulting
to a whole route). Arrow mode spells the same claim with the separator alone, a leading
or trailing `→` with no hop on the far side of it.
- **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
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
feed's addressed subject (`widgets.route_ends`: origin chip, one cell of page,
destination chip — never a seam, which would claim the two are neighbours), and the
courier's recipient. Arrow mode ignores the role: a lone name with a stray `→` reads as
a cut route, so there everything reads exactly as before (`Name: `, `you: `, `@Name`,
`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).
- 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 ·
@@ -477,8 +496,11 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow
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**).
A surface holding only a name resolves it first (`make_name_key_resolver`); a node no
key can place — an unresolved sender, a bare hash standing in as a name, an `○` ring —
takes `node.unknown`, THE light grey for an unidentified node, everywhere and on both
key can place — an unresolved sender, a name two keys used within `name_overlap_days`
of each other (a name is a claim anyone can make, so MeshTerm never picks between two
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
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
@@ -754,8 +776,17 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
thing that just isn't available this paint. Dimming is presentational — `handle` stays
the authority and no-ops. The lane is also the *only* affordance advertisement here, so
anything the desktop reaches by a chord or a bare letter (a list's `^PgUp/^PgDn` section
jumps, the Time Machine's `w`, the map's `^Y`, a row's `Del`) earns a slot — otherwise
it is undiscoverable on the device.
jumps, the Time Machine's `w`, the map's `^L`, a row's `Del`) earns a slot — otherwise
it is undiscoverable on the device. One exception, by JP's choice (2026-10-10): the
chat's day jump (`^PgUp/^PgDn`) has no chip; `Day ↑`/`Day ↓` were taken off F1/F2.
- **The three conversations share one lane** (channel, DM, room; JP, 2026-10-10): what
they share sits on F1–F3 plain, in the hint's order — `Paths` (^P), `URL→QR` (^U), `Map`
(^L, empty without a locator) — and each screen's own verbs ride the Shift bank: ^R on
Shift+F1 (`Retry` in a DM and a room, `Resend` in a channel), the room's `Log in` (^G)
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.
- **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
@@ -781,8 +812,10 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
(`emulator/framebuffer.py`, written ahead of the hardware), a Tk window on the desktop
(`emulator/window.py`). The window is the **emulator** for both handhelds —
`meshterm emulate cardputer-zero|picocalc-lyra --mock`, after `meshterm emulate
--fetch-fonts` once (Terminus is OFL, so it is fetched and digest-checked, never
shipped). An `EmulatedDevice` (`emulator/devices.py`) is everything that differs per
--fetch-fonts` once (Terminus is OFL, so it is fetched and digest-checked, never in the
repo or the wheel; the store `.deb`, `scripts/cardputer-zero/build-deb.sh`, carries it
beside `OFL.TXT`, since a launcher app has no terminal to fetch it from — JP,
2026-10-07: "minimize potential problems"). An `EmulatedDevice` (`emulator/devices.py`) is everything that differs per
handheld: the panel, a Linux console's top-left grid and bold-as-bright 4-bit palette
(the PicoCalc), the lane keys drawn under the panel (the Cardputer's 4–8), and how its
keyboard spells Shift+F-keys. It is **not a strict emulator** — it mimics the display's
+11 -5
View File
@@ -195,6 +195,11 @@ pipx install git+https://github.com/jpmartineau/MeshTerm
**Do you not have a radio yet?** `meshterm --mock` runs the complete app on a simulated
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 on the Cardputer Zero](docs/devices/cardputer-zero.md) has the details.
### Coming
**`pip install mesh-term`** will work after the package is published. It is not published
@@ -215,7 +220,7 @@ code survey, which we keep as a historical record. This table is a short version
| [The CLI cookbook](docs/cli/cookbook.md) | Common one-line commands, in groups by the task that you want to do. |
| [Configuring MeshTerm](docs/guide/configuration.md) | The preferences, the device profiles, and all the files that MeshTerm keeps in `~/.meshterm`. |
| [When a companion does not connect](docs/guide/connecting.md) | Bluetooth pairing on each system, how to find the PIN, USB permissions, and each connection error, with the action to take. |
| [MeshTerm on hardware](docs/devices/README.md) | Which handheld manual is the correct one for you: the [PicoCalc](docs/devices/picocalc-lyra.md) build, or the [uConsole](docs/devices/uconsole.md), whose LoRa chip MeshTerm can control directly. The [Cardputer Zero](docs/devices/cardputer-zero.md) is **not supported yet**. |
| [MeshTerm on hardware](docs/devices/README.md) | Which handheld manual is the correct one for you: the [PicoCalc](docs/devices/picocalc-lyra.md) build, the [uConsole](docs/devices/uconsole.md), whose LoRa chip MeshTerm can control directly, or the [Cardputer Zero](docs/devices/cardputer-zero.md), which gets MeshTerm from M5Stack's app store. |
| [How the code is laid out](docs/development/architecture.md) | The layers of the code, and where to put a new feature. |
| [CONTRIBUTING.md](CONTRIBUTING.md) | The development setup and the house rules. |
| [CHANGELOG.md](CHANGELOG.md) | What changed, with the newest change first. |
@@ -250,10 +255,11 @@ PicoCalc](docs/devices/picocalc-lyra.md)** goes from the shopping list for a sto
to a Linux handheld that has a LoRa radio soldered inside. **[MeshTerm on the
uConsole](docs/devices/uconsole.md)** shows how to control the SPI LoRa board of a uConsole
directly with `--spi`. It also shows how to use a small bridge, if you want the node to be
on the mesh at all times. M5Stack's Cardputer Zero also has a page, but it is **[not
supported yet](docs/devices/cardputer-zero.md)**. The scripts that the manuals run on the
device are in [`scripts/`](scripts/). If you do not know which manual is correct for you,
refer to [`docs/devices/README.md`](docs/devices/README.md).
on the mesh at all times. **[MeshTerm on the Cardputer
Zero](docs/devices/cardputer-zero.md)** shows how to install MeshTerm from M5Stack's app
store. On that handheld, MeshTerm drives the Cap LoRa-1262 radio directly. The scripts
that the manuals run on the device are in [`scripts/`](scripts/). If you do not know which
manual is correct for you, refer to [`docs/devices/README.md`](docs/devices/README.md).
Does a companion not connect? **[When a companion does not
connect](docs/guide/connecting.md)** explains Bluetooth pairing on Windows, Linux, and
+1 -1
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) | ⚠️ **Not supported yet.** What exists for M5Stack's Linux Cardputer, and how to try it in the desktop emulator. |
| [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. |
## Reading the code
+5 -3
View File
@@ -768,8 +768,9 @@ Show and change each device setting. If you give no subcommand, it runs `show`.
| `advert [--flood]` | Broadcast an advert. It is zero-hop unless you give `--flood`. |
| `share` | Print this node's contact card as a `meshcore://` URI. |
| `sync-clock` | Set the device clock from this computer. |
| `export-key [--out PATH]` | Export the private key. **Sensitive.** |
| `import-key KEY_HEX --yes` | Import a private key. It overwrites this node's identity. |
| `export-key [--out PATH]` | Export the private key: 64 bytes, as 128 hex digits. **Sensitive.** Only you can read the file that `--out` writes. |
| `import-key KEY_HEX --yes` | Import a private key. It overwrites this node's identity. Give 128 hex digits, or the 64 hex digits of a 32-byte seed. |
| `import-key --from PATH --yes` | Import the key in a file: hex text, as `export-key --out` writes it, or the raw bytes of a node's `identity.key`. |
| `reboot --yes` | Reboot the device. |
| `factory-reset --yes` | Erase all data and reset the settings to the defaults. |
@@ -1749,6 +1750,7 @@ full_width auto auto
color_depth auto auto How many colours to send this terminal (next launch)
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
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
```
@@ -1798,7 +1800,7 @@ code beside each link. The scripted face prints only the link.
**`--json`:**
```json
{"page":"discord","title":"Join Discord","version":"0.10.3","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.1","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 |
+2
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 | |
| send | MeshTerm gives a message, a packet, or a command to the device | emit |
| transmit | A radio puts a packet on the air | |
| broadcast | Transmit to all nodes, with no destination | |
@@ -255,6 +256,7 @@ and in quoted text.
| visible | On the screen | in view |
| viewport | The part of a page, or of the map, that the screen shows. A page that is taller than its viewport scrolls | view, window (for this) |
| list window | The visible rows of a list that scrolls inside a screen (`ListWindow`) | window (alone) |
| locate | Open the map on a node, or move the map to our node (the `locate` action, ^L) | find (for this) |
### The program
+6 -3
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)**: ⚠️ **not supported yet**. MeshTerm has never run on the handheld. The page tells you what works in the emulator |
| 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 |
| 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
@@ -52,6 +52,7 @@ the machine, and why each step is as it is. On the Lyra, only one program can ho
| [`xiao-radio/build-firmware.sh`](../../scripts/picocalc-lyra/xiao-radio/build-firmware.sh) | development machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, and makes the `.uf2` file. |
| [`xiao-radio/flash.py`](../../scripts/picocalc-lyra/xiao-radio/flash.py) | development machine | Flashes the `.uf2` file to the XIAO through its UF2 bootloader. It refuses the wrong board or SoftDevice. When the bootloader gives no drive, it prints the serial-DFU command. |
| [`xiao-radio/meshcore-uart1.patch`](../../scripts/picocalc-lyra/xiao-radio/meshcore-uart1.patch) | none | The two firmware hunks: the companion on `Serial1` of the nRF52, and I²C moved off the UART pads. It is not upstream yet. |
| [`xiao-radio/meshcore-frame-timeout.patch`](../../scripts/picocalc-lyra/xiao-radio/meshcore-frame-timeout.patch) | none | The serial parser of the companion drops a frame whose bytes stop for 200 ms. Thus one lost byte cannot make the radio ignore each command after it. It is not upstream yet. |
| [`xiao-radio/lyra-setup.sh`](../../scripts/picocalc-lyra/xiao-radio/lyra-setup.sh) | the Lyra (root) | A UART1 → GP4/GP5 mux service that stays after a reboot, membership of `dialout`, and a default MeshTerm serial profile for `/dev/ttyS1`. |
| [`xiao-radio/uart1-mux.py`](../../scripts/picocalc-lyra/xiao-radio/uart1-mux.py) | the Lyra (root) | The matrix-IO register poke that routes UART1 to the header pads. The service above runs it once at each boot. |
| [`uconsole/meshterm-spi-bridge`](../../scripts/uconsole/meshterm-spi-bridge) | the radio host (your user) | The **always-on** option. It runs a mesh node in software over an SX1262 on an SPI bus (uConsole AIO, Waveshare HATs). It serves the companion protocol on `127.0.0.1:5000`. Thus the node stays on the mesh while MeshTerm is closed. It has a setup menu, a preflight check, and an optional `systemd --user` service. It runs on `openhop_core` (preferred) or on its predecessor `pymc_core`. The manual describes the venv that it needs on a bookworm uConsole. Most users must use `meshterm --spi` instead. It needs no script and no service, and MeshTerm drives the chip itself while MeshTerm runs. |
@@ -77,8 +78,10 @@ meshterm platform # which platform this terminal resolv
```
The **cardputer-zero** platform is the display of the Cardputer Zero, which is 53×14.
MeshTerm draws this display itself. The platform is [not supported yet](cardputer-zero.md),
thus MeshTerm never detects it. Only `--platform cardputer-zero` selects it.
MeshTerm draws this display itself. MeshTerm does not detect this platform. On the
handheld, the launcher starts MeshTerm's own console, and the console selects the platform
(refer to [the Cardputer Zero page](cardputer-zero.md)). On another computer,
`--platform cardputer-zero` selects it.
The platform needs two things from the console: a font that has the glyphs that the
interface draws, and the stock sixteen-slot palette. The setup script in the PicoCalc
+40 -27
View File
@@ -1,23 +1,16 @@
# MeshTerm on the Cardputer Zero
> [!CAUTION]
> **The Cardputer Zero is not supported yet.**
>
> MeshTerm now runs on a real Cardputer Zero, with M5Stack's Cap LoRa-1262 as its
> radio. But it runs only from a copy of the source code that you set up yourself. There is
> no package in M5Stack's app store. Also, nobody has typed on the keyboard. Its keys
> are checked only when you send key events to it from another computer.
>
> Do not buy a Cardputer Zero to run MeshTerm on it yet. This page will tell you when that
> changes.
M5Stack makes three Cardputers. This page is about the **Cardputer Zero**. It has a
Raspberry Pi Compute Module 0 inside, and it runs Linux. The original Cardputer and the
Cardputer-Adv are ESP32 microcontroller boards. They cannot run MeshTerm, and nothing on
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.
- [The device](#the-device)
- [Where the work stands](#where-the-work-stands)
- [What works](#what-works)
- [Installing it on the device](#installing-it-on-the-device)
- [Trying it in the emulator](#trying-it-in-the-emulator)
- [Radios](#radios)
@@ -41,9 +34,9 @@ The launcher gives an app the whole display and the keyboard, but it gives no te
to run in. Thus MeshTerm has its own console. This is a small program inside MeshTerm. It
draws each character on the display itself and reads the keys directly.
## Where the work stands
## What works
**Done, and tried on a real Cardputer Zero:**
All of these were tried on a real Cardputer Zero:
- MeshTerm starts from M5Stack's app launcher and draws on the display itself.
- MeshTerm has a layout for the display of 14 rows. The title bar also shows the unread
@@ -61,18 +54,36 @@ draws each character on the display itself and reads the keys directly.
forces it. A short press of **Esc** still goes back one screen, as on all other
handhelds.
**Not done:**
- A package for M5Stack's app store. Until there is one, you install MeshTerm
yourself (refer to the next section).
- Typing on the real keyboard. Key events for the arrows, Enter, Esc, Fn+4 to Fn+8, and
Shift were sent to the keyboard from another computer. They worked. The symbols on the
Sym layer agree with M5Stack's own keymap on the handheld. Nobody has typed a
message on it yet.
## Installing it on the device
This section is for users who know a Linux command line. The Cardputer Zero must be on
### From the app store
1. On the Cardputer Zero, open M5Stack's app store.
2. Find **MeshTerm**. It is in the **Radio & Comms** category. Its share code is `MTRM`.
3. Install it.
MeshTerm now has an icon in the launcher. The package has all that MeshTerm needs: its
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.
### From GitHub
Each [release of MeshTerm](https://github.com/jpmartineau/MeshTerm/releases/latest) has the
package that the store has, `meshterm_<version>-1_arm64.deb`. Copy it to the Cardputer
Zero, for example with `scp`. Then install it from a command line on the handheld:
```bash
sudo apt install ./meshterm_*_arm64.deb
```
To remove it, run `sudo apt remove meshterm`.
### From the source code
This way is for developers who know a Linux command line. The Cardputer Zero must be on
Wi-Fi. You also need a way to type commands on it. SSH from another computer is the
easiest way.
@@ -93,8 +104,9 @@ python3 -m venv .venv
scripts/cardputer-zero/launcher-entry.sh
```
MeshTerm now has an icon in the launcher. To remove the icon, run
`scripts/cardputer-zero/launcher-entry.sh remove`.
MeshTerm now has an icon in the launcher. The icon is a negative of the MeshTerm logo. Thus
it is different from the icon of MeshTerm from the app store, and you can have both. To
remove the icon, run `scripts/cardputer-zero/launcher-entry.sh remove`.
### Typing long text from your computer
@@ -172,7 +184,8 @@ command line.
The Cap has no MeshCore firmware of its own. It is only a radio chip. Thus, while MeshTerm
is open, MeshTerm is the node, as it is on the AIO board of a uConsole. MeshTerm needs the
radio library, installed next to it:
radio library. The package from the store has it. If you install MeshTerm with pip,
install the library next to it:
```bash
pip install 'mesh-term[spi]'
+95 -36
View File
@@ -611,21 +611,27 @@ mode. Come back to this phase when you have the soldering iron out.
### Step 11: Install PlatformIO
On your PC, install PlatformIO:
PlatformIO builds the firmware and loads it onto the XIAO. If you already have it (for
example, the PlatformIO extension of VS Code), go to Step 12.
On your PC, install PlatformIO with its
[official installer script](https://docs.platformio.org/en/latest/core/installation/methods/installer-script.html).
The script puts PlatformIO in a Python environment of its own, `~/.platformio/penv`, and
does not change your system Python:
```bash
# on the PC
pip install platformio
# on the PC (Git Bash or WSL on Windows)
curl -fsSL -o get-platformio.py https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py
python3 get-platformio.py # on Windows: py get-platformio.py
```
If you do not want to change the system Python, use the
[official installer script](https://docs.platformio.org/en/latest/core/installation/methods/installer-script.html)
instead. Then make sure that `pio` is on your PATH:
The installer ends with the full path of `platformio`, and it tells you to add its directory
to your PATH. You do not have to. `build-firmware.sh` and `flash.py` look for PlatformIO in
`~/.platformio/penv` when it is not on the PATH.
```bash
# on the PC
pio --version
```
Do not use `pip install platformio`. Many systems refuse a `pip install` into the system
Python (Debian, Ubuntu, and Homebrew mark it "externally managed"). Also, in a shell with an
active virtual environment, the package goes into that environment.
The first firmware build downloads the nRF52 toolchain, which is several hundred
megabytes. Do this where you have a good connection.
@@ -633,16 +639,18 @@ megabytes. Do this where you have a good connection.
### Step 12: Build the firmware
```bash
# on the PC (Git Bash or WSL on Windows)
# on the PC (Git Bash or WSL on Windows), from your MeshTerm checkout
cd scripts/picocalc-lyra/xiao-radio
sh build-firmware.sh
```
The script clones MeshCore, applies a patch, and builds. It ends with this output:
The script clones MeshCore, applies two patches, and builds. The first build takes several
minutes. The script ends with this output:
```
DONE. Firmware: /path/to/scripts/picocalc-lyra/xiao-radio/meshcore-xiao-radio.uf2
Next: put the XIAO in bootloader (double-tap reset) and run: python flash.py
Next: connect the USB-C port of the XIAO to this machine, and run: python3 flash.py
(on Windows: py flash.py)
```
These are the actions of the script. You can do them by hand if you must:
@@ -650,15 +658,15 @@ These are the actions of the script. You can do them by hand if you must:
1. Clone [MeshCore](https://github.com/meshcore-dev/MeshCore) into
`scripts/picocalc-lyra/xiao-radio/_meshcore-build`.
2. Check out the pinned commit `e9edfc8e` on the `dev` branch. This is the commit that the
patch applies to and builds against without a problem.
3. Apply `meshcore-uart1.patch`.
patches apply to and build against without a problem.
3. Apply `meshcore-uart1.patch`, then `meshcore-frame-timeout.patch`.
4. Build the `Xiao_nrf52_companion_radio_serial` PlatformIO environment:
`pio run -e Xiao_nrf52_companion_radio_serial`.
5. Convert the resulting `.hex` file to a `.uf2` file with MeshCore's own converter.
Use the UF2 family id of the nRF52840:
`python bin/uf2conv/uf2conv.py firmware.hex -c -f 0xADA52840 -o meshcore-xiao-radio.uf2`.
#### What the patch changes, and why it is still necessary
#### What the patches change, and why they are still necessary
`meshcore-uart1.patch` changes two files. Both changes are necessary:
@@ -687,6 +695,20 @@ still has no `_serial` companion environment. To build with the patch is the nor
here, not a temporary measure. When the patch is merged, you can delete the `git apply`
step and the patch file.
`meshcore-frame-timeout.patch` changes the serial parser of the companion,
`src/helpers/ArduinoSerialInterface.cpp` and its header. Each protocol frame starts with `<`
and a length of two bytes, and the parser then reads that number of bytes. Without the
patch, the parser has no time limit. If one byte is lost or damaged on the wire, the parser
can read a wrong length of up to 65,535 bytes. Then it ignores each command until that many
bytes come in. MeshTerm sends one short request when it connects. Thus the radio does not
answer for many starts in sequence, and only a power cycle of the XIAO clears the fault.
With the patch, the parser drops a frame when its bytes stop for 200 ms, and the next
command starts a new frame. Bytes that wait in the receive buffer while the firmware is
busy still belong to the frame. Only silence on the wire resets the parser. All the serial
companions of MeshCore have this parser, thus the patch is also a candidate for upstream.
We have not submitted it yet.
#### Building by hand
If you want to follow a newer MeshCore and not the pinned commit, run:
@@ -697,13 +719,16 @@ git clone https://github.com/meshcore-dev/MeshCore.git
cd MeshCore
git checkout dev # or MESHCORE_COMMIT=dev sh build-firmware.sh
git apply /path/to/scripts/picocalc-lyra/xiao-radio/meshcore-uart1.patch
git apply /path/to/scripts/picocalc-lyra/xiao-radio/meshcore-frame-timeout.patch
pio run -e Xiao_nrf52_companion_radio_serial
python bin/uf2conv/uf2conv.py .pio/build/Xiao_nrf52_companion_radio_serial/firmware.hex \
-c -f 0xADA52840 -o meshcore-xiao-radio.uf2
```
If `git apply` fails, upstream has changed and the context of the patch does not match. The
two hunks are small, so you can make them by hand:
If `git apply` fails, upstream has changed and the context of the patch does not match. If
`meshcore-frame-timeout.patch` fails, open it and make its changes by hand in the two files
that it names. The two hunks of `meshcore-uart1.patch` are small, so you can also make them
by hand:
1. In `examples/companion_radio/main.cpp`, find the line
`HardwareSerial companion_serial(1);` and replace it with:
@@ -745,22 +770,25 @@ two hunks are small, so you can make them by hand:
densaugeo/base64 @ ~1.4.0
```
Only the pinned commit (`e9edfc8e`) is known to build without a problem with this patch. A
Only the pinned commit (`e9edfc8e`) is known to build without a problem with these patches. A
newer `dev` can need small changes.
### Step 13: Flash the XIAO
CAUTION: Make sure that the XIAO is the board that you flash and that it has the S140 v7
bootloader. A flash to the wrong board, or to the wrong SoftDevice, can leave a board that
does not start. `flash.py` checks both before it writes.
does not start. `flash.py` checks both before it writes to a UF2 drive. Through serial DFU,
it accepts only the USB ids of the XIAO bootloader.
1. Connect the **USB-C port of the XIAO itself** to your PC. It does not matter yet if the
XIAO is also wired to the Lyra.
2. Run the flash tool:
1. Connect the **USB-C port of the XIAO itself** to your PC. Do this before you wire the
XIAO in Step 14. To flash a XIAO that is already wired, follow "Updating the radio
firmware" in [Day-to-day](#day-to-day) instead. The USB power of a wired XIAO goes
backward into the Lyra.
2. Run the flash tool, in the same directory as Step 12:
```bash
# on the PC
python flash.py
# on the PC (Git Bash or WSL on Windows)
python3 flash.py # on Windows: py flash.py
```
The tool first tries a 1200-baud "touch" over USB serial. This resets the XIAO into its
@@ -768,7 +796,8 @@ bootloader. The touch targets only the USB vendor id of Seeed. Thus it does not
another nRF52840 board on the same bench. If the touch does not work, **double-tap the reset
button of the XIAO** and run the tool again.
When the tool finds the bootloader, it checks two things before it writes anything:
The bootloader then shows a UF2 drive, or only a serial port. When it shows a drive, the
tool checks two things before it writes anything:
- **The `Model` field of `INFO_UF2.TXT`** must say a XIAO. A drive letter is not an
identity. Another board can have the letter that your XIAO just gave up.
@@ -782,17 +811,17 @@ If you are certain, `--force` overrides both checks. A good flash ends with this
DONE. XIAO flashed and rebooting into the radio firmware.
```
**If no UF2 drive ever shows**, this is not always an error. Some XIAO bootloaders show
only a serial port and no mass-storage drive. `flash.py` knows this case. It prints the
fallback command and does not fail silently:
**When the bootloader shows only a serial port**, the tool loads the firmware through serial
DFU itself. The XIAO that we tested has this type of bootloader. The tool runs the
PlatformIO upload in `_meshcore-build`, which sends the firmware that Step 12 built. This
takes approximately one minute. The USB ids of the port identify the XIAO bootloader. The
upload ends with these lines:
```bash
# on the PC, from your MeshCore checkout
pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port <bootloader port>
```
This command ends with `Device programmed.` and the XIAO restarts itself into the radio
firmware.
Device programmed.
...
DONE. XIAO flashed and rebooting into the radio firmware.
```
After the flash, the USB serial port of the XIAO is **silent** to companion frames. The
companion protocol is now on D6/D7, not on USB. This silence is correct. It does not mean
@@ -938,6 +967,24 @@ If an update adds new glyphs to the console font, build the font again afterward
sh /home/meshterm/MeshTerm/scripts/picocalc-lyra/calculinux-console-font-6x12.sh
```
**Updating the radio firmware.** A new firmware for the XIAO goes in through its USB-C port,
as in Step 13. But the 3V3 wire of Step 14 connects the XIAO to the Lyra. When the XIAO gets
USB power, that wire powers the Lyra backward.
1. On the PC, build the firmware first ([Step 12](#step-12-build-the-firmware)). Then the
cable stays connected only for the flash.
2. On the PicoCalc, run `sudo poweroff`. Then set the power switch to off.
3. Remove the back of the shell. Then remove the batteries.
CAUTION: Remove the batteries before you connect the USB-C port of the XIAO. Through the
3V3 wire, the USB power of the XIAO goes backward into the Lyra and the circuits next to
it.
4. Connect the USB-C port of the XIAO to your PC.
5. Run `flash.py` ([Step 13](#step-13-flash-the-xiao)).
6. When the tool shows `DONE`, disconnect the cable.
7. Put the batteries and the back of the shell in again. Then turn the PicoCalc on.
**Changing Wi-Fi.** Run `uwific` as root, as in [Step 7](#step-7-join-wi-fi). The kick at
boot time finds the new network by itself. It needs only that `iwd` knows the network.
@@ -974,7 +1021,7 @@ You do not need to do anything to keep the shell's own font.
| The port opens but the radio never answers | Flash again with the **`_serial`** environment, not `_ble` or `_usb`. Only `_serial` defines `SERIAL_RX`/`SERIAL_TX`. When the XIAO runs the radio firmware, its USB serial must be silent. This is expected and is not a fault. |
| Still silent, and the wiring is correct | Make sure that the firmware has the I²C remap (`PIN_WIRE_SCL=16`, `PIN_WIRE_SDA=17`). A build without the second hunk of the patch looks the same until you check this. |
| The flash was good, but the XIAO never comes back | The board or the SoftDevice is wrong. `INFO_UF2.TXT` must report a XIAO and **S140 v7**, not `6.1.1`. `flash.py` refuses to flash with either mismatch unless you use `--force`. |
| `flash.py` finds no UF2 drive | This is expected on a bootloader that has only CDC. Use the `pio … -t upload --upload-port` command that the tool prints. |
| `flash.py` finds no UF2 drive | This is expected on a bootloader that has only a serial port. The tool then loads the firmware through serial DFU itself. If it says that there is no build, run `build-firmware.sh` first. |
| Nothing on `/dev/ttyS1` after a reboot | Run `systemctl status uart1-radio-mux`, then `journalctl -b -u uart1-radio-mux`. The pin-mux poke must run at each boot. If the oneshot fails, the log tells you why. |
---
@@ -992,6 +1039,18 @@ checked these items by hand:
- the nRF52840 UF2 family id
- the XIAO bootloader ids, against the board files of Adafruit
On 2026-10-08, the author flashed the radio again, with the frame timeout, and checked these
items on the bench:
- the build and the flash with the commands of this guide, on a Windows PC that did not
have the PlatformIO command-line tool
- that the bootloader of this XIAO shows only a serial port, and that `flash.py` then loads
the firmware through serial DFU
- that the USB power of a wired XIAO goes backward into the Lyra
- that the radio keeps its identity through the flash
- that, after the flash, a frame that breaks off no longer blocks the link: the next
`APP_START` after 0.5 s of silence gets its reply
The other facts are the findings of one bench. A second device can give other results:
- **The RK3506 register map** in `uart1-mux.py`. This covers the two register blocks, the
+8
View File
@@ -222,6 +222,14 @@ in `~/.meshterm/radio/spidev1.0/`. The files are `identity.key`, `prefs.json`,
MeshTerm keeps the log of the previous run as `node.log.1`. All of these files stay after
a restart.
**The identity key.** `identity.key` holds the private key of the node: its identity on
the mesh. On Device config, **Identity key…** shows the key, exports it, and imports a new
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
restarts the node, and the reconnect dialog shows until the node is ready again.
**A profile, if you want a name for the connection:**
```toml
+1 -1
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. 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. 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` |
+1 -1
View File
@@ -6,7 +6,7 @@ Connects to a companion over USB, Bluetooth, or TCP. For Windows, macOS, and Lin
from datetime import datetime
__version__ = "0.10.3"
__version__ = "0.11.1"
__author__ = "Jean-Pierre Martineau"
#: The year when MeshTerm was first published. The copyright span starts in this year.
+7 -3
View File
@@ -29,12 +29,13 @@ from pathlib import Path
__all__ = ["write_atomically"]
def write_atomically(path: Path, text: str, *, owner_only: bool = False) -> None:
def write_atomically(path: Path, text: str | bytes, *, owner_only: bool = False) -> None:
"""Replace ``path`` with ``text`` in one step, and make parent directories if necessary.
Args:
path: The file to replace. Its directory is created if it does not exist.
text: The complete new contents, written as UTF-8.
text: The complete new contents. A ``str`` is written as UTF-8, and ``bytes`` are
written as they are.
owner_only: Limit the file to the owner (``0600``) before it goes into position.
Use it for each file that holds a credential. The function applies it to the
temporary file, not to the destination. Thus the real file never exists with
@@ -44,7 +45,10 @@ def write_atomically(path: Path, text: str, *, owner_only: bool = False) -> None
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f"{path.suffix}.{os.getpid()}.tmp")
try:
tmp.write_text(text, encoding="utf-8")
if isinstance(text, bytes):
tmp.write_bytes(text)
else:
tmp.write_text(text, encoding="utf-8")
if owner_only:
try:
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
+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]:
+57 -15
View File
@@ -28,7 +28,7 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcon
from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, ClassVar
from . import transmit_gate
from .channels import CHANNEL_SLOT_PROBE_CAP
@@ -1216,6 +1216,12 @@ class Device(ABC):
events into the domain models in :mod:`meshterm.core.models`.
"""
#: Whether an imported private key takes effect only when the node starts again. A
#: firmware companion uses the new key at once. The node that MeshTerm runs on an SPI
#: radio reads its key only when it starts (refer to
#: :meth:`meshterm.core.spiradio.SpiRadioDevice.import_private_key`).
import_restarts: ClassVar[bool] = False
@abstractmethod
async def connect(self) -> None:
"""Open the connection to the device. Idempotent."""
@@ -1551,12 +1557,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
@@ -1568,12 +1583,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.
@@ -1625,7 +1644,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
@@ -1653,6 +1674,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.
@@ -1665,10 +1688,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)
@@ -1676,7 +1699,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:
@@ -1995,7 +2018,12 @@ class Device(ABC):
@abstractmethod
async def import_private_key(self, key_hex: str) -> None:
"""Import a private key from a hex string (this replaces the device identity)."""
"""Import a private key, and replace the identity of the device with it.
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.
"""
@abstractmethod
async def factory_reset(self) -> None:
@@ -4106,7 +4134,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
@@ -4135,7 +4163,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
@@ -4191,11 +4221,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]
@@ -4555,7 +4585,19 @@ class MeshCoreDevice(Device):
return key.hex() if isinstance(key, (bytes, bytearray)) else str(key)
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
self._ok(await self._require().commands.import_private_key(bytes.fromhex(key_hex)))
from meshcore import EventType
# The firmware has a build flag that turns the import off. Then it answers "disabled",
# and not with an error. The import command of the library waits only for "ok" or an
# 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),
[EventType.OK, EventType.ERROR, EventType.DISABLED],
)
if getattr(event, "type", None) == EventType.DISABLED:
raise DeviceCommandError("this device's firmware does not accept an imported key")
self._ok(event)
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
mc = self._require()
@@ -4863,7 +4905,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():
@@ -4908,7 +4950,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():
+10
View File
@@ -248,6 +248,16 @@ def utcnow() -> datetime:
#: "now" for as long as the false timestamp stays ahead of the wall clock.
ADVERT_FUTURE_SKEW_S = 300
#: How close together (in days) two keys must use one name before MeshTerm calls the name
#: unsure. A name is a label that the owner of a key chooses, and a new key for the same
#: name is usual on a mesh: a reflash with an erase makes a new identity. Thus the key heard
#: most recently gets the name. Only two keys that both used the name within this window
#: make it unsure, because then two nodes claim it at the same time (refer to
#: :func:`~meshterm.services.trace_runner.make_name_key_resolver`). Seven days, from the
#: history of a real mesh (JP, 2026-10-11): it kept the colour of each node that was given
#: a new key, and made grey the names that two active nodes shared.
NAME_OVERLAP_DAYS = 7
def advert_time(last_advert: object) -> datetime | None:
"""Change the Unix timestamp of an advert into a plausible UTC datetime, or ``None``.
+27
View File
@@ -54,6 +54,8 @@ from .advert_store import DEFAULT_QUIET_S, QUIET_CHOICES_S
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):
@@ -289,6 +291,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="room_relogin_minutes",
label="Room re-login",
@@ -488,6 +500,21 @@ PREFERENCES: tuple[PrefSpec, ...] = (
choices=_CONSOLE_FONT_CHOICES,
platforms=frozenset({"picocalc-lyra"}),
),
PrefSpec(
key="name_overlap_days",
label="Shared names",
help="Grey a name two nodes used within this many days",
group="Display",
value_type="int",
# A channel message gives only the name of its sender. When two keys used that
# name within this window, MeshTerm cannot know which one sent it, and the name
# is grey. Outside the window, the key heard most recently gets the name. A larger
# value makes more names grey. 0 always gives the name to the newest key.
default=NAME_OVERLAP_DAYS,
minimum=0,
maximum=3650,
unit="days",
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
+128
View File
@@ -0,0 +1,128 @@
# SPDX-License-Identifier: Apache-2.0
"""The private key of our node: the forms that MeshTerm accepts, and the one form that it sends.
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
32-byte nonce). In hex, this key has 128 digits.
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.
"""
from __future__ import annotations
import hashlib
import re
from pathlib import Path
__all__ = [
"KEY_BYTES",
"SEED_BYTES",
"expand_seed",
"normalize",
"parse_private_key",
"read_private_key_file",
]
#: The length of a private key in the form that a companion exports and imports.
KEY_BYTES = 64
#: The length of an Ed25519 seed, the short form from which a key comes.
SEED_BYTES = 32
#: The largest file that :func:`read_private_key_file` reads. A key in hex, with a line
#: break, is 129 bytes. Thus a larger file is not a key, and the function does not read a
#: large file that the user selected by mistake.
_MAX_FILE_BYTES = 4096
_HEX = re.compile(r"[0-9a-fA-F]*")
def expand_seed(seed: bytes) -> bytes:
"""The 64-byte private key that a 32-byte Ed25519 seed makes.
This is the key generation of the orlp Ed25519 library, which the MeshCore firmware
uses: the SHA-512 digest of the seed, with its first half clamped to make the scalar.
The result has the same public key as the seed, and a firmware companion accepts it.
Raises:
ValueError: If ``seed`` does not have 32 bytes.
"""
if len(seed) != SEED_BYTES:
raise ValueError(f"A seed has {SEED_BYTES} bytes, not {len(seed)}.")
digest = hashlib.sha512(seed).digest()
scalar = bytearray(digest[:32])
scalar[0] &= 0xF8
scalar[31] &= 0x3F
scalar[31] |= 0x40
return bytes(scalar) + digest[32:]
def normalize(raw: bytes) -> bytes:
"""``raw`` as a 64-byte key: a key stays as it is, and a seed is expanded.
Raises:
ValueError: If ``raw`` is neither a key nor a seed. The message is for the user.
"""
if len(raw) == KEY_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)}."
)
def parse_private_key(text: str) -> bytes:
"""The 64-byte key in ``text``, which the user typed or pasted as hex.
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.
Raises:
ValueError: If ``text`` is not hex, or has the wrong length. The message is for the
user.
"""
digits = "".join(text.split())
if not digits or not _HEX.fullmatch(digits):
raise ValueError("A private key has hex digits only (0-9 and a-f).")
if len(digits) % 2:
raise ValueError(
f"A private key has an even number of hex digits. This one has {len(digits)}."
)
return normalize(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.
``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
key is random bytes, and the chance that all of them are hex digits is too small to
matter. But a hex seed that someone wrote into a key file as text is a real mistake,
and as raw bytes it would be read as a different key.
Raises:
OSError: If the file cannot be read.
ValueError: If the file does not hold a key. The message is for the user.
"""
if path.stat().st_size > _MAX_FILE_BYTES:
raise ValueError(f"{path.name} is too large to be a private key.")
data = path.read_bytes()
try:
digits = "".join(data.decode("ascii").split())
except UnicodeDecodeError:
digits = ""
if digits and _HEX.fullmatch(digits):
return parse_private_key(digits)
if len(data) in (KEY_BYTES, SEED_BYTES):
return normalize(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)
+32 -1
View File
@@ -31,7 +31,8 @@ device, not of a profile. Thus, if the user gets to the same radio from the pick
and from a profile the next day, it is still the same node. The first time that a radio is
used, MeshTerm copies the identity and the contacts of the bridge
(:func:`import_bridge_state`). Thus a node that exists already keeps its key, and with
the key its place in the contact lists of all the other nodes.
the key its place in the contact lists of all the other nodes. An imported key goes into
the same file (:meth:`SpiRadioDevice.import_private_key`).
"""
from __future__ import annotations
@@ -48,9 +49,11 @@ from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, replace
from pathlib import Path
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
_log = logging.getLogger(__name__)
@@ -879,6 +882,8 @@ class SpiRadioDevice(MeshCoreDevice):
new device) starts a new node.
"""
import_restarts = True
def __init__(self, wiring: SpiWiring, state: Path, *, node_name: str = "MeshTerm") -> None:
"""Prepare the device. Nothing starts until :meth:`connect`.
@@ -932,6 +937,32 @@ class SpiRadioDevice(MeshCoreDevice):
finally:
await self._stop_node()
async def import_private_key(self, key_hex: str) -> None:
"""Write the key into the node's ``identity.key``. The node uses it when it starts again.
The node of the radio library refuses an import over the companion protocol. It
answers "disabled", as a firmware does when its import is off. But the key of the
node is a file that MeshTerm keeps (refer to :mod:`~meshterm.core.radionode`), and
the node reads it only when it starts. Thus this method writes the file, and does
not send the command. The running node keeps its old identity until it stops. The
caller restarts it (:attr:`~meshterm.core.connection.Device.import_restarts`): the
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 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)}"
)
await asyncio.to_thread(
write_atomically, self._state / "identity.key", key, owner_only=True
)
_log.info("spi radio: wrote a new identity key for %s", self._wiring.spidev)
async def _stop_node(self) -> None:
"""Stop the node, if one runs, and forget it."""
node, self._node = self._node, None
+4 -1
View File
@@ -1,7 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
"""Download the Terminus 12-pixel fonts that the emulator draws in: one time, only on request.
Terminus has the SIL Open Font License. Thus MeshTerm never ships it. This module
Terminus has the SIL Open Font License. Thus the repository and the wheel never carry it.
The Debian package for the Cardputer Zero carries it next to its licence, because an app
from the launcher has no terminal in which to run this download. That build also uses
this module (refer to ``scripts/cardputer-zero/build-deb.sh``). This module
downloads the upstream release and checks that it is the correct archive. Then it
extracts ``ter-u12n.bdf``, ``ter-u12b.bdf``, and the licence next to them into the
directory that the emulator reads (:func:`~.font.font_dir`). ``meshterm emulate
+29 -1
View File
@@ -8,10 +8,11 @@ new tool can store data, and the schema does not have to change again and again.
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
SCHEMA_VERSION = 18
SCHEMA_VERSION = 19
_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_meta (
@@ -395,3 +396,30 @@ def _migrate(conn: sqlite3.Connection) -> None:
# Rows from before this column are NULL too: when MeshTerm stored such a post, the
# author was lost on the way in, and nothing on disk can bring it back.
conn.execute("ALTER TABLE messages ADD COLUMN author TEXT")
stored = conn.execute("SELECT value FROM schema_meta WHERE key = 'version'").fetchone()
if stored is not None and int(stored["value"]) < 19:
# v18 -> v19: no private key in the run log. `config import-key` stored its key as
# plain text in the arguments of its run. The tools now put a secret in a wrapper
# 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)
def _forget_imported_keys(conn: sqlite3.Connection) -> None:
"""Replace the key in each stored ``config import-key`` run with ``<secret>``."""
rows = conn.execute(
"SELECT id, args_json FROM runs WHERE tool = 'config' AND args_json LIKE '%import_key%'"
).fetchall()
for row in rows:
try:
args = json.loads(row["args_json"])
except ValueError:
continue
ops = args.get("ops") if isinstance(args, dict) else None
if not isinstance(ops, list):
continue
for op in ops:
if isinstance(op, list) and len(op) > 1 and op[0] == "import_key":
op[1] = "<secret>"
conn.execute("UPDATE runs SET args_json = ? WHERE id = ?", (json.dumps(args), row["id"]))
+19 -2
View File
@@ -1734,6 +1734,21 @@ class Repository:
The latest name that is not empty, keyed by the stored node id (the key
prefix of 12 hex digits).
"""
return self.node_names_with_times()[0]
def node_names_with_times(self) -> tuple[dict[str, str], dict[str, datetime]]:
"""The latest name of each node (as :meth:`node_names` gives it), and when it used it.
The time is the latest reception that carried the name, with the time from our
clock. The name resolver needs it to know which of two keys that use one name is
the one in use now (refer to
:func:`~meshterm.services.trace_runner.make_name_key_resolver`). The same scan
gives the two, because a second scan of all the history costs as much again, and
a screen that resolves names opens with it.
Returns:
``(names, times)``, each keyed by the stored node id. The times are aware UTC.
"""
# The SQLite bare-column-with-MAX guarantee: in a group with ``MAX(observed_at)``,
# the ungrouped ``name`` comes from the row that has the maximum. Thus one
# aggregate scan gives the latest name of each node, and Python does not walk all
@@ -1741,11 +1756,13 @@ class Repository:
# ``idx_observations_node`` row by row (one random-access read for each entry,
# which is measurably slower than the scan).
rows = self._conn.execute(
"SELECT node, name, MAX(observed_at) FROM observations NOT INDEXED "
"SELECT node, name, MAX(observed_at) AS last FROM observations NOT INDEXED "
"WHERE node IS NOT NULL AND name IS NOT NULL AND name != '' "
"AND kind != 'packet' GROUP BY node"
).fetchall()
return {row["node"]: row["name"] for row in rows}
names = {row["node"]: row["name"] for row in rows}
times = {row["node"]: datetime.fromisoformat(row["last"]) for row in rows}
return names, times
def last_heard_by_node(self) -> dict[str, datetime]:
"""The most recent reception of each node, with the time from our clock.
+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)
+74 -8
View File
@@ -12,8 +12,10 @@ this module.
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from datetime import datetime, timedelta, timezone
from ..core import preferences
from ..core.connection import Device
from ..core.models import Contact, NameKeyResolver, NodeResolver, TraceResult
@@ -182,37 +184,94 @@ def make_key_resolver(contacts: list[Contact] | None) -> NodeResolver:
def make_name_key_resolver(
contacts: list[Contact] | None,
stored_names: dict[str, str] | None = None,
*,
heard: Mapping[str, datetime] | None = None,
overlap_days: int | None = None,
) -> NameKeyResolver:
"""Build a resolver that finds the key of a display name.
The app-wide colour rule takes the hue of each name from the key of the node. Some
places in the UI (the inline sender of a channel message, an ``@mention``, the origin
of a route graph) have only a *name*. This resolver changes that name (casefolded)
back into a key. The contacts of the device win (their keys are canonical). Then the
back into a key. The contacts of the device give the canonical key of a node. Then the
stored names of the recorder add the nodes that the companion never added as
contacts. Thus a known node that is not a contact still gets its own hue. A name that
no node has resolves to ``None``. The caller renders it muted, because colour is only
for identities with a key.
no node has resolves to ``None``. The caller renders it in the grey of an unknown node,
because colour is only for identities with a key.
**When more than one key uses a name, the key heard most recently gets it**, unless
another of those keys used the name within ``overlap_days`` of it. Then the name
resolves to ``None``. A name is a label that the owner of a key chooses. A new key for
the same name is usual: a reflash with an erase makes a new identity, and an old key
that is silent for weeks is the earlier identity of the same owner. But a channel
message carries only the name of its sender, as text that each holder of the channel
secret can write. When two keys use the name at the same time, MeshTerm cannot know
which one sent the message, and a choice between the two is a guess that the hue, the
map (^L), and the node page (^O) then show as a fact.
This rule replaced two others. First, the last contact in the list won: a second
"LOG.IC 3R" with a new key then took the hue and the map of the first. Then each name
that two keys ever used was grey: an old key, silent since September, then made "Smog"
grey in October (JP, 2026-10-11). A key with no time (a contact that was never heard)
never wins against a key with a time. When no key of a name has a time, the name is
unsure. Two ids are one node when one is a prefix of the other: the full key of a
contact and the stored id of the same node are one claim, not two.
Args:
contacts: The known contacts to resolve against (name → public key/key prefix).
Their ``last_seen`` is when each was last heard.
stored_names: The latest advertised name of each stored node id in the recorder
(:meth:`~meshterm.persistence.repository.Repository.node_names`). This
function inverts it into a fallback from name → node id.
heard: When each stored node id last used its name
(:meth:`~meshterm.persistence.repository.Repository.node_names_with_times`).
overlap_days: The window, in days. ``None`` reads the ``name_overlap_days``
preference.
Returns:
A callable that takes a display name and returns the best key hex that we have
for it, or ``None`` when the name matches no known node.
for it, or ``None`` when the name matches no known node, or two that use it at
the same time.
"""
keys: dict[str, str] = {}
if overlap_days is None:
overlap_days = preferences.current().name_overlap_days
window = timedelta(days=max(0, overlap_days))
times = heard or {}
# The nodes that use each name: one [id, last time] entry for each node.
claims: dict[str, list[list]] = {}
def claim(name: str, ident: str, when: datetime | None, *, canonical: bool) -> None:
held = claims.setdefault(name.casefold(), [])
when = _aware(when)
for entry in held:
if entry[0].startswith(ident) or ident.startswith(entry[0]):
if canonical:
entry[0] = ident # the key of a contact replaces the stored id
if when is not None and (entry[1] is None or when > entry[1]):
entry[1] = when
return
held.append([ident, when])
for node, name in (stored_names or {}).items():
ident = node.lower().removeprefix("0x")
if name and ident:
keys.setdefault(name.casefold(), ident)
claim(name, ident, times.get(node), canonical=False)
for c in contacts or []:
ident = (c.public_key or c.key_prefix or "").lower().removeprefix("0x")
if c.name and ident:
keys[c.name.casefold()] = ident # contacts win over stored names
claim(c.name, ident, c.last_seen, canonical=True)
keys: dict[str, str] = {}
for name, held in claims.items():
if len(held) == 1:
keys[name] = held[0][0]
continue
dated = [entry for entry in held if entry[1] is not None]
if not dated:
continue # nothing tells which key uses the name now
newest = max(when for _ident, when in dated)
live = [ident for ident, when in dated if when >= newest - window]
if len(live) == 1:
keys[name] = live[0]
def key_of(name: str) -> str | None:
if not name:
@@ -222,6 +281,13 @@ def make_name_key_resolver(
return key_of
def _aware(when: datetime | None) -> datetime | None:
"""``when`` as an aware time. A time with no zone is UTC, as MeshTerm stores each time."""
if when is None or when.tzinfo is not None:
return when
return when.replace(tzinfo=timezone.utc)
def parse_trace_path(spec: str, contacts: list[Contact] | None = None) -> str:
"""Parse a path spec from the user into the hex path string that ``send_trace`` expects.
+34
View File
@@ -23,6 +23,40 @@ 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.
+76 -19
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,9 +58,20 @@ 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.theme import name_style
from ..ui.pathline import SELF_GLYPH, PathHop
from ..ui.termfont import powerline_enabled
from ..ui.theme import emoticon, name_style
from ..ui.tui import Choice, DeleteRequest, Separator
from ..ui.widgets import NODE_GLYPHS, age_seconds, channel_glyph, format_age
from ..ui.widgets import (
DESTINATION,
NODE_GLYPHS,
ORIGIN,
age_seconds,
channel_glyph,
format_age,
hop_chip,
name_chip,
)
from .base import Tool, ToolResult, register
if TYPE_CHECKING:
@@ -216,8 +227,8 @@ class ChatTool(Tool):
# app-wide colour rule). A name that no contact and no stored advert has stays
# muted. A room post names its author by key, which resolves in the other
# direction.
stored_names = ctx.repo.node_names()
key_of = make_name_key_resolver(contacts, stored_names)
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)
# List the contacts by recency: first the contacts with messages, with the newest
@@ -1020,30 +1031,32 @@ def _preview_text(
It is the same as the live transcript:
- Our own messages get a ``you:`` prefix.
- In a received channel message, the inline ``Name:`` sender gets the hue that comes from
its key (muted when no known node has the name).
- Our own messages start with our author (refer to :func:`_append_author`).
- 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 a bare ``@Name``, in the same way.
- Each ``@[Name]`` mention shows as ``@Name``, in the same way (refer to
:func:`_append_body`).
Thus the list and the chat use the same colour language.
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.
The preview is made *in full*, however long the message is. Before, it was cut to a
fixed 40 cells, and nothing could undo that cut. Now ←→ scroll the row. A preview that
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:
text.append("you: ", style="accent")
_append_author(text, PathHop(SELF_GLYPH, you=True), Text("you: ", style="accent"))
_append_body(text, body_raw, key_of)
elif last.is_channel:
name, body = split_channel_sender(body_raw)
if name is not None:
text.append(name, style=name_style(name, key_of(name)))
text.append(": ", style="muted")
key = key_of(name)
_append_author(text, PathHop(name, key=key), _byline(name, key))
_append_body(text, body, key_of)
else:
_append_body(text, body_raw, key_of)
@@ -1051,26 +1064,70 @@ def _preview_text(
from ..ui.room import author_label
name, key = author_label(last.author, name_of)
text.append(name, style=name_style(name, key))
text.append(": ", style="muted")
_append_author(text, PathHop(name, key=key), _byline(name, key))
_append_body(text, body_raw, key_of)
else:
_append_body(text, body_raw, key_of)
return text
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 _append_author(text: Text, hop: PathHop, byline: Text) -> None:
"""Append the author of a preview: an 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
the chip does not carry a colon. Where the terminal draws no chips, the author is the
``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(" ")
else:
text.append_text(byline)
def _byline(name: str, key: str | None) -> Text:
"""The author of a preview as text: the name in its hue, then a muted colon."""
text = Text(name, style=name_style(name, key))
text.append(": ", style="muted")
return text
def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> 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. It stays
muted when the name resolves to no node that we know.
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.
"""
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(f"@{name}", style=name_style(name, key_of(name)))
text.append_text(name_chip(f"@{name}", key_of(name), role=DESTINATION))
pos = match.end()
if pos < len(body):
text.append(body[pos:], style="muted")
+47 -7
View File
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core.atomicwrite import write_atomically
from ..core.channels import CHANNEL_SLOT_PROBE_CAP
from ..core.config_io import backup_config, plan_restore, read_backup
from ..core.connection import Device, DeviceCommandError
@@ -30,8 +31,9 @@ from ..core.device_config import (
parse_value,
settings_by_category,
)
from ..core.privatekey import normalize, parse_private_key, read_private_key_file
from ..services.clock_sync import set_clock
from .base import Tool, ToolResult, register
from .base import Secret, Tool, ToolResult, register, reveal
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Block, Facts, Listing, Report
@@ -170,11 +172,17 @@ class ConfigTool(Tool):
@config_app.command("import-key", help="Import a private key (overwrites identity)")
def _import_key_cmd(
key_hex: str = typer.Argument(..., help="Private key as hex"),
key_hex: str | None = typer.Argument(
None, help="Private key as hex: 128 digits, or a 64-digit seed"
),
from_file: Path | None = typer.Option(
None, "--from", help="Read the key from this file (hex text or raw bytes)"
),
yes: bool = typer.Option(False, "--yes", help="Confirm this destructive action"),
) -> None:
_require_yes(yes, "import-key overwrites the device identity")
run_tool_command(self, {"ops": [("import_key", key_hex)]})
key = _private_key_argument(key_hex, from_file)
run_tool_command(self, {"ops": [("import_key", Secret(key.hex()))]})
@config_app.command("sync-clock", help="Set the device clock from this computer")
def _sync_clock_cmd() -> None:
@@ -312,8 +320,13 @@ 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":
await device.import_private_key(op[1])
ctx.ui.ack("[ok]✓[/ok] private key imported")
# 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())
if device.import_restarts:
ctx.ui.ack("[ok]✓[/ok] private key imported — the node uses it from its next start")
else:
ctx.ui.ack("[ok]✓[/ok] private key imported")
changes += 1
report.append(_acted("imported", changes=1, imported=True))
elif kind == "factory_reset":
@@ -635,20 +648,28 @@ async def _export_key(
This document holds a private key when the caller asked for one. That is the purpose
of the command, the same as on the plain face, and this function adds no more gate.
The menu has no renderer for the report. Thus there, the function also draws the key
itself, as :func:`_share_contact` draws its code. When the report replaced the line
that printed the key, "Show private key" drew its warning and no key at all.
The file is for the owner only (``0600``), as each other file that holds a credential.
"""
from ..ui import fields
from ..ui.report import BARE, Facts
from ..ui.surface import TuiUi
key_hex = await device.export_private_key()
written: Path | None = None
if out is not None:
written = Path(out)
written.parent.mkdir(parents=True, exist_ok=True)
written.write_text(key_hex, encoding="utf-8")
write_atomically(written, key_hex, owner_only=True)
artifacts.append(str(written))
ctx.ui.ack("[warn]private key written — keep this file secret.[/warn]")
else:
ctx.ui.ack("[warn]private key (keep secret):[/warn]")
if isinstance(ctx.ui, TuiUi):
ctx.ui.note(key_hex)
return Facts(
key="key",
fields=(fields.hexid("private_key", "private_key"), fields.word("path", "path")),
@@ -694,6 +715,25 @@ def _require_yes(yes: bool, what: str) -> None:
raise typer.BadParameter(f"{what}. Re-run with --yes to confirm.")
def _private_key_argument(key_hex: str | None, from_file: Path | None) -> bytes:
"""The 64-byte key that ``config import-key`` got, from its argument or from ``--from``.
The command reads and checks the key before it connects. Thus a bad key sends nothing
to the device, and it is a usage error (exit 2), as each other bad argument is.
Raises:
typer.BadParameter: If the command got no key, two keys, or a key that is not one.
"""
if (key_hex is None) == (from_file is None):
raise typer.BadParameter("give the key as hex, or --from with a file, but not both.")
try:
if from_file is not None:
return read_private_key_file(from_file)
return parse_private_key(key_hex or "")
except (OSError, ValueError) as exc:
raise typer.BadParameter(str(exc)) from None
def _setting_columns() -> tuple:
"""The columns of one setting row, shared by ``config show`` and ``config get``."""
from ..ui import fields
+559 -131
View File
@@ -18,21 +18,25 @@ from __future__ import annotations
import asyncio
import re
import unicodedata
from collections.abc import Awaitable, Callable
from functools import lru_cache
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from ..core import links
from ..core import links, reactions
from ..core.channels import MENTION, split_channel_sender
from ..core.connection import ContactNotOnDeviceError
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
from ..core.regions import WILDCARD
from ..platforms import Platform, on_platform
from .pathline import PATH_INK
from .qr import QrScreen, qr_strip
from .theme import name_style, snr_style
from .theme import emoticon, name_style, snr_style
from .tui.prompt import (
CHANNEL_BYTE_LIMIT,
DM_BYTE_LIMIT,
@@ -42,10 +46,13 @@ from .tui.prompt import (
from .tui.render import render_hanging, render_lines, right_aligned_tail
from .tui.screen import CANCEL, Screen
from .tui.spinner import Spinner, spinner_interval
from .widgets import name_chip
from .widgets import DESTINATION, ORIGIN, name_chip
if TYPE_CHECKING:
from ..context import AppContext
from .map_render import MapMarker
from .map_screen import Locator
from .node_detail_screen import ContactPages
#: The delivery marks for a *resolved* outbound direct message, at the end of its line:
@@ -69,12 +76,37 @@ _URL_GUTTER = 2
#: only on the PicoCalc). Bound for each platform, never asked at each frame.
_URL_CODES = False
#: The mark in front of the count of a reaction. Where the font has no emoji, an emoticon
#: such as ``+1`` or ``<3`` stands in for the emoji, and a bare count after it reads as part
#: of it (``+1 3``). There, the count is ``x3``. Bound for each platform.
_COUNT_MARK = ""
@on_platform
def _bind(platform: Platform) -> None:
"""Hang codes under URLs where the platform draws them (now and at each switch)."""
global _URL_CODES
"""Bind the choices of the transcript that depend on the platform (now and at each switch)."""
global _URL_CODES, _COUNT_MARK
_URL_CODES = platform.url_codes
_COUNT_MARK = "" if platform.emoji else "x"
#: The maximum width of a footer hint, in cells (refer to the UX standards).
_HINT_CELLS = 72
def _fit_hint(atoms: list[str], spare: tuple[str, ...] = ()) -> str:
"""Join the hint atoms that are not empty, and remove ``spare`` atoms until the hint fits.
The ``spare`` atoms go in the given order, and only while the hint is wider than
:data:`_HINT_CELLS`. Thus a key that the line can hold stays on it.
"""
kept = [atom for atom in atoms if atom]
for atom in spare:
if cell_len(" · ".join(kept)) <= _HINT_CELLS:
break
if atom in kept:
kept.remove(atom)
return " · ".join(kept)
class ChatScreen(Screen):
@@ -88,7 +120,15 @@ class ChatScreen(Screen):
opens the delivery paths of the message. In the two types of chat, ^P opens the paths
of the selected message. A path belongs to one message, so when no message is
selected, there is nothing to show. ^U shows the links of the selected message as QR
codes, on the share screen. When no message is selected, Esc leaves the chat.
codes, on the share screen. ^L opens the map on the node of the author of the selected
message, when that node has a location. ^E reacts to the selected message with an emoji
(refer to :mod:`~meshterm.core.reactions`). When no message is selected, Esc leaves the
chat.
A reaction is a message on the wire, but the transcript does not show it as one. It shows
each reaction under the message that it reacts to: the emoji and a count, or the names of
the nodes that reacted while that message is selected. A room has no reactions, so a
room shows each message as it arrived.
A channel with a send scope shows its region in the title (``#ops · yul``). There, ^R
sends a message again *unscoped*: the selected message, or the newest when no message is
@@ -104,80 +144,93 @@ class ChatScreen(Screen):
@property
def picocalc_lyra_lane(self):
"""The PicoCalc lane in the words of the transcript: Latest/Oldest, Paths, Retry.
"""The PicoCalc lane of a conversation: the same lane in a channel, a DM, and a room.
The Shift bank of the shared lane sends the plain ``end``/``home`` actions. But
here the cursor of the compose line already uses them (refer to :meth:`handle`).
Thus, without a change, the two keyboard keys go to the same fallback ("clear the
selection, stay at the tail"), and each one seems to scroll to the bottom. Instead,
the two Shift slots send ``ctrl_end``/``ctrl_home``, and their labels say what
these actions do to a conversation: go back to the *latest* message and the
compose line, or go back to the *oldest*. This is the shared pair with new labels
in the same slots. Thus it keeps the shared sides: each jump is still behind the
page key that goes in the same direction (refer to
:data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). F4/F5 keep the shared paging pair.
From the outside, the transcript moves by one screen, also when it is the selection
that moves.
The plain bank of F1 to F3 has the verbs that all three screens share, in the order
of the hint: ``Paths`` (^P), ``URL→QR`` (^U), and ``Map`` (^L). The Shift bank of F1
and F2 has the verbs of one screen only. ``Retry`` (^R) is on the Shift half of F1 in
a DM and in a room, and ``Resend`` (the same ^R, unscoped) is there in a channel. A
room adds ``Log in`` (^G) on the Shift half of F2 (refer to
:class:`~meshterm.ui.room.RoomScreen`). Thus a key does the same thing on each of
the three screens, and the user finds a verb in the same place (JP, 2026-10-10).
The F3 pair follows the two rules of the lane. *Retry* is not there in a channel,
because a channel message is never acknowledged, so there is nothing to retry. In
a direct chat with nothing outstanding, it is only dim. The Shift slot of a channel
is *Resend* instead: the selected message (or the newest) again, unscoped (refer
to :meth:`_retry_target`). It is there when a resend is connected, and it is dim
until that message is one of ours that went out under a region. *Paths* must have
a selected message to show the paths of, and the two navigation slots must have a
transcript to move through.
The shared verbs act *on* the selected message. ``Paths`` is lit while a message is
selected. ``URL→QR`` shows the codes of the links of the selection on a share
screen, one code at a time with its full URL, and is lit while the selection has a
URL (refer to :meth:`_picked_urls`). On the Cardputer it is the only way to a code.
On the PicoCalc, the transcript already draws each code under its message in the
same size, so there the chip only shows one code alone. ``Map`` is lit while the
author of the selection has a location (refer to :meth:`_locate_target`).
F1/F2 have the **day** jump: the section step of the transcript (its dividers are
days). A grouped select list does the same with ``Sect ↑``/``Sect ↓``, on the same
keyboard keys, which send the same ``ctrl_pageup``/``ctrl_pagedown``. The label
names what the sections *are* here, because of the lane rule that a chip names an
action. A left-hand pair rises toward F1. Thus ``Day ↑`` (back through the
transcript) is on the outer side of ``Day ↓``, the same as the pager on the right
and the list that it copies. These chips are lit only when there is more than one
day to step between. A conversation in one afternoon has no sections, the same as
a list with one section.
``React`` (^E) is on the Shift half of F2, behind ``URL→QR``. A channel and a DM have
it, and a room does not. Thus it goes on the Shift bank, the bank for a verb that not
all three screens share. A room puts ``Log in`` there instead. ``React`` is lit while
the selection is a message that a reaction can name (refer to :meth:`_can_react`).
``QR`` (^U) is on the Shift half of F2, next to ``Retry`` on the Shift half of F3.
The two are actions *on* the selected message: the codes of its links on a share
screen, larger than the codes under it in the transcript. ``QR`` is lit while the
selection has a URL.
``Author`` (^O, the node page of the author of the selection) is the fourth shared
verb, and the plain bank has only three slots. Thus it is on the Shift half of F3,
behind ``Map``, the other verb about the author. It is lit while the author has a
page (refer to :meth:`_author_page`). Each of the two halves of F3 is empty when
the screen has nothing to find that node with.
The Shift half of F1 follows the two rules of the lane. In a channel with no
resend connected, it is empty, because a channel message is never acknowledged and
there is nothing to retry. In a DM with nothing outstanding, it is only dim.
``Resend`` sends the selected message (or the newest) again, unscoped (refer to
:meth:`_retry_target`), and it is dim until that message is one of ours that went
out under a region.
The day jump (``ctrl_pageup``/``ctrl_pagedown``) has no chip. ``Day ↑`` and
``Day ↓`` were on F1 and F2, and JP removed them (2026-10-10). The desktop keeps
the jump on ^PgUp and ^PgDn.
F4/F5 keep the shared paging pair. The Shift bank of the shared lane sends the plain
``end``/``home`` actions. But here the cursor of the compose line already uses them
(refer to :meth:`handle`). Thus, without a change, the two keyboard keys go to the
same fallback ("clear the selection, stay at the tail"), and each one seems to
scroll to the bottom. Instead, the two Shift slots send ``ctrl_end``/``ctrl_home``,
and their labels say what these actions do to a conversation: go back to the
*latest* message and the compose line, or go back to the *oldest*. Each jump is
still behind the page key that goes in the same direction (refer to
:data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). From the outside, the transcript moves
by one screen, also when it is the selection that moves.
"""
from .tui.fkeys import FPair, default_lane
lane = list(default_lane(nav=bool(self._messages)))
live = bool(self._messages)
# The messages are in time order. Thus there are two days exactly when the two ends
# have different dates. This costs O(1). A walk through the dividers dates the full
# transcript again at each paint.
days = live and (
self._messages[0].created_at.astimezone().date()
!= self._messages[-1].created_at.astimezone().date()
)
lane[0] = FPair("Day ↑", "ctrl_pageup", enabled=days)
lane[1] = FPair(
"Day ↓",
"ctrl_pagedown",
"QR",
"url_code",
enabled=days,
opp_enabled=bool(self._picked_urls()),
)
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
if not self._is_channel:
retry = ("Retry", "retry")
again = ("Retry", "retry")
elif self._resend_unscoped is not None:
retry = ("Resend", "retry")
again = ("Resend", "retry")
else:
retry = ("", "")
lane[2] = FPair(
again = ("", "")
lane[0] = FPair(
"Paths",
"paths",
*retry,
*again,
enabled=self._selected is not None and self._paths is not None,
opp_enabled=self._retry_target() is not None,
)
react = ("React", "react") if self._fold is not None else ("", "")
lane[1] = FPair(
"URL→QR",
"url_code",
*react,
enabled=bool(self._picked_urls()),
opp_enabled=self._can_react(),
)
if self._locator is not None or self._pages is not None:
where = ("Map", "locate") if self._locator is not None else ("", "")
who = ("Author", "author_page") if self._pages is not None else ("", "")
lane[2] = FPair(
*where,
*who,
enabled=self._locate_target() is not None,
opp_enabled=self._author_page() is not None,
)
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
return lane
def __init__(
@@ -193,6 +246,10 @@ class ChatScreen(Screen):
key_of: Callable[[str], str | None] | None = None,
scope: str | None = None,
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
locator: Locator | None = None,
pages: ContactPages | None = None,
self_name: str | None = None,
reaction_set: list[str] | None = None,
) -> None:
"""Build the chat screen.
@@ -224,6 +281,17 @@ class ChatScreen(Screen):
resend_unscoped: An async callable that sends the text of a channel message
again, unscoped, and returns the stored message (channels only. With
``None``, ^R does nothing there).
locator: The located nodes, for ^L: the map on the author of the selected
message (refer to :class:`~meshterm.ui.map_screen.Locator`). With ``None``,
^L does nothing.
pages: The contacts, for ^O: the node page of the author of the selected
message (refer to :class:`~meshterm.ui.node_detail_screen.ContactPages`).
With ``None``, ^O does nothing.
self_name: The name of our node. A channel reaction names the author of its
target, and our node is the author of each message that we sent. With
``None``, our channel messages get no reactions.
reaction_set: The emoji that ^E offers, in order. ``None`` offers
:data:`~meshterm.core.reactions.DEFAULT_REACTIONS`.
"""
super().__init__()
self._scope = scope if conversation.is_channel else None
@@ -246,11 +314,21 @@ class ChatScreen(Screen):
if conversation.is_channel or contact is None
else (contact.public_key or contact.key_prefix or "")
)
self._messages = list(messages)
# The reactions of the conversation, folded onto their targets. A room has none.
self._fold = (
None
if conversation.is_room
else reactions.ReactionFold(channel=conversation.is_channel, self_name=self_name)
)
self._reaction_set = reaction_set or reactions.DEFAULT_REACTIONS.split()
self._peer_label = conversation.label
self._messages = [m for m in messages if not self._folds(m)]
self._send = send
self._resend = resend
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
self._paths = paths
self._locator = locator
self._pages = pages
self._names = names
self._session = session
self._editor = LineEditor()
@@ -263,9 +341,12 @@ class ChatScreen(Screen):
self._status = ""
self._stick = True # keep the newest message visible until the user scrolls up
self._paths_open = False # one paths dialog at a time
self._map_open = False # one map at a time
self._page_open = False # one node page at a time
self._codes_open = False # one share screen of links at a time
self._paste_open = False # one paste-confirm dialog at a time
self._resend_open = False # one resend-unscoped confirm at a time
self._react_open = False # one reaction dialog at a time
# The selection: the index of the highlighted message (or None when the compose
# line has the focus), and the body line where it rendered, so that the frame keeps
# it visible.
@@ -299,23 +380,66 @@ class ChatScreen(Screen):
Only the keys that do something show. ``^P paths`` must have a selected message to
show the paths *of*. ``^U QR`` must have a selected message with a URL in it
(refer to :meth:`_picked_urls`). ``^R retry failed`` must have something to retry
(refer to :meth:`_retry_target`). The F-key lane uses the same rules to dim its
slots.
(refer to :meth:`_picked_urls`). ``^L map`` must have a selected message whose
author has a location (refer to :meth:`_locate_target`). ``^O author`` must have a
selected message whose author has a node page (refer to :meth:`_author_page`).
``^R retry failed`` must have something to retry (refer to :meth:`_retry_target`).
The F-key lane uses the same rules to dim its slots.
"""
if self._selected is not None:
code = " · ^U QR" if self._picked_urls() else ""
code = "^U QR" if self._picked_urls() else ""
where = "^L map" if self._locate_target() is not None else ""
who = "^O author" if self._author_page() is not None else ""
react = "^E react" if self._can_react() else ""
if self._is_channel and self._retry_target() is not None:
# A selected scoped message of ours can be sent again unscoped, and the hint
# must name that key. The "(@mention)" of the reply and ^End are removed to
# keep the line inside 72 cells (Esc still cancels the selection, and Enter
# still says what it does). ↑↓ is also removed when the hint must also name
# the code of a link.
pick = "" if code else " · ↑↓ pick"
return f"Enter reply · ^P paths{code} · ^R resend unscoped{pick} · Esc cancel"
if self._is_channel:
return f"Enter reply (@mention) · ^P paths{code} · ↑↓ pick · ^End/Esc cancel"
return f"Enter paths{code} · ↑↓ pick · ^End/Esc cancel"
# still says what it does). If the line is still too long, ↑↓ goes first,
# then ^P, which the hint of each other selected message names, then ^O.
# The message is ours here, so ^O only opens our own page.
return _fit_hint(
[
"Enter reply",
"^P paths",
code,
where,
who,
react,
"^R resend unscoped",
"↑↓ pick",
"Esc cancel",
],
spare=("↑↓ pick", "^P paths", "^O author", "^E react"),
)
if self._replies:
# With all the keys of a selection, the long words do not fit in 72 cells.
# Then the hint uses the short words of the resend hint above.
hint = _fit_hint(
[
"Enter reply (@mention)",
"^P paths",
code,
where,
who,
react,
"↑↓ pick",
"^End/Esc cancel",
],
spare=("↑↓ pick",),
)
if cell_len(hint) <= _HINT_CELLS:
return hint
# ^E goes after ↑↓ when even the short words do not fit. The keys of the
# selection that were here before ^E keep their places.
return _fit_hint(
["Enter reply", "^P paths", code, where, who, react, "↑↓ pick", "Esc cancel"],
spare=("↑↓ pick", "^E react"),
)
return _fit_hint(
["Enter paths", code, where, who, react, "↑↓ pick", "^End/Esc cancel"],
spare=("↑↓ pick", "^E react"),
)
if self._retry_target() is not None:
if self._is_channel:
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
@@ -325,7 +449,14 @@ class ChatScreen(Screen):
# --- live updates --------------------------------------------------------
def append(self, message: ChatMessage) -> None:
"""Append an inbound message to the transcript and paint."""
"""Append an inbound message to the transcript and paint.
A reaction goes onto the message that it reacts to, and the transcript does not
move for it.
"""
if self._folds(message):
self._session.invalidate()
return
self._messages.append(message)
# Do not pull the viewport to the tail while the user selects a message to reply to.
if self._selected is None:
@@ -472,7 +603,8 @@ class ChatScreen(Screen):
tick),
* it is the selected message (the selection sets its style, not the message), or
* its identity or ``acked`` is different from the cache (it was appended, it was
replaced in place when a pending bubble resolved, or it was removed).
replaced in place when a pending bubble resolved, or it was removed), or
* its number of reactions is different from the cache (a reaction arrived).
Most importantly, this works for each message. It is not a cached prefix that
stops at the first such row. Thus a message stays cheap to draw again, however far
@@ -488,11 +620,12 @@ class ChatScreen(Screen):
cached_ids = cache["ids"]
cached_acked = cache["acked"]
cached_selected = cache["selected"]
cached_reacts = cache["reacts"]
cached_boundaries = cache["boundaries"]
cached_lines = cache["lines"]
cache_count = len(cached_ids)
else:
cached_ids = cached_acked = cached_selected = cached_boundaries = ()
cached_ids = cached_acked = cached_selected = cached_reacts = cached_boundaries = ()
cached_lines = []
cache_count = 0
@@ -506,6 +639,7 @@ class ChatScreen(Screen):
ids: list[int] = []
ackeds: list[bool | None] = []
selecteds: list[bool] = []
reacts: list[int] = []
boundaries: list[int] = []
prev_group: tuple[bool, str] | None = None
prev_day = None
@@ -521,6 +655,7 @@ class ChatScreen(Screen):
new_day = day != prev_day
selected = idx == self._selected
pending = message.acked is None and message.outbound and not message.is_channel
react_count = self._fold.count(message) if self._fold is not None else 0
reusable = (
not selected
and not pending
@@ -528,6 +663,7 @@ class ChatScreen(Screen):
and not cached_selected[idx]
and cached_ids[idx] == id(message)
and cached_acked[idx] == message.acked
and cached_reacts[idx] == react_count
)
if reusable:
start = cached_boundaries[idx - 1] if idx > 0 else 0
@@ -569,6 +705,7 @@ class ChatScreen(Screen):
ids.append(id(message))
ackeds.append(message.acked)
selecteds.append(selected)
reacts.append(react_count)
boundaries.append(len(lines))
self._render_cache = {
@@ -576,6 +713,7 @@ class ChatScreen(Screen):
"ids": ids,
"acked": ackeds,
"selected": selecteds,
"reacts": reacts,
"lines": lines,
"boundaries": boundaries,
}
@@ -604,14 +742,19 @@ class ChatScreen(Screen):
sender looks exactly like that node as a hop in a route, also for our messages:
the ``★``, never our name.
Only this name is in a chip. An ``@mention`` in a body (refer to
:meth:`_render_mentions`) is a part of what the sender said. A chip for it puts a
chip in the middle of a sentence.
Our star comes from the direction of the message (``outbound``), not from the
``"you"`` label. Thus a remote sender with the name ``you`` still gets the hue of
its key, and does not look like our node.
The sender is the origin of the route of each message under it. Thus the chip
closes on the point, the mark of a route that goes on. An ``@mention`` in a body is
the other end, and it opens on the notch (refer to :meth:`_render_mentions`).
"""
if message.outbound:
return name_chip("", you=True)
return name_chip("", you=True, role=ORIGIN)
# ``·`` is a channel line that arrived with no sender prefix: there is no key to use.
return name_chip(sender, None if sender == "·" else self._header_key(sender, message))
key = None if sender == "·" else self._header_key(sender, message)
return name_chip(sender, key, role=ORIGIN)
def _header_key(self, sender: str, message: ChatMessage) -> str | None:
"""The key that gives the colour of the sender chip of a group: the key of the name.
@@ -651,14 +794,59 @@ class ChatScreen(Screen):
prefix.append(f"{stamp:%H:%M} ", style="cursor" if selected else "muted")
body_text = self._body_text(body, message, selected=selected)
whole = [m.span() for m in _WORD.finditer(body_text.plain) if links.urls(m.group())]
# A mention is a chip, and a chip never breaks across two lines.
whole += [(s.start, s.end) for s in body_text.spans if str(s.style) == PATH_INK]
lines = render_hanging(
prefix, body_text, width, indent=prefix.cell_len, whole=whole, gutter=_URL_GUTTER
)
urls = links.urls(body) if _URL_CODES else []
if urls:
lines += render_lines(qr_strip(urls, width, indent=prefix.cell_len), width)
badges = self._fold.badges(message) if self._fold is not None else []
if badges:
row, runs = self._badge_text(badges, selected=selected)
lines += render_hanging(
Text(" " * prefix.cell_len), row, width, indent=prefix.cell_len, whole=runs
)
return lines
def _badge_text(
self, badges: list[reactions.Badge], *, selected: bool
) -> tuple[Text, list[tuple[int, int]]]:
"""The reactions on one message, as one line under its body, and the runs to keep whole.
Each emoji has its count after it. The count is ``accent`` when our node is one of
the nodes that reacted, else ``muted``. On the selected message, the names of the
nodes take the place of the count, each in the hue of its node, and our node is the
white ``you``. Where the font has no emoji, an emoticon stands in for each default
reaction (refer to :func:`~meshterm.ui.theme.emoticon`).
"""
text = Text()
runs: list[tuple[int, int]] = []
for index, badge in enumerate(badges):
if index:
text.append(" ")
start = len(text)
text.append(emoticon(badge.emoji))
text.append(" ")
if not selected:
count = f"{_COUNT_MARK}{len(badge.reactors)}"
text.append(count, style="accent" if badge.ours else "muted")
runs.append((start, len(text)))
continue
for number, reactor in enumerate(badge.reactors):
if number:
text.append(", ", style="muted")
if reactor.ours:
text.append("you", style="you")
else:
name = reactor.name or self._peer_label
key = self._peer_key if reactor.name is None else self._sender_key(name)
text.append(name, style=name_style(name, key or None))
if not number:
runs.append((start, len(text))) # the emoji never ends a line alone
return text, runs
def _body_text(self, body: str, message: ChatMessage, *, selected: bool) -> Text:
"""Build the styled body of a message: mentions in colour, then the glyphs at the end."""
text = self._render_mentions(body, selected=selected)
@@ -693,13 +881,19 @@ class ChatScreen(Screen):
return Text(f" · scope {message.scope}", style="muted")
def _render_mentions(self, body: str, *, selected: bool) -> Text:
"""Render body text, and change each ``@[Name]`` token to a ``@Name`` in its hue.
"""Render body text, and change each ``@[Name]`` token to a ``@Name`` chip.
The reply flow puts an ``@[Name]`` token in the compose line (refer to
:meth:`_begin_reply`). Here it shows as a bare ``@Name`` in the stable hue of that
sender. Thus the user sees that a mention belongs to the person that it names. The
text around the mentions keeps the base style of the line (``cursor`` when the
message is the selected reply target, or else no style).
:meth:`_begin_reply`). Here it shows as ``@Name`` in a chip of the hue of that
node. Thus the user sees that a mention belongs to the node that it names. The text
around the mentions keeps the base style of the line (``cursor`` when the message
is the selected reply target, or else no style).
The chip opens on the notch, because the node that a mention names is the
destination of a route that came from the sender (refer to :meth:`_group_header`,
whose chip closes on the point). The ``@`` stays in the chip. A chip keeps the words
of the arrow mode, where a mention is ``@Name`` with no frame, as it was before the
chip (JP, 2026-10-10).
"""
base = "cursor" if selected else None
text = Text()
@@ -708,7 +902,8 @@ class ChatScreen(Screen):
if match.start() > pos:
text.append(body[pos : match.start()], style=base)
name = match.group(1)
text.append(f"@{name}", style=self._sender_style(name, mention=True))
key = self._sender_key(name, mention=True)
text.append_text(name_chip(f"@{name}", key, role=DESTINATION))
pos = match.end()
if pos < len(body):
text.append(body[pos:], style=base)
@@ -744,44 +939,24 @@ class ChatScreen(Screen):
)
return Text("Enter to see the paths this message took · End to cancel", style="accent")
def _sender_style(self, sender: str, *, is_self: bool = False, mention: bool = False) -> str:
"""Get a stable colour for a sender name, from the node key of the sender.
Our messages are white. This comes from ``is_self`` (the message is outbound), not
from the ``"you"`` label. Thus a remote sender with the name ``you`` still gets a
hue from the palette, and does not look like our node. ``·`` (unknown) takes
``node.unknown``. Each other sender name is resolved to a key (the contacts, then
the names that the recorder stored, and in a direct thread the *sender label*
falls back to the key of the peer), and takes the hue of that key. A name that no
known node has also stays ``node.unknown``: the rule of the app is that colour
marks an identity that has a key.
Each grey here is the *node* grey, never ``muted``. An unidentified sender is
content that you can still do actions on (select the message, open its paths),
not chrome. The two greys must not follow each other: on the console, ``muted`` is
the dark slot and ``node.unknown`` is the light one (JP, 2026-08-12).
``mention=True`` gives the style of an ``@mention`` in the body, not of a sender
label. A mention names any person, so the fallback to the key of the peer must
not apply. Thus a mention that is not resolved stays grey in a direct chat,
exactly as it does in a channel.
"""
if is_self:
return "you" # white, outside the range of sender hues, so always easy to see
if sender == "·":
return "node.unknown"
return name_style(sender, self._sender_key(sender, mention=mention))
def _sender_key(self, sender: str, *, mention: bool = False) -> str | None:
"""The key of a sender name, or ``None`` when nothing gives a key for it.
The contacts first, then the names that the recorder stored. In a *direct* thread,
a sender label with no key falls back to the key of the peer, because the only two
persons in the conversation are we and the peer. A mention names any person, so
that fallback must not apply to a mention.
persons in the conversation are we and the peer. A mention names any node, so
that fallback must not apply to a mention. Thus a mention that is not resolved
stays grey in a direct chat, exactly as it does in a channel.
The colour (:meth:`_sender_style`) and the chip (:meth:`_group_header`) share this
method. Thus the two cannot disagree about the identity of a name.
A name with no key is drawn in the *node* grey, never in ``muted`` (the chip does
this, refer to :func:`~meshterm.ui.widgets.name_chip`). An unidentified sender is
content that you can still do actions on (select the message, open its paths),
not chrome. On the console, ``muted`` is the dark slot and ``node.unknown`` is the
light one (JP, 2026-08-12).
The sender chip (:meth:`_group_header`) and the mention chips
(:meth:`_render_mentions`) share this method. Thus the two cannot disagree about
the identity of a name.
"""
key = self._key_of(sender)
if not key and not self._multiparty and not mention:
@@ -801,10 +976,10 @@ class ChatScreen(Screen):
Ctrl+PgUp/PgDn (day dividers), and the viewport moves with it. Enter on a selected
message starts a reply with an ``@mention`` in a channel, and opens the delivery
paths in a direct chat. In the two types of chat, ^P opens the paths of the
selected message, and ^U the QR codes of its links. The two do nothing when no
message is selected. ^End (or a move past the newest message) goes back to the
compose line. The first Esc clears the selection, and the second Esc leaves the
screen.
selected message, ^U the QR codes of its links, ^L the map on its author, and ^E the
reaction dialog. The four do nothing when no message is selected. ^End (or a move
past the newest message) goes back to the compose line. The first Esc clears the
selection, and the second Esc leaves the screen.
"""
if action == "enter":
if self._selected is not None:
@@ -820,6 +995,12 @@ class ChatScreen(Screen):
self._open_paths(self._selected)
elif action == "url_code":
self._open_codes()
elif action == "locate":
self._open_map()
elif action == "author_page":
self._open_author_page()
elif action == "react":
self._begin_react()
elif action == "retry":
if self._is_channel:
self._begin_resend_unscoped()
@@ -879,6 +1060,85 @@ class ChatScreen(Screen):
self._session.run_detached(run())
def _locate_target(self) -> MapMarker | None:
"""The marker of the author of the selected message, or ``None`` when ^L does nothing.
The author is the node whose key gives the colour of the chip of the message (refer
to :meth:`_header_key`). Our own message has our node as its author. A message with
no sender, or an author that has no location, has no marker.
"""
if self._locator is None or self._selected is None or not self._messages:
return None
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
if message.outbound:
return self._locator.you()
sender, _body = self._sender_and_body(message)
if sender == "·":
return None
return self._locator.find(self._header_key(sender, message))
def _open_map(self) -> None:
"""Open the map on the author of the selected message (^L). One map at a time.
The map is a screen, and it goes on the stack above the chat. Esc on the map comes
back to the chat, with the same message selected.
"""
target = self._locate_target()
if target is None or self._locator is None or self._map_open:
return
locator = self._locator
self._map_open = True
async def run() -> None:
try:
await locator.show(target)
finally:
self._map_open = False
self._session.invalidate()
self._session.run_detached(run())
def _author_page(self) -> Callable[[], Awaitable[None]] | None:
"""The node page that ^O opens for the selected message, or ``None`` when there is none.
The page belongs to the author of the message: our node for a message of ours, or
the contact that the key of the author finds (refer to :meth:`_header_key`, the
same key that gives the colour of the sender chip). A message with no sender, or an
author that is not a contact, has no page. The hint and the F-key lane ask this at
each paint, so that they name ^O only when it acts.
"""
pages = self._pages
if pages is None or self._selected is None or not self._messages:
return None
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
if message.outbound:
return lambda: pages.show(None)
sender, _body = self._sender_and_body(message)
if sender == "·":
return None
contact = pages.find(self._header_key(sender, message))
return None if contact is None else (lambda: pages.show(contact))
def _open_author_page(self) -> None:
"""Open the node page of the author of the selected message (^O). One page at a time.
The page is a screen, and it goes on the stack above the chat. Esc on the page comes
back to the chat, with the same message selected.
"""
page = self._author_page()
if page is None or self._page_open:
return
self._page_open = True
async def run() -> None:
try:
await page()
finally:
self._page_open = False
self._session.invalidate()
self._session.run_detached(run())
def _picked_urls(self) -> list[str]:
"""The URLs in the body of the selected message, in order. None if nothing is selected."""
if self._selected is None or not self._messages:
@@ -1070,7 +1330,7 @@ class ChatScreen(Screen):
try:
message = await self._send(text)
if message is not None:
self._messages.append(message)
self._admit(message)
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"send failed: {exc}"
@@ -1228,7 +1488,7 @@ class ChatScreen(Screen):
try:
message = await self._resend_unscoped(target)
if message is not None:
self._messages.append(message)
self._admit(message)
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"resend failed: {exc}"
@@ -1255,12 +1515,126 @@ class ChatScreen(Screen):
self._session.invalidate()
def _swap(self, old: ChatMessage, new: ChatMessage) -> None:
"""Replace an optimistic bubble with its stored message, in place."""
"""Replace an optimistic bubble with its stored message, in place.
The stored message is the one that a reaction can name, because it has the
timestamp that went out. Thus the fold takes it here, and not the bubble.
"""
if self._folds(new):
self._discard(old)
return
try:
self._messages[self._messages.index(old)] = new
except ValueError: # pragma: no cover - the bubble is always still there
self._messages.append(new)
def _folds(self, message: ChatMessage) -> bool:
"""Give ``message`` to the fold. ``True`` for a reaction, which the transcript omits."""
return self._fold is not None and self._fold.take(message)
def _admit(self, message: ChatMessage) -> None:
"""Append a message that we sent, unless it is a reaction (the fold shows that one)."""
if not self._folds(message):
self._messages.append(message)
# --- reactions -------------------------------------------------------------
def _react_target(self) -> tuple[ChatMessage, str | None, str, int] | None:
"""The selected message, and the author, body, and timestamp that a reaction names it by.
``None`` when nothing is selected, or when a reaction cannot name the selection: a
room, a message that is still in flight, or a channel message with no author (a
line with no ``Name: `` prefix, or one of ours when our name is not known).
"""
if self._fold is None or self._selected is None or not self._messages:
return None
message = self._messages[min(self._selected, len(self._messages) - 1)]
named = self._fold.target_of(message)
if named is None:
return None
author, body, timestamp = named
if self._is_channel and not author:
return None
return message, author, body, timestamp
def _can_react(self) -> bool:
"""Whether ^E can start a reaction: a selection that a reaction can name, and no send."""
return not self._sending and self._composing and self._react_target() is not None
def _begin_react(self) -> None:
"""Ask which emoji, then send the reaction to the selected message (^E).
The dialog is a question, so it floats as a box over the chat. The emoji of the
``reaction_emoji`` preference come first, then each other emoji (refer to
:func:`emoji_catalog`). Each row has the name of its emoji, so a typed word such as
``fire`` or ``heart`` filters the list to the emoji that it names. A row that our
node already sent to this message says so. A second copy of it would only count one
time, so the selection of that row sends nothing. The reaction goes out as a usual
message in the conversation. When it is stored, the fold puts it under its target
(refer to :meth:`_admit`).
"""
target = self._react_target()
if target is None or self._sending or self._react_open or self._fold is None:
return
message, author, body, timestamp = target
fold = self._fold
self._react_open = True
sender, _ = self._sender_and_body(message)
who = "your message" if message.outbound else sender
async def run() -> None:
from .menus import menu_rows, section_heading
def row(emoji: str) -> tuple[str, str, str]:
name = reactions.emoji_name(emoji)
sent = fold.reacted(message, emoji)
return emoticon(emoji), f"{name} · sent" if sent else name, emoji
mine = list(self._reaction_set)
others = [emoji for emoji in emoji_catalog() if emoji not in mine]
rows = menu_rows([row(emoji) for emoji in mine + others])
items = [
section_heading("Your reactions"),
*rows[: len(mine)],
section_heading("All emoji"),
*rows[len(mine) :],
]
try:
emoji = await self._session.select(
f"React — {who}", items, filterable=True, floating=True
)
finally:
self._react_open = False
if not emoji or self._sending:
self._session.invalidate()
return
if fold.reacted(message, emoji):
self._status = "you already reacted with that"
self._session.invalidate()
return
if self._is_channel:
text = reactions.channel_text(emoji, author or "", body, timestamp)
else:
text = reactions.direct_text(emoji, body, timestamp)
self._sending = True
self._status = "reacting…"
self._session.invalidate()
try:
sent_message = await self._send(text)
if sent_message is not None:
self._admit(sent_message)
no_ack = not self._is_channel and sent_message.acked is False
self._status = "reaction sent · no ack" if no_ack else ""
else:
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"reaction failed: {exc}"
finally:
self._sending = False
self._session.invalidate()
self._spawn(run())
def _discard(self, message: ChatMessage) -> None:
"""Remove an optimistic bubble that never became a real message."""
try:
@@ -1269,6 +1643,47 @@ class ChatScreen(Screen):
pass
#: The blocks of Unicode where an emoji with the emoji presentation can be. A wide symbol
#: between them is a CJK symbol or radical, not an emoji.
_EMOJI_BLOCKS = ((0x2000, 0x2E7F), (0x1F000, 0x1FAFF))
#: The I Ching symbols, which Unicode 16 draws wide. They are symbols, not emoji: the
#: firmware and the other apps never offer them.
_NOT_EMOJI = frozenset(range(0x268A, 0x2690)) | frozenset(range(0x2630, 0x2638))
@lru_cache(maxsize=1)
def emoji_catalog() -> tuple[str, ...]:
"""Each single emoji that the reaction dialog offers, in the order of Unicode.
The standard library has no list of emoji. Thus the catalog takes two sets. The first is
each symbol in the emoji blocks that Unicode draws wide by default (``🔥``, ``⌚``). The
second is the emoji that are text by default (``❤``), which the table of Rich lists.
Each of these gets the variation selector that asks for the emoji form (``❤️``), as the
apps send it. A flag, a skin tone, and a joined sequence are not in the catalog, but a
reaction with one still shows when it arrives. The names come from the Unicode database
of the Python that runs MeshTerm, so a newer Python offers newer emoji.
"""
from rich._unicode_data import load
found: dict[int, str] = {}
for first, last in _EMOJI_BLOCKS:
for cp in range(first, last + 1):
char = chr(cp)
if cp in _NOT_EMOJI:
continue
if unicodedata.category(char) == "So" and unicodedata.east_asian_width(char) == "W":
found[cp] = char
for char in load("auto").narrow_to_wide:
if not char.isascii() and len(char) == 1:
found.setdefault(ord(char), char + "\ufe0f")
return tuple(
emoji
for _, emoji in sorted(found.items())
if not reactions._is_regional(emoji[0]) and reactions.is_reaction_emoji(emoji)
)
async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
"""Open the live chat screen for ``conversation`` and run it until the user closes it.
@@ -1314,7 +1729,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
)
contacts = await ctx.devstate.contacts()
names = _contact_names(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, ctx.repo.node_names())
stored_names, named_at = ctx.repo.node_names_with_times()
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
async def send(text: str) -> ChatMessage | None:
if conversation.is_channel:
@@ -1345,7 +1761,15 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
ctx, lambda: ctx.chat.resend_direct(conversation.contact, message)
)
from .map_screen import Locator
from .node_detail_screen import ContactPages
paths = await _make_paths_presenter(ctx, conversation, device)
self_name: str | None = None
try:
self_name = str((await ctx.devstate.self_info()).get("name") or "") or None
except Exception: # noqa: BLE001 - with no name, our channel messages get no reactions
self_name = None
screen = ChatScreen(
conversation,
history,
@@ -1357,6 +1781,10 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
key_of=key_of,
scope=scope,
resend_unscoped=resend_unscoped,
locator=await Locator.gather(ctx),
pages=await ContactPages.gather(ctx),
self_name=self_name,
reaction_set=reactions.reaction_set(ctx.preferences.reaction_emoji),
)
ctx.chat.set_active(conversation.key)
@@ -1568,10 +1996,10 @@ async def _make_paths_presenter(
session = ctx.ui.session
contacts = await ctx.devstate.contacts()
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
resolve = trace_runner.make_node_resolver(contacts, stored_names)
type_of = trace_runner.make_node_type_resolver(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
self_name: str | None = None
self_key: str | None = None
+63 -20
View File
@@ -56,6 +56,7 @@ from ..core.device_config import (
parse_value,
settings_by_category,
)
from ..core.privatekey import parse_private_key, read_private_key_file
from ..platforms import Platform, on_platform
from .device_info_screen import REVEAL_KEY
from .marks import MASK_MARK
@@ -212,7 +213,14 @@ async def edit_config(ctx: AppContext) -> dict[str, Any] | None:
snapshot, custom = await _reread(ctx, device)
_unstage_held(snapshot, custom, pending, custom_pending)
elif choice == _IDENTITY_KEY:
if await _identity_key_menu(ctx, device, snapshot):
if await _identity_key_menu(ctx, device, snapshot, staged()):
if device.import_restarts and ctx.active_transport is not None:
# The node reads its new key only when it starts. The reconnect of
# the session stops this node and starts a new one, as after a
# reboot, and the visit ends with it (refer to _reboot).
ctx.announce_reboot()
await asyncio.sleep(_REBOOT_HANDOFF_TIMEOUT_S)
return summary()
snapshot, custom = await _reread(ctx, device)
elif choice == _REBOOT:
# A reboot ends the visit, and the staged values go with it. Thus ask first.
@@ -1352,12 +1360,27 @@ async def _restore_now(ctx: AppContext, device: Device, snapshot: dict) -> bool:
return changed > 0
async def _identity_key_menu(ctx: AppContext, device: Device, snapshot: dict) -> bool:
async def _identity_key_menu(
ctx: AppContext, device: Device, snapshot: dict, staged: int = 0
) -> bool:
"""Export or import the private identity key of the device.
An import takes a pasted key or a file. Both accept the 64-byte key that a companion
exports, and also the 32-byte seed that the node of an SPI radio keeps
(:mod:`meshterm.core.privatekey`). Each input is checked before the confirmation, so
a key that is not one never gets to the typed confirmation.
Args:
ctx: The shared application context.
device: The connected device.
snapshot: The current device snapshot.
staged: The number of staged changes on the page. An import that restarts the node
ends the visit, as a reboot does, so the user must agree to discard them first.
Returns:
``True`` if the identity changed (a key was imported). Then the caller reads its
snapshot again.
snapshot again, or gives control to the reconnect if the node must restart
(:attr:`~meshterm.core.connection.Device.import_restarts`).
"""
choice = await ctx.ui.select(
"Identity key",
@@ -1366,7 +1389,8 @@ async def _identity_key_menu(ctx: AppContext, device: Device, snapshot: dict) ->
[
("Show private key", "Display it on screen (sensitive)", "show"),
("Export to a file…", "Write it to disk (keep it secret)", "file"),
("Import a key…", "Replace this device's identity", "import"),
("Import a key…", "Paste it (replaces the identity)", "import"),
("Import from a file…", "Read it from disk (replaces the identity)", "load"),
]
),
],
@@ -1399,22 +1423,41 @@ async def _identity_key_menu(ctx: AppContext, device: Device, snapshot: dict) ->
return False
# Import: get the key, then ask for the typed confirmation.
key_hex = await ctx.ui.text(
"Import identity key",
prompt="Paste the private key as hex:",
validate=_is_hex,
)
if not key_hex:
if choice == "load":
raw = await ctx.ui.path(
"Import identity key", prompt="Read the private key from this file:"
)
if not raw:
return False
try:
key = read_private_key_file(Path(raw))
except (OSError, ValueError) as exc:
ctx.ui.note(f"[err]not imported:[/err] {escape(str(exc))}")
await ctx.ui.present(title="Import key")
return False
else:
key_hex = await ctx.ui.text(
"Import identity key",
prompt="Paste the private key as hex:",
validate=_is_private_key,
)
if not key_hex:
return False
key = parse_private_key(key_hex)
restarts = device.import_restarts and ctx.active_transport is not None
if restarts and staged and not await confirm_discard(ctx, staged, verb="applying"):
return False
confirmed = await ctx.ui.typed_confirm(
warning = (
"Importing a key permanently overwrites this device's identity. Contacts and "
"messages keyed to the old identity will no longer match it.",
"IMPORT",
title="Import private key",
"messages keyed to the old identity will no longer match it."
)
if restarts:
warning += " The node restarts to use the new key."
confirmed = await ctx.ui.typed_confirm(warning, "IMPORT", title="Import private key")
if not confirmed:
return False
await _run_now(ctx, device, snapshot, [("import_key", key_hex.strip())], "Import key")
await _run_now(ctx, device, snapshot, [("import_key", key.hex())], "Import key")
return True
@@ -1440,10 +1483,10 @@ async def _factory_reset(ctx: AppContext, device: Device, snapshot: dict) -> boo
# --- validators --------------------------------------------------------------
def _is_hex(text: str) -> bool | str:
"""Check that ``text`` is a hex string."""
def _is_private_key(text: str) -> bool | str:
"""Check that ``text`` is a private key in hex: ``True``, or the problem for the prompt."""
try:
bytes.fromhex(text)
parse_private_key(text)
return True
except ValueError:
return "Enter hex characters only."
except ValueError as exc:
return str(exc)
+7
View File
@@ -30,8 +30,10 @@ say "and if it's the trace picker…".
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from rich.cells import cell_len
from rich.text import Text
@@ -571,6 +573,7 @@ class ContactListScreen(SelectScreen):
tail: list | None = None,
footer_hint: str = _HINT,
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
key_hint: Callable[[Any], str] | None = None,
) -> None:
"""Build the list over rows that are already resolved.
@@ -599,6 +602,9 @@ class ContactListScreen(SelectScreen):
footer_hint: The key hint of the footer. The default shows the full grammar.
lanes: The fixed lanes drawn between the name and the key of each row. Refer
to :data:`DEFAULT_LANES`, :data:`TRACE_LANES`, and :data:`ARCHIVED_LANES`.
key_hint: The hint atoms for the shortcut keys that act on the highlighted row
(refer to :class:`~meshterm.ui.tui.select.SelectScreen`). ``None`` for a list
with no such keys.
"""
self._contact_rows = rows
self._prefix_bytes = prefix_bytes
@@ -615,6 +621,7 @@ class ContactListScreen(SelectScreen):
self._compose_items(),
prompt=prompt,
footer_hint=footer_hint,
key_hint=key_hint,
)
def _compose_items(self) -> list:
+70 -4
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. The one-shot CLI (``meshterm contacts --sort …``) still renders the static
as it was. ^L opens the map on the highlighted node, when it has a location. The one-shot
CLI (``meshterm contacts --sort …``) still renders the static
:func:`~meshterm.ui.widgets.contacts_table`. Only the menu gets the live list.
Below the contacts is the maintenance action of the list: **Archive contacts**. It is the
@@ -45,13 +46,15 @@ from .archived_screen import open_archived
from .contactlist import ContactListScreen, ContactRow
from .menus import icon_lane, marked_label
from .sweep_screen import archive_contacts
from .tui import Choice, Separator
from .tui import Choice, KeyRequest, Separator
from .tui.screen import CANCEL
from .widgets import ContactsSort, contact_packets
if TYPE_CHECKING:
from ..context import AppContext
from ..core.models import Contact
from .map_render import MapMarker
from .map_screen import Locator
#: A stable identity for the row of our node. The lane data of this row has no key when no
#: device answered. Enter on this row opens the detail page of our node.
@@ -75,9 +78,35 @@ 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. The shared list handles the sort, the filter, and the navigation.
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.
"""
@property
def picocalc_lyra_lane(self):
"""The lane of the contact list, with ``Map`` (^L) on the Shift half of F1.
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.
"""
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,
)
return lane
def __init__(
self,
self_name: str,
@@ -88,6 +117,7 @@ class ContactsScreen(ContactListScreen):
sort: ContactsSort,
archived: int = 0,
locked: frozenset[str] = frozenset(),
locator: Locator | None = None,
) -> None:
"""Create the contacts screen over contact data that MeshTerm already got.
@@ -110,8 +140,11 @@ class ContactsScreen(ContactListScreen):
because a screen must not offer a way into an empty list.
locked: The full keys (lowercase hex) of the contacts that are locked against
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.
"""
self._self_name, self._self_key = self_name, self_key
self._locator = locator
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
@@ -153,6 +186,7 @@ class ContactsScreen(ContactListScreen):
prefix_bytes=prefix_bytes,
sort=sort,
tail=tail,
key_hint=lambda value: "^L map" if self._marker_of(value) is not None else "",
)
if archived:
self.short_title = f"Contacts · {known} + {archived}"
@@ -176,6 +210,29 @@ class ContactsScreen(ContactListScreen):
)
return rows
def _marker_of(self, value: object) -> MapMarker | None:
"""The marker of the node of a row value, or ``None`` (a tail row, or no location)."""
if self._locator is None:
return None
if value == YOU:
return self._locator.you()
key = getattr(value, "public_key", None) or getattr(value, "key_prefix", None)
return self._locator.find(key) if isinstance(key, str) else None
def _highlighted_marker(self) -> MapMarker | None:
"""The marker of the highlighted node, or ``None`` when ^L does nothing."""
current = self._current_choice()
return self._marker_of(current.value) if current is not None else None
def handle(self, action: str, data: str = "") -> None:
"""^L asks :func:`open_contacts` to open the map. The list handles all other actions."""
if action == "locate":
marker = self._highlighted_marker()
if marker is not None:
self.resolve(KeyRequest("locate", marker))
return
super().handle(action, data)
def refresh_locks(self, locked: frozenset[str]) -> None:
"""Draw the padlocks again in place, after a detail page can lock or unlock a contact.
@@ -196,7 +253,7 @@ async def open_contacts(
counts: dict[str, int],
sort: ContactsSort,
) -> None:
"""Open the interactive contacts list. Enter opens Node detail, and Esc leaves.
"""Open the interactive contacts list. Enter opens Node detail, ^L the map, and Esc leaves.
The list stays pushed for the whole visit. Thus the detail page of the node that Enter
selects, the archive flow, and the archived list (the tail actions open the last two)
@@ -224,12 +281,14 @@ async def open_contacts(
RuntimeError: If the caller is outside the interactive menu (no full-screen
session).
"""
from .map_screen import Locator
from .node_detail_screen import open_node_detail
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the caller is for the menu only
raise RuntimeError("the interactive contacts list is only available in the menu")
session = ctx.ui.session
locator = await Locator.gather(ctx)
def build() -> ContactsScreen:
"""The screen over the current contacts, with the live archived count on its tail."""
@@ -242,6 +301,7 @@ async def open_contacts(
sort,
archived=_archived_count(ctx, self_key),
locked=_locked_keys(ctx, self_key),
locator=locator,
)
screen = build()
@@ -252,6 +312,11 @@ async def open_contacts(
chosen = await visit.result()
if chosen is CANCEL or chosen is None: # Esc
return
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.
await locator.show(chosen.value)
continue
if chosen == _ARCHIVE:
# The archive ladder, its preview, and its confirm all float over the
# list, which is already the backdrop. A sweep that archived a contact
@@ -286,6 +351,7 @@ async def open_contacts(
if not rebuild:
return
contacts = await ctx.devstate.contacts()
locator = await Locator.gather(ctx)
screen = build()
+22 -5
View File
@@ -38,9 +38,18 @@ 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 .pathline import PathHop
from .tui import CANCEL, DM_BYTE_LIMIT, Choice, SelectScreen, Separator
from .watchtower_screen import contact_watch_key
from .widgets import ContactsSort, age_seconds, contact_packets, format_ago
from .widgets import (
DESTINATION,
ContactsSort,
age_seconds,
contact_packets,
format_ago,
hop_chip,
name_chip,
)
if TYPE_CHECKING:
from ..context import AppContext
@@ -302,9 +311,13 @@ def _entry_mark(mark: str, style: str, lane: int) -> 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."""
"""One waiting entry: the recipient, the body, and what the entry waits for.
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.
"""
row = _entry_mark("⏳", "warn", lane)
row.append(message.node_name)
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()
@@ -327,12 +340,16 @@ def _waiting_row(ctx: AppContext, message: QueuedMessage, lane: int = _MARK_LANE
def _done_row(message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
"""One finished entry: the outcome mark, the recipient, the body, and when it ended."""
"""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(message.node_name, style="muted")
row.append_text(hop_chip(PathHop(message.node_name, style="muted"), role=DESTINATION))
row.append(f" “{_shorten(message.text)}”", style="muted")
when = message.finished or message.created
verb = "delivered" if message.status == DELIVERED else "gave up"
+34 -22
View File
@@ -57,9 +57,11 @@ itself with the same ``↑``/``↓``. If an overheard channel-text packet names
which we have the key, the viewer decrypts it. **The viewer has the two stops too.** It
holds the same pin above the newest packet, so ``↑`` from the top follows the stream
there too (each packet that arrives becomes the card that the user reads). The viewer
also moves the cursor of the feed with it, the pin included. Thus, when the dialog
closes, the feed is on the stop that the user had in the dialog, not on the stop that
the user left when the dialog opened.
opens on the stop that the feed has. Enter on the pin opens the viewer on the pin, so
the card follows the stream immediately, and Enter on a selected packet opens that
packet and holds it. The viewer also moves the cursor of the feed with it, the pin
included. Thus, when the dialog closes, the feed is on the stop that the user had in the
dialog, not on the stop that the user left when the dialog opened.
The screen has no subscriptions of its own. The opener (:func:`open_livefeed`) connects
the hub subscription and the paint that occurs one time each second, and removes them
@@ -93,11 +95,11 @@ from .packet_viewer import (
node_label,
unnamed_scope,
)
from .pathline import SELF_GLYPH, PathHop, PathLine
from .pathline import SELF_GLYPH, PathHop, cut_to
from .theme import name_style, snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.screen import ListWindow, Screen
from .widgets import NameKeyResolver, TypeOf, scope_text
from .widgets import NameKeyResolver, TypeOf, route_ends, scope_text
if TYPE_CHECKING:
from ..context import AppContext
@@ -556,14 +558,17 @@ class LiveFeedScreen(Screen):
When the user pages in the viewer, the feed highlight moves with it (through
``on_navigate``). Thus, when the viewer closes, the feed is on the packet that the
user saw last. For the same reason, this method removes the pin when it opens the
viewer. Enter names this packet. If the pin stays, the packets that arrive during a
long read move the cursor off this packet. Then, when the viewer closes, the feed
is on a row that the user did not select.
user saw last.
The viewer opens on the stop that the feed has, and Enter does not change it. On
the pin, the viewer opens on the pin too, and the card follows the stream. On a
selected packet, the viewer holds that packet. JP decided this on 2026-10-10.
Before, Enter removed the pin, because Enter named the packet under it. But a user
who watches the feed and opens the card continues to watch, and ``↓`` in the viewer
holds the visible packet with one key press.
"""
if self._selected is None or not self._feed:
return
self._select_row(self._selected)
def follow(entry: PacketEntry) -> None:
# The feed may have more rows after the snapshot. Thus find the entry itself.
@@ -575,6 +580,7 @@ class LiveFeedScreen(Screen):
viewer = PacketViewer(
list(self._feed),
self._selected,
pinned=self._pinned,
resolve=self._resolve,
prefix_bytes=self._prefix_bytes,
self_name=self._self_name,
@@ -775,7 +781,8 @@ class LiveFeedScreen(Screen):
(:func:`~meshterm.core.channels.identify_channel`). A channel for which we have
no key shows the fingerprint that it advertises.
* **direct message, request, response, returned path, anonymous request**: about
the two nodes between which it travels, drawn as ``sender → recipient``
the two nodes between which it travels, drawn as an origin chip and a destination
chip, or as ``sender → recipient`` where the terminal draws no chips
(:meth:`_endpoints`).
* **ack**: about the message that it acknowledges, named by the checksum of that
message.
@@ -859,12 +866,14 @@ class LiveFeedScreen(Screen):
def _endpoints(self, raw: dict) -> Text:
"""An addressed packet's subject: the two nodes between which it travels.
The subject is drawn as ``sender → recipient`` with the only hop-sequence widget
(:mod:`~meshterm.ui.pathline`). Thus the endpoints of a delivery show the same as
the hops of a route: resolved to contact names, each in the hue of its key, and
our node in white when a packet is addressed to us (or from us). The widget uses
plain arrows, not chips. This is a lane in a table row, and the padding of a chip
uses the cells that are necessary for the names.
The subject is drawn with the only hop-sequence widget (:mod:`~meshterm.ui.pathline`).
Thus the endpoints of a delivery show the same as the hops of a route: resolved to
contact names, each in the hue of its key, and our node as the ``★`` when a packet
is addressed to us (or from us). Where the terminal draws chips, the sender is an
origin chip and the recipient is a destination chip, and the relays between them
are not drawn (:func:`~meshterm.ui.widgets.route_ends`). Elsewhere the line is
``sender → recipient``. The chips use more cells than the arrow, but the two ends of
a route read as two ends at a glance (JP, 2026-10-10).
The packet names each endpoint by one byte of its key. This is the same one-byte
identity that a relay hop has, with the same probability of a collision. Thus a
@@ -876,18 +885,21 @@ class LiveFeedScreen(Screen):
If the two names do not fit in the lane, the recipient keeps its name and the
sender changes to its hash. The packet is addressed, so the recipient is the half
that gets the cells.
that gets the cells. A recipient that is still too wide is cut at the lane
(:func:`~meshterm.ui.pathline.cut_to`). A chip breaks off in its own colour, and an
arrow line ends on ``…``.
"""
dest = raw.get("dest_hash") or ""
src = raw.get("src_key") or raw.get("src_hash") or ""
if not src:
return self._token("to", dest)
named = PathLine([self._hop(src), self._hop(dest)], mode="plain").text()
named = route_ends(self._hop(src), self._hop(dest))
if named.cell_len <= self._lanes.subject:
return named
# Too wide: the sender changes to its hash, so the recipient keeps all of its name
# (or as much of it as the lane holds), and is not the half that gets cut.
return PathLine([self._hop(src, named=False), self._hop(dest)], mode="plain").text()
hashed = route_ends(self._hop(src, named=False), self._hop(dest))
return cut_to(hashed, self._lanes.subject)
def _hop(self, value: str, *, named: bool = True) -> PathHop:
"""One endpoint as a path hop: its contact name, or its one-byte hash in its hue.
@@ -1010,10 +1022,10 @@ async def open_livefeed(ctx: AppContext) -> None:
# The contacts first, then each name that the recorder overheard, as the fallback.
# This is the rule of all of the app: a node that we can name never renders as a bare
# hash.
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
resolve = trace_runner.make_node_resolver(contacts, stored_names)
type_of = trace_runner.make_node_type_resolver(contacts)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names)
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
# The cap is the only limit: the newest _FEED_CAP packets that were ever stored, also
+75 -5
View File
@@ -16,7 +16,7 @@ draws the map again through :func:`~meshterm.ui.map_render.render_map`. The keys
* ``Home`` centres the viewport again and fits it to the dense core of the nodes. This is
the area that the mesh covers (the ``Region`` chip), and it is also the default viewport
when the map opens.
* ``^Y`` (for "you") centres the viewport on **our node**. It keeps the zoom that the user
* ``^L`` (to locate) centres the viewport on **our node**. It keeps the zoom that the user
selected, and it clears the find query. The ``You`` chip on the F-key lane does the
same, and its Shift half also zooms in close.
* **Type to find nodes.** Each letter key adds to a live name filter. The matching nodes
@@ -310,7 +310,7 @@ class MapScreen(Screen):
Thus the typed text is always visible in a fixed place.
The line uses all of its 72 cells. Thus the two keys that move the viewport to a
destination share one atom (``Home/^Y region/you``). The ``⇧ fine`` atom gives
destination share one atom (``Home/^L region/you``). The ``⇧ fine`` atom gives
its space to them. That atom only refines a key that the line already names, and
it is the only atom here that shows a modifier instead of a key binding. The
state of the basemap was once a suffix at the end of this line. That state is a
@@ -320,7 +320,7 @@ class MapScreen(Screen):
"""
if self._filter:
return f"find: {self._filter}▏ · ^Enter frame · ⌫ erase · Enter/Esc clear"
return "↑↓←→ pan · PgUp/PgDn zoom · Home/^Y region/you · type to find · Esc back"
return "↑↓←→ pan · PgUp/PgDn zoom · Home/^L region/you · type to find · Esc back"
def consume_edge_scrub(self) -> int:
"""How many right-edge cells the session must paint again at the next paint (0 is none).
@@ -1057,7 +1057,7 @@ class MapScreen(Screen):
first an active filter, and the map itself only when the filter is clear.
Three actions fit the viewport again, and each has a key and an F-key chip:
``home`` fits the full region, ``locate`` (^Y, for "you") goes to our node, and
``home`` fits the full region, ``locate`` (^L) goes to our node, and
``frame`` (^Enter) fits the find matches. ``locate_zoom`` is the Shift-bank
variant of ``locate`` that also zooms in. ``clear_find`` clears the query and does
not move the viewport, and plain **Enter** does the same.
@@ -1143,7 +1143,7 @@ class MapScreen(Screen):
return next((m for m in self._markers if m.is_self), None)
def _locate(self, vp: Viewport, *, zoom_in: bool = False) -> None:
"""Centre the viewport on our node (``^Y`` or the ``You`` chip), and clear the find query.
"""Centre the viewport on our node (``^L`` or the ``You`` chip), and clear the find query.
Plain ``You`` keeps the zoom that the user selected. Two presses do the same thing
two times, and one more press of Zoom + adds the zoom. Its Shift half (``You +``)
@@ -1535,3 +1535,73 @@ async def open_map(
# paint, so that the menu drawn under the map starts from a clean terminal and
# does not keep that garbage.
session.request_full_repaint()
class Locator:
"""The nodes of the mesh that have a location, and the map that opens on one of them.
This is the ^L key (``locate``) of a screen that shows other nodes: the author of a chat
message, or the highlighted contact. At each paint, the screen asks :meth:`find` if the
node of its selected row is on the map. Thus its hint and its F-key lane name ^L only
when the key acts. Then :meth:`show` opens the map on that node, the same as the map
action of the node page.
The markers come from :func:`~meshterm.services.markers.gather_markers` one time, when
the screen opens. A node that advertises a location while the screen is open gets a
marker at the next visit.
"""
def __init__(self, ctx: AppContext, markers: list[MapMarker]) -> None:
"""Hold the markers that :meth:`show` draws.
Args:
ctx: The shared application context (it must be in the interactive menu).
markers: The located nodes of the mesh.
"""
self._ctx = ctx
self._markers = markers
# 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 markers stay the same.
self._found: dict[str, MapMarker | None] = {}
@classmethod
async def gather(cls, ctx: AppContext) -> Locator:
"""A locator over the markers that MeshTerm has now. With no markers, nothing is found."""
from ..services.markers import gather_markers
try:
markers = await gather_markers(ctx)
except Exception: # noqa: BLE001 - with no markers, ^L does nothing, and the screen works
markers = []
return cls(ctx, markers)
def find(self, key: str | None) -> MapMarker | None:
"""The marker of the node with ``key`` (a full key or a key prefix), or ``None``.
A key prefix finds the node whose full key starts with it, and a full key finds the
marker of a node that MeshTerm knows only by its key prefix. The contacts come
first in the markers, so a contact wins over an overheard node with the same key
prefix.
"""
if not key:
return None
needle = key.lower().removeprefix("0x")
if needle not in self._found:
self._found[needle] = next(
(m for m in self._markers if _same_node(m.key, needle)), None
)
return self._found[needle]
def you(self) -> MapMarker | None:
"""The marker of our node, or ``None`` when our node has no location."""
return next((m for m in self._markers if m.is_self), None)
async def show(self, marker: MapMarker) -> None:
"""Open the map on ``marker``, with its name as the find query, so that it is bright."""
await open_map(self._ctx, self._markers, focus=(marker.lat, marker.lon), find=marker.label)
def _same_node(marker_key: str | None, needle: str) -> bool:
"""Whether a marker key and a lowercase key (or key prefix) name the same node."""
key = (marker_key or "").lower().removeprefix("0x")
return bool(key) and (key.startswith(needle) or needle.startswith(key))
+88 -20
View File
@@ -23,7 +23,7 @@ screen is the node itself, in full:
faint ``…`` marks at the edges, the same as a long path line scrolls on the Routes
tab. Then, when the node has advertised a location, a static basemap preview (refer
to :class:`~meshterm.ui.minimap.MiniMap`) centred on the node. The preview grows into
the rows of the viewport that remain.
the rows of the viewport that remain. A short frame (the Cardputer) does not show it.
The actions of the tab: ``Open full map`` opens the full map, centred here, with its
find filter set to this node, so the node is lit among the others. Then
@@ -884,7 +884,7 @@ class NodeDetailScreen(Screen):
if tab is not None:
if tab.kind == "info":
budget = viewport - len(lines) - action_lines - 1 # the line of the rule
lines.extend(self._info_stage(width, budget))
lines.extend(self._info_stage(width, budget, short=short))
else:
route_blocks = [
self._route_row_lines(route, i == self._row_index, width)
@@ -936,17 +936,18 @@ class NodeDetailScreen(Screen):
self._scroll_total = max(1, len(lines))
return lines
def _info_stage(self, width: int, budget: int) -> list[str]:
def _info_stage(self, width: int, budget: int, *, short: bool = False) -> list[str]:
"""The block of vitals and, with a fix, the location preview grown to fit.
``budget`` is the number of visible lines that remain for the full stage. The rows
of vitals are never truncated. The preview changes its size instead: it takes the
lines that the vitals and its caption leave, up to ``_MAP_MAX_ROWS``. When that is
less than ``_MAP_MIN_ROWS``, the preview is not shown at all (a short frame keeps
its header and actions visible instead). Each row, but not the key, hangs under
itself when it wraps. The key keeps its one line and scrolls (refer to
:meth:`_key_line`). Thus the row count that sets the size of the preview cannot
change while the user scrolls through a long key.
lines that the vitals and its caption leave, between ``_MAP_MIN_ROWS`` and
``_MAP_MAX_ROWS``. When the vitals leave less than the minimum, the preview keeps
its minimum and the page scrolls. On a ``short`` frame, the preview is not shown at
all, because its minimum alone pushes the header or the actions off the screen.
Each row, but not the key, hangs under itself when it wraps. The key keeps its one
line and scrolls (refer to :meth:`_key_line`). Thus the row count that sets the
size of the preview cannot change while the user scrolls through a long key.
"""
lines: list[str] = [""] * tab_air() # the space of the stage under the strip, if any
for label, value in self._info_rows:
@@ -961,17 +962,16 @@ class NodeDetailScreen(Screen):
indent=_LABEL_LANE,
)
)
if self._minimap is not None:
# A short frame has no rows for the preview. The 14 rows of the Cardputer leave it
# two, and its minimum pushes the identity header and the actions off the screen.
if self._minimap is not None and not short:
caption = 1 if self._map_caption is not None else 0
room = budget - len(lines) - 1 - caption # minus the blank line above it
# Below its minimum, the preview is not shown. It does not push the identity
# header and the actions off a short frame (the 14 rows of the Cardputer leave
# it two).
if room >= _MAP_MIN_ROWS:
lines.append("")
lines.extend(self._minimap.render(width, min(_MAP_MAX_ROWS, room)))
if self._map_caption is not None:
lines.extend(render_lines(self._map_caption, width, no_wrap=True))
rows = max(_MAP_MIN_ROWS, min(_MAP_MAX_ROWS, room))
lines.append("")
lines.extend(self._minimap.render(width, rows))
if self._map_caption is not None:
lines.extend(render_lines(self._map_caption, width, no_wrap=True))
return lines
def _key_line(self, label: str, value: Text, width: int) -> str:
@@ -1242,6 +1242,74 @@ def _located(lat: float | None, lon: float | None) -> bool:
# -- data gathering + the action loop -----------------------------------------
class ContactPages:
"""The contacts whose node page a screen can open, and the page that opens on one of them.
This is the ^O key (``author_page``) of a conversation: the page of the node that wrote
the selected message. At each paint, the screen asks :meth:`find` for the contact of
the author. Thus its hint and its F-key lane name ^O only when the key acts. Then
:meth:`show` opens the page that the Contacts list opens for that contact. It is the
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.
"""
def __init__(self, ctx: AppContext, contacts: list[Contact]) -> 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._ctx = ctx
self._contacts = contacts
# 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:
"""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))
def find(self, key: str | None) -> Contact | None:
"""The contact with ``key`` (a full key or a key prefix), or ``None``.
The rule is the same as the rule of the map (refer to
:meth:`~meshterm.ui.map_screen.Locator.find`). A key prefix finds the contact whose
full key starts with it, and a full key finds a contact that MeshTerm knows only by
its key prefix.
"""
if not key:
return None
needle = key.lower().removeprefix("0x")
if needle not in self._found:
self._found[needle] = next(
(c for c in self._contacts if _same_key(c.public_key or c.key_prefix, needle)),
None,
)
return self._found[needle]
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.
"""
await open_node_detail(self._ctx, contact, manage=False)
def _same_key(contact_key: str | None, needle: str) -> bool:
"""Whether the key of a contact and a lowercase key (or key prefix) name the same node."""
key = (contact_key or "").lower().removeprefix("0x")
return bool(key) and (key.startswith(needle) or needle.startswith(key))
async def open_node_detail(
ctx: AppContext, contact: Contact | None, *, manage: bool = True
) -> bool:
@@ -1338,7 +1406,7 @@ async def open_node_detail(
except Exception: # noqa: BLE001 - optional reads. Safe defaults keep the page working.
prefix_bytes, width_bytes = 0, 1
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
# One resolver from hash to name for the full page. The route list and the route graph
# all name their hops through it (the contacts first, then the history of the recorder).
resolve = make_node_resolver(contacts, stored_names)
@@ -1491,7 +1559,7 @@ async def open_node_detail(
width_bytes,
resolve=resolve,
type_of=make_node_type_resolver(contacts),
key_of=make_name_key_resolver(contacts, stored_names),
key_of=make_name_key_resolver(contacts, stored_names, heard=named_at),
style=route_graph_style,
self_name=self_name,
node_label=label,
+39 -20
View File
@@ -42,9 +42,9 @@ packet, and Home from anywhere, go to the **pin**. On the pin, the viewer holds
position instead of a packet. Thus each new packet becomes the card that the user reads,
and the title says ``following`` instead of ``n/total``. ``↓`` steps off the pin onto
the packet that shows at that moment. That packet then moves down the list as newer
packets arrive. Thus a user who opened a packet to read it keeps it, and a user who
walked up to the top gets the stream. These are the same two stops, with the same keys,
as the feed under the dialog.
packets arrive. The viewer opens on the stop that the list had (``pinned``). Thus a user
who opened a packet to read it keeps it, and a user who watched the stream continues to
watch it. These are the same two stops, with the same keys, as the feed under the dialog.
"""
from __future__ import annotations
@@ -65,19 +65,22 @@ from ..services.trace_runner import NodeResolver
from .marks import SELF_MARK, UNKNOWN_MARK
from .menus import SEP_COMPACT, SEP_ROOMY
from .pathgraph import PathLayer, render_path_graph, revisited_hops
from .pathline import PathLine, hops_atom, path_line
from .pathline import PathHop, PathLine, hops_atom, path_line
from .theme import glyph, name_style, snr_style
from .trace_screen import snr_bar
from .tui.render import render_lines, render_to_ansi
from .tui.screen import Screen
from .widgets import (
DEFAULT_GLYPH,
DESTINATION,
NODE_GLYPHS,
ORIGIN,
NameKeyResolver,
TypeOf,
age_seconds,
format_ago,
highlighted_hash,
hop_chip,
node_type_legend,
revisit_note,
route_graph_style,
@@ -465,6 +468,7 @@ class PacketViewer(Screen):
self_name: str | None = None,
on_navigate: Callable[[PacketEntry], None] | None = None,
on_pin: Callable[[], None] | None = None,
pinned: bool = False,
channels: Sequence[tuple[str, bytes]] = (),
source: Callable[[], Sequence[PacketEntry]] | None = None,
type_of: TypeOf | None = None,
@@ -486,6 +490,10 @@ class PacketViewer(Screen):
same stop (the live feed) can also follow the stream, and still follows it
when the dialog closes. It is never called without a ``source``, because
the pin is a stop only where the list is live.
pinned: Whether to open on the pin instead of on the packet at ``index``.
The live feed gives its own stop here, so the viewer follows the stream
when the feed did. The open does not call ``on_pin``, because the list is
already on the pin. Without a ``source``, the viewer ignores this value.
channels: The configured channels of the device, as ``(name, secret)``
pairs. The viewer tries them on the channel-text packet of an overheard
``packet`` entry (refer to
@@ -532,18 +540,19 @@ class PacketViewer(Screen):
self._key_of = key_of
self._scope_of: ScopeOf = scope_of or unnamed_scope
self._message_scope = message_scope
#: The packet that shows now, followed by identity. Thus a live prepend to the
#: source (which moves each index) never moves the viewer onto another packet.
self._current: PacketEntry | None = self._entries[self._index] if self._entries else None
#: Whether the viewer is on the pin: the stop above the newest packet, which
#: holds the top position instead of a packet. It means that ``_index == 0``. The
#: pin is a mode on the newest entry, so all the code that reads the viewer (the
#: card, the scroll, the cache) needs no special case. Only the title and the
#: moves are different. An open never goes to the pin, because Enter selected
#: this packet (refer to
#: :meth:`~meshterm.ui.livefeed_screen.LiveFeedScreen._open_packet`). Thus the
#: user walks up to the stream on purpose, or not at all.
self._pinned = False
#: moves are different. An open goes to the pin only when the list was on its own
#: pin (refer to
#: :meth:`~meshterm.ui.livefeed_screen.LiveFeedScreen._open_packet`).
self._pinned = pinned and self._can_pin and bool(self._entries)
if self._pinned:
self._index = 0
#: The packet that shows now, followed by identity. Thus a live prepend to the
#: source (which moves each index) never moves the viewer onto another packet.
self._current: PacketEntry | None = self._entries[self._index] if self._entries else None
self._update_footer()
self._set_title()
@@ -837,7 +846,13 @@ class PacketViewer(Screen):
glyph, glyph_style = self._node_marker(entry, style)
who.append(glyph, style=glyph_style)
who.append(" ")
who.append(label, style=style)
if style == "muted": # a bare hash: no name to put in a chip
who.append(label, style=style)
else:
# The node that sent the packet is the origin of its route. Thus it is a chip
# that closes on the point, and the ``via`` row continues that route.
hop = PathHop(label, you=True) if style == "you" else PathHop(label, key=entry.node)
who.append_text(hop_chip(hop, role=ORIGIN))
if entry.node and label != entry.node:
who.append(" ")
who.append_text(highlighted_hash(entry.node, self._prefix_bytes))
@@ -1140,10 +1155,10 @@ class PacketViewer(Screen):
rows: list[tuple[str, RenderableType]] = []
dest = raw.get("dest_hash")
if dest:
rows.append(("to", self._endpoint(dest)))
rows.append(("to", self._endpoint(dest, DESTINATION)))
src = raw.get("src_key") or raw.get("src_hash")
if src:
rows.append(("from", self._endpoint(src)))
rows.append(("from", self._endpoint(src, ORIGIN)))
if raw.get("trace_tag"):
rows.append(("tag", Text(raw["trace_tag"], style="muted")))
readings = raw.get("trace_snrs")
@@ -1178,22 +1193,26 @@ class PacketViewer(Screen):
line.append(f"{value:+.2f} dB", style=snr_style(value))
return line
def _endpoint(self, value: str) -> Text:
def _endpoint(self, value: str, role: str) -> Text:
"""One end of an addressed packet: its resolved name, then the key that named it.
This is the presentation of the ``from`` row, without the node-type glyph: a
one-byte hash is too weak an identity for a type mark. A node that we cannot name
shows only the hash, all grey (``known=False``), as each unknown-node hash in the
app does.
A name is a chip at its end of the route (``role``). The sender closes on the point
and the recipient opens on the notch. The ``via`` row opens and closes on both,
because it is the middle (refer to :meth:`_via_path`). Thus the shape of each chip
shows which part of the one route its row names.
"""
text = Text()
named = self._resolve(value)
known = bool(named and named != value)
if known:
style = (
"you" if self._self_name and named == self._self_name else name_style(named, value)
)
text.append(named, style=style)
ours = bool(self._self_name and named == self._self_name)
hop = PathHop(named, you=True) if ours else PathHop(named, key=value)
text.append_text(hop_chip(hop, role=role))
text.append(" ")
text.append_text(highlighted_hash(value, self._prefix_bytes or 1, known=known))
return text
+7
View File
@@ -809,6 +809,13 @@ def node_geo_lookup(
A record stores canonical ids. Thus the lookup matches them the same as the resolver
does: a prefix on either side.
A hop that the topology could not resolve keeps its short hash (``3d``), and several
nodes can start with it. The lookup then makes a guess: the first entry that matches.
The score leaves such a hop out, so the drawing can show a point that the score did not
measure. JP chose this on 2026-10-07: the probable shape is more useful than a shape
that has lost a point. The name resolver makes the same guess, so the name and the pin
of a hop always agree.
Args:
heard: The heard nodes of the history (:meth:`Repository.heard_nodes`).
contacts: The contacts of the device.
+47 -36
View File
@@ -22,7 +22,7 @@ Three things make it different, and each one belongs to the room:
(:mod:`meshterm.ui.rooms`). Here, when the user opens a joined room, MeshTerm logs in to
it again only if the room has been quiet
(:meth:`~meshterm.services.rooms.RoomService.login_due`). It does this quietly, and the
board is open to read. ^L runs the explained login when the user wants it.
board is open to read. ^G runs the explained login when the user wants it.
* **Posting waits for the room.** A room drops, with no message, a post from anyone that it
does not know, or that it knows at the read-only access. Thus the compose line opens only
when we are logged in and can post. Until then, it says why it is not open and which key
@@ -51,6 +51,8 @@ from .chat import ChatScreen, _belongs, _contact_names, _make_paths_presenter, _
if TYPE_CHECKING:
from ..context import AppContext
from .map_screen import Locator
from .node_detail_screen import ContactPages
#: The title's login atom while a login is in flight.
LOGGING_IN = "logging in…"
@@ -94,32 +96,30 @@ class RoomScreen(ChatScreen):
"""The board of a room: posts under their authors, a compose line, and the login state.
It does everything that a chat does. When the user selects a post, Enter replies with an
``@mention``. ^P shows the paths that a post took. ^R tries again a post that the room
did not acknowledge. It also has ^L, which logs in to the room. It uses the remembered
password, or it asks for a password when none is remembered or when the last login
got no answer.
``@mention``. ^P shows the paths that a post took. ^L opens the map on the author of a
post, and ^O opens the node page of that author. ^R tries again a post that the room did
not acknowledge. It also has ^G, which logs in to the room. It uses the remembered
password, or it asks for a password when none is remembered or when the last login got
no answer.
"""
_empty_text = "No posts yet"
@property
def picocalc_lyra_lane(self):
"""The lane of the chat, with *Log in* on the Shift half of F1 (F6), where ^L has no key.
"""The lane of the chat, with *Log in* on the Shift half of F2 (F7), where ^G has no key.
Each chord that the desktop has needs a chip on the handheld. The Shift half of F1
is the one that the chat leaves free. The chip is dim while a login is in progress.
Each chord that the desktop has needs a chip on the handheld. The plain bank of F1
to F3 has the verbs that a room shares with a channel and a DM, and the Shift bank
has the verbs of the screen (refer to :attr:`ChatScreen.picocalc_lyra_lane`). Thus
``Retry`` (^R) stays on the Shift half of F1, as in a DM, and ``Log in`` is next to
it. The chip is dim while a login is in progress.
"""
from .tui.fkeys import FPair
from dataclasses import replace
lane = list(super().picocalc_lyra_lane)
day = lane[0]
lane[0] = FPair(
day.label,
day.action,
"Log in",
"login",
enabled=day.enabled,
opp_enabled=not self._login_open,
lane[1] = replace(
lane[1], opp_label="Log in", opp_action="login", opp_enabled=not self._login_open
)
return lane
@@ -139,6 +139,8 @@ class RoomScreen(ChatScreen):
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
key_of: Callable[[str], str | None] | None = None,
locator: Locator | None = None,
pages: ContactPages | None = None,
) -> None:
"""Build the room screen.
@@ -165,6 +167,8 @@ class RoomScreen(ChatScreen):
resend: Posts again a post that the room did not acknowledge (^R).
paths: Shows the paths that a selected post took (^P).
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.
"""
super().__init__(
conversation,
@@ -175,6 +179,8 @@ class RoomScreen(ChatScreen):
resend=resend,
paths=paths,
key_of=key_of,
locator=locator,
pages=pages,
)
self._label = conversation.label
self._resolve = resolve
@@ -190,7 +196,7 @@ class RoomScreen(ChatScreen):
# --- the login ----------------------------------------------------------------------
def begin_login(self, *, ask: bool = False) -> None:
"""^L: the explained login flow, with its prompt and its result floating over the board.
"""^G: the explained login flow, with its prompt and its result floating over the board.
Args:
ask: Ask for the password also when one is remembered. A member at the read-only
@@ -208,7 +214,7 @@ class RoomScreen(ChatScreen):
title says ``logging in…`` while the login runs, and the compose line waits for the
answer. A room that has forgotten us drops each post that we send. Thus the compose
line opens only when the room has let us in. If the room does not answer, this shows
in the same two places, and ^L is the explained retry.
in the same two places, and ^G is the explained retry.
"""
if self._login_open:
return
@@ -304,16 +310,16 @@ class RoomScreen(ChatScreen):
# Each reason is inside the 53 columns of the handhelds, so it is one line everywhere.
why = {
LOGGING_IN: "logging in… posting opens once the room lets you in",
NO_REPLY: "not logged in: no answer from the room · ^L to retry",
REFUSED: "not logged in: the login was refused · ^L to retry",
FAILED: "not logged in · ^L to try again",
NOT_JOINED: "not joined · ^L to join",
NO_REPLY: "not logged in: no answer from the room · ^G to retry",
REFUSED: "not logged in: the login was refused · ^G to retry",
FAILED: "not logged in · ^G to try again",
NOT_JOINED: "not joined · ^G to join",
}.get(self._login_state or "")
if why is None:
why = (
"read-only — the room drops your posts · ^L to log in"
"read-only — the room drops your posts · ^G to log in"
if self._access is RoomAccess.READ_ONLY
else "not logged in · ^L to log in"
else "not logged in · ^G to log in"
)
return Text(why, style="muted")
@@ -323,10 +329,10 @@ class RoomScreen(ChatScreen):
super()._submit()
def handle(self, action: str, data: str = "") -> None:
"""^L logs in. The chat handles all the other actions."""
"""^G logs in. The chat handles all the other actions."""
if action == "login":
# The remembered password of a member at the read-only access gave all that it
# can, so ^L asks for another password. For each other member, the flow tries
# can, so ^G asks for another password. For each other member, the flow tries
# the remembered password first and asks only if it fails.
self.begin_login(ask=self._access is RoomAccess.READ_ONLY)
self._session.invalidate()
@@ -342,18 +348,19 @@ class RoomScreen(ChatScreen):
@property
def footer_hint(self) -> str:
"""The hint of the chat, in the words of a board, with ^L when a login can start."""
"""The hint of the chat, in the words of a board, with ^G when a login can start.
A selected post has the hint of a selected message in the chat. With no place for a
reply, Enter shows the paths of the post there too.
"""
if self._selected is not None:
code = " · ^U QR" if self._picked_urls() else ""
if not self._composing: # no place for a reply: Enter shows the paths of the post
return f"Enter paths{code} · ↑↓ pick · ^End/Esc cancel"
return f"Enter reply (@mention) · ^P paths{code} · ↑↓ pick · ^End/Esc cancel"
return super().footer_hint
atoms = ["Enter send"] if self._composing else []
atoms.append("↑ pick a message")
if self._retry_target() is not None:
atoms.append("^R retry failed")
if not self._login_open:
atoms.append("^L log in")
atoms.append("^G log in")
atoms.append("Esc back")
return " · ".join(atoms)
@@ -366,7 +373,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
board is readable. The login starts the catch-up of the room again. A room that MeshTerm
heard from recently sends nothing. The Rooms page joins a room
(:mod:`meshterm.ui.rooms`). Thus a room that is not joined opens only to read its
stored posts, and ^L joins it. Posts that arrive while the board is open are added at
stored posts, and ^G joins it. Posts that arrive while the board is open are added at
once, and the unread count of the room stays clear while the user is on the board.
Args:
@@ -377,6 +384,8 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
The number of posts on the board when the screen closed.
"""
from ..services import trace_runner
from .map_screen import Locator
from .node_detail_screen import ContactPages
from .rooms import join_room
session = ctx.ui.session
@@ -395,7 +404,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
posts_only=True,
)
contacts = await ctx.devstate.contacts()
stored_names = ctx.repo.node_names()
stored_names, named_at = ctx.repo.node_names_with_times()
async def send(text: str) -> ChatMessage | None:
return await _with_restore(ctx, lambda: ctx.chat.send_post(room, text))
@@ -429,7 +438,9 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
access=ctx.rooms.access(room),
resend=resend,
paths=await _make_paths_presenter(ctx, conversation, device),
key_of=trace_runner.make_name_key_resolver(contacts, stored_names),
key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at),
locator=await Locator.gather(ctx),
pages=ContactPages(ctx, list(contacts)),
)
ctx.chat.set_active(conversation.key)
+1 -1
View File
@@ -19,7 +19,7 @@ password worked before. The page then offers the next step as buttons: try by fl
first forgets the stale route), try again, or try another password. Each of these is one
more login that the user asked for. MeshTerm never sends one on its own.
This flow (:func:`join_room`) is shared. The ^L key of the board also runs it. The
This flow (:func:`join_room`) is shared. The ^G key of the board also runs it. The
``rooms join`` command of the command line explains a silent room in the same words
(:func:`silence_explanation`).
+10 -5
View File
@@ -20,7 +20,7 @@ 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 _recency_style, channel_glyph, name_chip
from .widgets import DESTINATION, ORIGIN, _recency_style, channel_glyph, name_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
@@ -131,17 +131,22 @@ def specimen_lines() -> list[RenderableType]:
names.append("me", style="you")
lines.append(names)
# The same names as chips. These are the sender labels of the chat. The path line's
# 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.
chips = Text("chips ")
for name, key in _DEMO_NAMES[:2]:
chips.append_text(name_chip(name, key))
chips.append_text(name_chip(name, key, role=ORIGIN))
chips.append(" ")
chips.append_text(name_chip("", you=True))
chips.append_text(name_chip("", you=True, role=ORIGIN))
chips.append(" ")
chips.append_text(name_chip("·"))
chips.append_text(name_chip("·", role=ORIGIN))
chips.append(" ")
name, key = _DEMO_NAMES[2]
chips.append_text(name_chip(f"@{name}", key, role=DESTINATION))
lines.append(chips)
lines.append(Text("heard ").append_text(_ages_row()))
+33 -1
View File
@@ -825,6 +825,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)
@@ -1099,6 +1129,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 +1140,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
+6 -5
View File
@@ -226,8 +226,9 @@ class LaneDeck:
"""The action that F-key ``number`` resolves to on this lane, or ``None``.
A plain key takes the action of its slot, and a Shift-bank key takes the action of
its companion. The result is ``None`` where the slot has no action (a lone slot, or
an unassigned slot), and where the keyboard key is not a key of this deck. A dim
its companion. The result is ``None`` where the slot has no action (a lone slot, a
slot that has only a Shift-bank companion, or an unassigned slot), and where the
keyboard key is not a key of this deck. A dim
slot still resolves. Dimming is how the lane draws an action on which the screen
does nothing anyway. Because the action resolves in all cases, a lane that was
built from stale metrics can never block a key press that works (refer to the
@@ -244,7 +245,7 @@ class LaneDeck:
return None
if shifted:
return pair.opp_action or None
return pair.action
return pair.action or None
def lane_text(self, lane: Lane, *, shifted: bool = False) -> Text:
"""Render the lane as this deck's chips: coloured fill, white text, each at its column.
@@ -366,10 +367,10 @@ _HINT_KEY_ACTIONS: dict[str, str] = {
}
#: Marks a word as a key notation of some type, also where this module cannot map it: a
#: chord (``^Y``), a shifted key (``⇧Tab``), or a bare glyph key (``⌫``). If the verb half
#: chord (``^L``), a shifted key (``⇧Tab``), or a bare glyph key (``⌫``). If the verb half
#: of an atom has one of these marks, the atom names more than the keys at its start. Thus
#: the atom is never removed because of the keys that it did name (the map's
#: ``Home/^Y region/you``).
#: ``Home/^L region/you``).
_KEYISH = ("^", "⇧", "⌫", "↑", "↓", "←", "→")
+45 -10
View File
@@ -395,13 +395,15 @@ def render_hanging(
``whole`` names runs of the body that must not be cut where the screen can hold them.
A URL is such a run: a terminal can open it, and a user can copy it, only while it is
on one line. For a run that fits the hanging lane, nothing is necessary, because the
wrap already moves a word to the next line and does not split it. A run that is wider than the
lane, but not wider than the screen, steps out of the block onto a line of its own. It
starts at ``gutter``, or as far to the right of the gutter as still fits. After it,
the body continues under its indent, unless the text that follows is short enough to
finish the line of the run. Only a run wider than the full screen is folded, as
before, because no line can hold it.
on one line. A chip is another: a mention that breaks between its notch and its name
reads as two things. A run that fits the hanging lane moves to the next line as one
word. The wrap breaks only at a space, and a space inside a run does not count for it
(refer to :func:`_held`), because a chip has its pads and a name can have spaces. A
run that is wider than the lane, but not wider than the screen, steps out of the block
onto a line of its own. It starts at ``gutter``, or as far to the right of the gutter
as still fits. After it, the body continues under its indent, unless the text that
follows is short enough to finish the line of the run. Only a run wider than the full
screen is folded, as before, because no line can hold it.
Args:
prefix: The leading run (for example, a pointer and a timestamp), shown one time,
@@ -427,15 +429,19 @@ def render_hanging(
(start, end) for start, end in sorted(whole) if avail < cell_len(plain[start:end]) <= width
]
rows: list[tuple[int, Text]] = [] # (left pad, line). The pad of the first row is the prefix.
held, stand_in = _held(body, whole)
def hang(start: int, end: int) -> None:
"""Wrap ``body[start:end]`` under the indent, and trim the seams next to a run."""
if out:
while start < end and plain[start].isspace() and start:
while start < end and held.plain[start].isspace() and start:
start += 1
while end > start and plain[end - 1].isspace():
while end > start and held.plain[end - 1].isspace():
end -= 1
lines = list(body[start:end].wrap(console, avail)) if end > start else []
lines = list(held[start:end].wrap(console, avail)) if end > start else []
if stand_in:
for line in lines:
line.plain = line.plain.replace(stand_in, " ")
if not rows and not lines:
lines = [Text("")] # the line of the prefix, also when the body starts with a run
rows.extend((indent, line) for line in lines)
@@ -467,3 +473,32 @@ def render_hanging(
combined.append_text(prefix if i == 0 else Text(" " * pad))
combined.append_text(line)
return render_lines(combined, width)
def _held(body: Text, runs: Sequence[tuple[int, int]]) -> tuple[Text, str]:
"""A copy of ``body`` in which no run has a space, and the character that stands in for one.
The wrap of Rich breaks a line only at whitespace. Thus a run that has no space in it
moves to the next line whole, and a run that has spaces can break at each of them. In
the copy, each space inside a run is a character that the body does not use, from the
private use area. It has one cell, the same as the space, so the wrap measures the same
widths. The caller wraps the copy, then puts the spaces back on each line.
Args:
body: The text that is about to wrap.
runs: ``(start, end)`` offsets into ``body.plain`` of the runs to keep together.
Returns:
``(copy, stand_in)``. When no run has a space, this is the body itself and ``""``.
"""
plain = body.plain
spaced = [(start, end) for start, end in runs if " " in plain[start:end]]
if not spaced:
return body, ""
stand_in = next(c for c in map(chr, range(0xE000, 0xF900)) if c not in plain)
chars = list(plain)
for start, end in spaced:
chars[start:end] = [stand_in if c == " " else c for c in chars[start:end]]
held = body.copy()
held.plain = "".join(chars) # the same length, so each span stays where it was
return held, stand_in
+20 -6
View File
@@ -76,9 +76,15 @@ from .spinner import spinner_interval
#: ``Keys.ControlM``/``ControlI``/``ControlH``. If this table claims them, it binds those
#: keys again.
#: The letter of a chord is the mnemonic of the action, not of the word that one screen uses
#: for it. ``locate`` is ^Y for **you** (JP, 2026-08-09). It is the same keyboard key on the
#: map and in the mesh walk, because on both it names our node. It was ^U until 2026-10-01.
#: Then U went to ``url_code``: the links of a chat message as QR codes.
#: for it. ``locate`` is ^L: on the map and in the mesh walk it goes to our node, and in the
#: chat and on Contacts it opens the map on the node of the selected row. It was ^U until
#: 2026-10-01, then ^Y until 2026-10-10, when ``login`` moved from ^L to ^O (JP). On
#: 2026-10-11, ``login`` moved again to ^G, and ^O became ``author_page``: the node page of
#: the author of the selected message, the same as ^L opens the map on that author (JP).
#: One action for each letter in all screens is the preference, not the rule (JP,
#: 2026-10-10). A screen that has no use for the action of a letter can give the letter a
#: different action, if no terminal or multiplexer takes it. ^A and ^B are not free for
#: this reason: they are the prefix keys of GNU screen and tmux.
#:
#: Two of these chords are for the full app, not for one screen:
#:
@@ -96,8 +102,17 @@ from .spinner import spinner_interval
#: ``IXON``/``IXOFF``, so ^Q is not XON here.
_CTRL_LETTER_CHORDS: dict[str, str] = {
"c": "quit",
# ^L logs in to the room that a room screen shows.
"l": "login",
# ^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",
"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
# discard output.
"o": "author_page",
"p": "paths",
"q": "quit",
"r": "retry",
@@ -108,7 +123,6 @@ _CTRL_LETTER_CHORDS: dict[str, str] = {
"u": "url_code",
"v": "paste_clipboard",
"w": "to_menu",
"y": "locate",
}
#: Maps the prompt_toolkit keys to the normalized action names that the screens understand.
+7 -7
View File
@@ -51,7 +51,7 @@ while it fits. If it does not fit, it is *cropped* at each edge where the walk
continues: the chip breaks on its own half block, the same as each path row in the app
that is too long for its lane. At rest, the window is at the tail. Thus the focus and the
last steps are visible, and the crop removes the start of the walk. **←→ slide the
window**, so that you can read back over a long walk. **^Y** puts the focus on our node
window**, so that you can read back over a long walk. **^L** puts the focus on our node
again. **Typing finds**: a filter over all the nodes in the graph, islands also. While you
type, it also narrows the fan on the canvas to the neighbours that match, and the focus
stays. Enter teleports the focus to the highlighted match. The trail then starts again
@@ -253,7 +253,7 @@ class WalkScreen(Screen):
to stand in for *Top* and leave *Bottom* blank to keep up the pretence. Instead, it
takes a slot of this screen (JP, 2026-08-09), because F1-F3 are for this purpose.
The Shift bank of the pager then has the same meaning here as in all other places:
the two ends of the list that this screen scrolls. On a keyboard, ^Y does the same
the two ends of the list that this screen scrolls. On a keyboard, ^L does the same
action. The map uses the same chord for our node.
The two navigation slots must have rows to move through, and a leaf node in a
@@ -301,7 +301,7 @@ class WalkScreen(Screen):
self._self_label = self_label
self._prefix_bytes = prefix_bytes
#: The walked trail of canonical ids. The focus is its last entry. A walk appends,
#: ⌫ pops, ^Y resets the trail to our node, and a find teleport starts it again.
#: ⌫ pops, ^L resets the trail to our node, and a find teleport starts it again.
self._trail: list[str] = [topo.self_id]
#: The cells by which the breadcrumb line is scrolled off its *tail* end (refer to
#: :meth:`_trail_text`). 0 is the rest state (the focus at the right edge). Each
@@ -450,7 +450,7 @@ class WalkScreen(Screen):
atoms = ["↑↓ move"]
if scrolls:
atoms.append("←→ trail")
atoms += ["Enter focus", "⌫ back", "^Y you"]
atoms += ["Enter focus", "⌫ back", "^L you"]
if not scrolls:
atoms.append("type to find")
atoms.append("Esc back")
@@ -460,7 +460,7 @@ class WalkScreen(Screen):
"""Move the highlight, walk, step back, find, or close.
Home/End go to the ends of the list here, the same as on each other screen that
scrolls. The jump back to our node is ``locate`` (^Y, and F3 on the lane), which is
scrolls. The jump back to our node is ``locate`` (^L, and F3 on the lane), which is
not a position in this list. Refer to :attr:`picocalc_lyra_lane`.
"""
rows = self._rows()
@@ -512,7 +512,7 @@ class WalkScreen(Screen):
elif action == "right":
self._trail_scroll = max(0, self._trail_scroll - _HSCROLL_STEP)
elif action == "locate":
# ^Y (and F3): stop the walk, instead of a move inside it. The trail goes back
# ^L (and F3): stop the walk, instead of a move inside it. The trail goes back
# to only our node, and a find that narrows the list is also removed.
self._trail = [self._topo.self_id]
self._filter = ""
@@ -676,7 +676,7 @@ class WalkScreen(Screen):
is measured only when the two marks are known, or the line draws one cell past the
row. The slide stops when the head becomes visible, so the line does not move off
the edge. Each change to the trail resets the slide: a walk, a step back, a
teleport, and ^Y all end with the focus visible, because the user reads the next
teleport, and ^L all end with the focus visible, because the user reads the next
move from there.
"""
self._trail_width = width
+77 -7
View File
@@ -58,6 +58,7 @@ from .marks import (
parse_hex,
)
from .pathline import SELF_GLYPH, PathHop, PathLine, path_line
from .termfont import powerline_enabled
from .theme import glyph, mark_rgb, name_style, node_style, snr_style
if TYPE_CHECKING:
@@ -580,7 +581,77 @@ def highlighted_hash(
return text
def name_chip(label: str, key: str | None = None, *, you: bool = False) -> Text:
#: The roles of a lone chip in a route that it does not draw (the ``role`` of
#: :func:`hop_chip`). The chip of an *origin* closes on the point, because the route goes
#: on from it: the author of a message, the sender of a packet. The chip of a
#: *destination* opens on the notch, because the route came to it: the node that a
#: mention names, the recipient of a packet or of a queued message.
ORIGIN = "origin"
DESTINATION = "destination"
def hop_chip(hop: PathHop, *, role: str | None = None) -> Text:
"""One hop drawn alone, as the single chip of a path line.
The ends of the chip say the same thing as the ends of a route. With no ``role``, the
node is the whole route, and the two ends are square (or rounded where the font has
the caps). A ``role`` puts the chip at one end of a route that is not drawn, and the
other end of the chip shows that the route continues there (refer to
:data:`ORIGIN` and :data:`DESTINATION`). A message goes from its author toward the
nodes that it names. Thus the author closes on the point and a mention opens on the
notch, as the first and the last hop of a route do (JP, 2026-10-10).
Where the terminal cannot draw powerline separators, a path line is text in arrow mode,
and a single hop is only its label in its own style. There the ``role`` does nothing.
A lone name with a stray ``→`` before or after it reads as a broken route, and not as a
sender or as a mention (JP, 2026-10-10). The text there also has no
:data:`~meshterm.ui.pathline.PATH_INK` stamp. A lone name in arrow mode is a name, and
not a route whose hues tell one hop from the next. Thus the highlight of a list row
changes it to white, as it changes each other name on the row
(refer to :func:`~meshterm.ui.tui.render._whiten_identities`).
Args:
hop: The hop to draw.
role: :data:`ORIGIN`, :data:`DESTINATION`, or ``None`` for a node alone.
Returns:
The chip as a one-line :class:`Text`.
"""
if not powerline_enabled():
line = PathLine([hop]).text()
line.style = ""
return line
return PathLine([hop], from_origin=role != DESTINATION, to_destination=role != ORIGIN).text()
def route_ends(origin: PathHop, destination: PathHop) -> Text:
"""The two ends of a route whose middle is not drawn: an origin chip and a destination chip.
The point of the origin and the notch of the destination face each other across one
cell of page. Thus the line reads as a route from the one to the other, and it does not
claim that the two are neighbours. A seam between two chips makes that claim. Where the
terminal draws no chips, the two ends are joined by the arrow of the app
(``sender → recipient``), which says the same thing in text.
Args:
origin: The node where the route starts (the sender).
destination: The node where the route ends (the recipient).
Returns:
The line, not fitted. A caller that must fit it to a lane cuts it with
:func:`~meshterm.ui.pathline.cut_to`, so that a chip cracks and is not elided.
"""
if not powerline_enabled():
return PathLine([origin, destination]).text()
line = hop_chip(origin, role=ORIGIN)
line.append(" ")
line.append_text(hop_chip(destination, role=DESTINATION))
return line
def name_chip(
label: str, key: str | None = None, *, you: bool = False, role: str | None = None
) -> Text:
"""A node name drawn as the single chip of a path line: the only frame for a name.
This is not an imitation of the chip language. It uses the chip language. The function
@@ -590,11 +661,9 @@ def name_chip(label: str, key: str | None = None, *, you: bool = False) -> Text:
them (JP, 2026-08-12). The colour reads the same as in a route. The name has the hue
of the node, on a darker fill of that hue. A node that no key can place has the
keyless grey. Our own end is the ``★`` of the map on its neutral dark grey, and not a
name.
The degradation is also inherited. Where the terminal cannot draw powerline
separators, a path line is text in arrow mode, and a single hop is only the name in its
own hue. This is what a sender label was before it had a frame.
name. The ``role`` and the arrow mode are those of :func:`hop_chip`. In arrow mode, a
name is only the name in its own hue. This is what a sender label was before it had a
frame.
Args:
label: The name as it must read (it is already resolved, and this function never
@@ -605,12 +674,13 @@ def name_chip(label: str, key: str | None = None, *, you: bool = False) -> Text:
only for identities that have a key.
you: ``True`` if this is our node. It is the ``★``, the same as a route draws our
end of it.
role: :data:`ORIGIN`, :data:`DESTINATION`, or ``None`` for a node alone.
Returns:
The chip as a one-line :class:`Text`.
"""
hop = PathHop(SELF_GLYPH, you=True) if you else PathHop(label, key=key or None)
return PathLine([hop]).text()
return hop_chip(hop, role=role)
def _shorten_hash(value: str, hash_bytes: int | None) -> str:
+38 -10
View File
@@ -36,7 +36,7 @@ import builtins
import platform
import sys
from dataclasses import dataclass
from importlib.metadata import Distribution, PackagePath, distribution
from importlib.metadata import Distribution, PackagePath, distribution, distributions
from pathlib import Path
from packaging.markers import default_environment
@@ -318,16 +318,41 @@ def dependency_closure(root: str = ROOT_DISTRIBUTION) -> list[Distribution]:
return sorted(seen.values(), key=lambda d: canonicalize_name(d.metadata["Name"]))
def collect_notices(root: str = ROOT_DISTRIBUTION) -> list[Notice]:
def installed_in(site: str | Path, root: str = ROOT_DISTRIBUTION) -> list[Distribution]:
"""Each distribution installed in the directory ``site``, except ``root``.
The Debian package for the Cardputer Zero installs MeshTerm with ``pip install
--target`` (refer to ``scripts/cardputer-zero/build-deb.sh``). Thus that directory holds
exactly the distributions that the package bundles. They include the ``spi`` extra,
which :func:`dependency_closure` does not follow, because it asks for no extras. The
directory is the true list, so this function reads it and does not walk requirements.
Returns:
The distributions, in the order of their canonical (PEP 503) name, as
:func:`dependency_closure` returns them.
"""
found = {canonicalize_name(d.metadata["Name"]): d for d in distributions(path=[str(site)])}
found.pop(canonicalize_name(root), None)
return [found[key] for key in sorted(found)]
def collect_notices(root: str = ROOT_DISTRIBUTION, site: str | Path | None = None) -> list[Notice]:
"""All the notices that ``THIRD-PARTY-NOTICES.txt`` of this build must carry.
The PSF notice of Python is always first. Python is not a PyPI distribution that
``Requires-Dist`` can name, so it can never appear in :func:`dependency_closure`. Each
dependency follows it, in the order of its name. Thus the difference between the notices
file of one build and the next is stable, and a person can examine it.
Args:
root: The distribution that the build ships.
site: A directory that ``pip install --target`` filled. If it is given, the
dependencies are the distributions in it (:func:`installed_in`). If it is
``None``, they are the closure of ``root`` in this interpreter.
"""
notices = [_python_notice()]
for dist in dependency_closure(root):
bundled = dependency_closure(root) if site is None else installed_in(site, root)
for dist in bundled:
canonical = canonicalize_name(dist.metadata["Name"])
notices.append(
Notice(
@@ -364,29 +389,32 @@ def render(notices: list[Notice]) -> str:
return "\n".join(blocks).rstrip("\n") + "\n"
def write_third_party_notices(path: str | Path, root: str = ROOT_DISTRIBUTION) -> Path:
def write_third_party_notices(
path: str | Path, root: str = ROOT_DISTRIBUTION, site: str | Path | None = None
) -> Path:
r"""Generate ``THIRD-PARTY-NOTICES.txt`` and write it to ``path``.
The function writes raw UTF-8 bytes. It does not use text mode. Thus a Windows build
does not change the ``\n`` line endings to ``\r\n``. The content of the file must be the
same, byte for byte, on each platform that builds it.
same, byte for byte, on each platform that builds it. ``site`` has the meaning that
:func:`collect_notices` gives it.
Returns:
``path``, as a :class:`~pathlib.Path`, for the convenience of the caller.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(render(collect_notices(root)).encode("utf-8"))
path.write_bytes(render(collect_notices(root, site)).encode("utf-8"))
return path
def main(argv: list[str] | None = None) -> int:
"""CLI entry point: ``python packaging/notices.py OUTPUT_PATH``."""
"""CLI entry point: ``python packaging/notices.py OUTPUT_PATH [SITE_DIR]``."""
args = sys.argv[1:] if argv is None else argv
if len(args) != 1:
print("usage: notices.py OUTPUT_PATH", file=sys.stderr)
if len(args) not in (1, 2):
print("usage: notices.py OUTPUT_PATH [SITE_DIR]", file=sys.stderr)
return 2
written = write_third_party_notices(args[0])
written = write_third_party_notices(args[0], site=args[1] if len(args) == 2 else None)
print(f"wrote {written}")
return 0
+1 -1
View File
@@ -5,7 +5,7 @@ These are tools for the repository. None of them is in the wheel.
| Script | What it is for |
|---|---|
| `basemap-doctor.py` | Finds why a user's map has no basemap: certificates, DNS, proxy, or colour depth |
| `cardputer-zero/` | `launcher-entry.sh` puts a source checkout in the Cardputer Zero's app launcher. `remove` takes it out. This is for the time before there is a store package. `meshterm.png` is the icon: the website's logo, at the launcher's 100×100 size. `type-text.py` types a pasted string (an API key, a Wi-Fi password) on the Cardputer's keyboard from another computer, over SSH |
| `cardputer-zero/` | `build-deb.sh` builds the Debian package for M5Stack's app store. Run it on the Cardputer Zero or on another arm64 Debian 13 system. `store/` holds the listing for the store (`app-builder.json`, which `czdev publish` reads), its icon, and its screenshots. `screenshot.py` saves the display of a Cardputer as a 320×170 PNG over SSH, as the store wants a screenshot. `store-publish.py` sends a package to the store with M5Stack's `czdev`, with two corrections. The release workflow runs it after each release. `launcher-entry.sh` puts a source checkout in the Cardputer Zero's app launcher. `remove` takes it out. `meshterm.png` is the package's icon: the website's logo, at the launcher's 100×100 size. `meshterm-dev.png` is its negative, for the checkout's entry, so that the two entries look different. `type-text.py` types a pasted string (an API key, a Wi-Fi password) on the Cardputer's keyboard from another computer, over SSH |
| `picocalc-lyra/` | Setup of the Calculinux device: console font, palette, Wi-Fi. `xiao-radio/` is the kit for the XIAO radio (firmware build, flashing, UART setup) |
| `uconsole/` | The uConsole's always-on SPI bridge service. MeshTerm can also control that radio directly, with no script (refer to `docs/devices/uconsole.md`) |
+171
View File
@@ -0,0 +1,171 @@
#!/bin/sh
# SPDX-License-Identifier: Apache-2.0
# build-deb -- build the Debian package of MeshTerm for the Cardputer Zero's app store.
#
# Usage: scripts/cardputer-zero/build-deb.sh [REVISION]
#
# Run it on arm64 Debian 13 ("Trixie") with Python 3.13: on the Cardputer Zero itself, or
# in a debian:trixie container on an arm64 machine. The package holds compiled code
# (spidev, pycryptodome, and others), so it must be built on the system that it is for.
# Build it from a clean checkout of a release tag. pip builds MeshTerm from this tree, and
# it also takes the files in meshterm/ that git does not track. On the Cardputer Zero, /tmp
# is in memory and too small for the build: set TMPDIR to a directory on the card.
#
# The package depends only on python3. All else is inside it:
#
# /usr/lib/meshterm/site MeshTerm and its Python packages, compiled to bytecode
# /usr/lib/meshterm/fonts Terminus 6x12, regular and bold, and its licence (the OFL)
# /usr/share/APPLaunch/ the launcher entry, its icon, and the wrapper that it runs
# /usr/bin/meshterm the command line, for a user on SSH
# /usr/share/doc/meshterm/ LICENSE, NOTICE, THIRD-PARTY-NOTICES.txt, and copyright
#
# We add no other Debian dependency on purpose. When a dependency is not installed, the
# store app runs `apt-get install`, but it never runs `apt-get update`. On a device with
# old package lists, that install can fail. For the same reason the package carries the
# font: Terminus is not in the repository or in the wheel, but a launcher app has no
# terminal in which the user can run `meshterm emulate --fetch-fonts`.
#
# REVISION is the Debian revision, 1 by default. Increase it to publish a new package of
# the same MeshTerm version, because the store refuses a version that is not newer.
# Set TERMINUS_ARCHIVE to the path of terminus-font-4.49.1.tar.gz if the download fails.
# The build checks that archive against the same digest.
set -eu
umask 022
HERE=$(cd "$(dirname "$0")" && pwd)
SOURCE=$(cd "$HERE/../.." && pwd)
REVISION=${1:-1}
PYTHON=/usr/bin/python3
die() {
echo "build-deb: $*" >&2
exit 1
}
command -v dpkg-deb >/dev/null || die "no dpkg-deb here -- run this on Debian"
[ "$(dpkg --print-architecture)" = arm64 ] || die "this is not arm64 -- the package holds arm64 code"
[ "$("$PYTHON" -c 'import sys; print("%d.%d" % sys.version_info[:2])')" = 3.13 ] ||
die "$PYTHON is not Python 3.13 -- the package is for Debian 13"
case "$REVISION" in
*[!0-9]* | "") die "the revision must be a number, not '$REVISION'" ;;
esac
VERSION=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "$SOURCE/meshterm/__init__.py")
[ -n "$VERSION" ] || die "no __version__ in $SOURCE/meshterm/__init__.py"
DEB_VERSION="$VERSION-$REVISION"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
STAGE="$WORK/root"
LIB="$STAGE/usr/lib/meshterm"
DOC="$STAGE/usr/share/doc/meshterm"
APPLAUNCH="$STAGE/usr/share/APPLaunch"
echo "building meshterm $DEB_VERSION from $SOURCE"
# A venv for the tools of the build only. It has a current pip and the `packaging` library
# that packaging/notices.py imports. Its base is the system Python, so the compiled
# packages that its pip builds are correct for the Python that runs them.
"$PYTHON" -m venv "$WORK/venv"
"$WORK/venv/bin/pip" install --quiet --upgrade pip packaging
# MeshTerm and its packages, with the radio of the Cap LoRa-1262 (the `spi` extra).
# --no-compile, because the bytecode is compiled below in a different mode.
"$WORK/venv/bin/pip" install --quiet --no-compile --target "$LIB/site" "$SOURCE[spi]"
# pip writes console scripts whose first line names the build venv. The wrappers below
# replace them.
rm -rf "$LIB/site/bin"
# The licences: MeshTerm's own, and the licence of each package in the site directory.
mkdir -p "$DOC"
cp "$SOURCE/LICENSE" "$SOURCE/NOTICE" "$DOC/"
"$WORK/venv/bin/python" "$SOURCE/packaging/notices.py" "$DOC/THIRD-PARTY-NOTICES.txt" "$LIB/site"
cat >"$DOC/copyright" <<EOF
MeshTerm $VERSION
Source: https://github.com/jpmartineau/MeshTerm
MeshTerm is licensed under the Apache License, Version 2.0. Refer to LICENSE and NOTICE in
this directory. The name "MeshTerm" and its logo are trademarks. NOTICE has the terms.
The Python packages in /usr/lib/meshterm/site have their own licences. Refer to
THIRD-PARTY-NOTICES.txt in this directory.
The Terminus font in /usr/lib/meshterm/fonts is licensed under the SIL Open Font
License, Version 1.1. Refer to /usr/lib/meshterm/fonts/OFL.TXT.
EOF
# Terminus, downloaded and checked against its digest by meshterm.emulator.fetch.
MESHTERM_EMULATOR_FONTS="$LIB/fonts" PYTHONPATH="$LIB/site" "$PYTHON" -s -c '
import os, sys
from pathlib import Path
from meshterm.emulator.fetch import install_terminus
archive = os.environ.get("TERMINUS_ARCHIVE")
install_terminus(Path(archive) if archive else None)
'
# Bytecode. The user cannot write in /usr/lib, so Python cannot cache bytecode there at
# run time. Without this step, each start compiles all of MeshTerm again, and the start is
# several seconds slower. "unchecked-hash": the files never change after the install, so
# Python does not compare them with the source at each import.
"$PYTHON" -m compileall -q -j 0 --invalidation-mode unchecked-hash "$LIB/site" >/dev/null
# The wrappers. PYTHONPATH is exported, so that a child process of MeshTerm finds the same
# packages. -s keeps the packages of the user's own site directory out.
wrapper() {
cat >"$1" <<EOF
#!/bin/sh
# $2
export PYTHONPATH=/usr/lib/meshterm/site
export MESHTERM_EMULATOR_FONTS="\${MESHTERM_EMULATOR_FONTS:-/usr/lib/meshterm/fonts}"
exec /usr/bin/python3 -s -m $3 "\$@"
EOF
chmod 755 "$1"
}
mkdir -p "$STAGE/usr/bin" "$APPLAUNCH/bin" "$APPLAUNCH/applications" "$APPLAUNCH/share/images"
wrapper "$STAGE/usr/bin/meshterm" "MeshTerm's command line." meshterm
wrapper "$APPLAUNCH/bin/meshterm" "MeshTerm from the launcher: it draws on the display itself." meshterm.emulator
# The launcher entry. Terminal=false gives the app the display and the keyboard.
cat >"$APPLAUNCH/applications/meshterm.desktop" <<EOF
[Desktop Entry]
Name=MeshTerm
Exec=/usr/share/APPLaunch/bin/meshterm
Icon=share/images/meshterm.png
Terminal=false
Type=Application
EOF
install -m 644 "$HERE/meshterm.png" "$APPLAUNCH/share/images/meshterm.png"
# Make sure that the bundle runs before it is packaged: the help, a tool's help (the tools
# are found at run time), the written pages (the specimen), and the font of the display.
# MESHTERM_HOME keeps the files that a run writes out of the home directory of the builder.
check() {
MESHTERM_HOME="$WORK/home" MESHTERM_EMULATOR_FONTS="$LIB/fonts" PYTHONPATH="$LIB/site" \
"$PYTHON" -s "$@" >/dev/null
}
check -m meshterm --help
check -m meshterm contacts --help
check -m meshterm specimen
check -c 'from meshterm.emulator.font import find_font; find_font()'
mkdir -p "$STAGE/DEBIAN"
cat >"$STAGE/DEBIAN/control" <<EOF
Package: meshterm
Version: $DEB_VERSION
Architecture: arm64
Maintainer: Jean-Pierre Martineau <johnputer@meshterm.net>
Installed-Size: $(du -sk "$STAGE" | cut -f1)
Depends: python3 (>= 3.13), python3 (<< 3.14)
Section: hamradio
Priority: optional
Homepage: https://meshterm.net
Description: full-featured MeshCore client for the Cardputer Zero
MeshTerm is a full-featured TUI MeshCore client. On the Cardputer Zero it
draws on the display itself, and it runs the Cap LoRa-1262 as its own radio.
It can also connect to a companion over USB, Bluetooth, or TCP.
EOF
mkdir -p "$SOURCE/dist"
OUT="$SOURCE/dist/meshterm_${DEB_VERSION}_arm64.deb"
dpkg-deb --root-owner-group -Zxz --build "$STAGE" "$OUT" >/dev/null
echo "built $OUT ($(du -h "$OUT" | cut -f1))"
+12 -7
View File
@@ -14,7 +14,12 @@
# This is the entry for developers. Run it from a source checkout that has its venv at
# .venv (refer to docs/devices/cardputer-zero.md). It writes into /usr/share, so it asks
# sudo for the two files and for nothing else. Then it restarts the launcher, so that the
# icon appears. A package from a store will replace it, and `remove` takes it out again.
# icon appears. `remove` takes it out again.
#
# The entry has its own file names and a negative icon (meshterm-dev.png). Thus it is not
# the same as the entry of the package from the store (build-deb.sh), and the two can be
# installed together. The package owns meshterm.desktop and meshterm.png, and this script
# never touches them.
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
@@ -39,18 +44,18 @@ install)
[Desktop Entry]
Name=MeshTerm
Exec=$PYTHON -m meshterm.emulator
Icon=share/images/meshterm.png
Icon=share/images/meshterm-dev.png
Terminal=false
Type=Application
EOF
sudo install -m 644 "$entry" "$APPS/meshterm.desktop"
sudo install -m 644 "$HERE/meshterm.png" "$IMAGES/meshterm.png"
sudo install -m 644 "$entry" "$APPS/meshterm-dev.desktop"
sudo install -m 644 "$HERE/meshterm-dev.png" "$IMAGES/meshterm-dev.png"
rm -f "$entry"
echo "installed $APPS/meshterm.desktop (runs $PYTHON)"
echo "installed $APPS/meshterm-dev.desktop (runs $PYTHON)"
;;
remove)
sudo rm -f "$APPS/meshterm.desktop" "$IMAGES/meshterm.png"
echo "removed MeshTerm from the launcher"
sudo rm -f "$APPS/meshterm-dev.desktop" "$IMAGES/meshterm-dev.png"
echo "removed the checkout's MeshTerm from the launcher"
;;
*)
echo "usage: $0 [install|remove]" >&2
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Save the display of a Cardputer Zero as a PNG, from another computer.
The script reads the framebuffer of the display over SSH and writes it as a PNG of the
display's own size (320×170). The image is exactly what the app wrote, pixel for pixel:
there is no window, no frame, and no scale. This is the image that M5Stack's app store asks
for as a screenshot.
python scripts/cardputer-zero/screenshot.py --host pi@192.168.0.165 menu.png
The script reads the size, the stride, and the depth of the framebuffer from sysfs. Thus it
does not assume the layout. It reads only the framebuffer. It does not press a key, and the
app does not know that a picture was taken.
Necessary: ``ssh`` on this computer, and a Cardputer user in the ``video`` group (the
default user ``pi`` is in this group).
"""
from __future__ import annotations
import argparse
import struct
import subprocess
import sys
import zlib
from pathlib import Path
#: The program that runs on the Cardputer: the geometry from sysfs on the first line, then
#: the raw bytes of the visible part of the framebuffer.
REMOTE = r"""
set -e
dev=$1
name=$(basename "$dev")
sys=/sys/class/graphics/$name
size=$(cat "$sys/virtual_size")
stride=$(cat "$sys/stride")
bpp=$(cat "$sys/bits_per_pixel")
height=${size#*,}
echo "$size $stride $bpp"
head -c $((stride * height)) "$dev"
"""
def rgb565_to_rgb(raw: bytes, width: int, height: int, stride: int) -> bytes:
"""Change little-endian RGB565 rows into 24-bit RGB rows of ``width`` pixels."""
out = bytearray()
for y in range(height):
row = raw[y * stride : y * stride + width * 2]
for (value,) in struct.iter_unpack("<H", row):
r, g, b = value >> 11, (value >> 5) & 0x3F, value & 0x1F
# Copy the high bits into the low bits, so that full intensity stays 255.
out += bytes(((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)))
return bytes(out)
def png(rgb: bytes, width: int, height: int) -> bytes:
"""Encode 24-bit RGB rows as a PNG, with no library."""
def chunk(kind: bytes, data: bytes) -> bytes:
body = kind + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
rows = b"".join(b"\x00" + rgb[y * width * 3 : (y + 1) * width * 3] for y in range(height))
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(rows, 9))
+ chunk(b"IEND", b"")
)
def main() -> int:
"""Read the framebuffer over SSH, and write the PNG."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("output", type=Path, help="the PNG file to write")
parser.add_argument("--host", required=True, help="the Cardputer, as user@host for ssh")
parser.add_argument("-i", "--identity", help="the private key for ssh")
parser.add_argument("--fb", default="/dev/fb0", help="the framebuffer (default /dev/fb0)")
args = parser.parse_args()
command = ["ssh", "-o", "BatchMode=yes"]
if args.identity:
command += ["-i", str(Path(args.identity).expanduser())]
command += [args.host, "sh", "-s", "--", args.fb]
done = subprocess.run(command, input=REMOTE.encode(), capture_output=True, check=False)
if done.returncode != 0:
sys.stderr.write(done.stderr.decode(errors="replace"))
return 1
head, _, raw = done.stdout.partition(b"\n")
size, stride, bpp = head.decode().split()
width, height = (int(n) for n in size.split(","))
if bpp != "16":
sys.stderr.write(f"screenshot: the framebuffer has {bpp} bits a pixel, not 16\n")
return 1
if len(raw) < int(stride) * height:
sys.stderr.write(f"screenshot: got {len(raw)} bytes, not {int(stride) * height}\n")
return 1
rgb = rgb565_to_rgb(raw, width, height, int(stride))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_bytes(png(rgb, width, height))
print(f"wrote {args.output} ({width}x{height})")
return 0
if __name__ == "__main__":
sys.exit(main())
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
r"""Publish the Cardputer Zero package to M5Stack's app store, with ``czdev`` and two corrections.
``czdev publish`` (M5Stack's CardputerZero-AppBuilder) uploads the ``.deb`` to a release in
the fork of ``CardputerZero/packages``, pushes a branch with the store listing, and opens a
pull request. The store merges an update by itself when its checks pass. This script runs
``czdev`` in its own process with two corrections. It also sets up ``git`` for it, and it
brings the fork up to date first:
* The manifest records the email of the uploader. ``czdev`` takes the public email of the
GitHub profile. MeshTerm uses only ``johnputer@meshterm.net`` (JP, 2026-10-07), so the
script gives ``czdev`` that address.
* For a fork, ``czdev`` makes the branch name from the clock two times: before the upload
and after it. The upload takes seconds, so the pull request asks for a branch that was
never pushed, and GitHub refuses it (HTTP 422). The script makes the second call return
the first name.
* ``czdev`` pushes to ``git@github.com:``. A CI runner or a Cardputer has no SSH key for
GitHub, so the script sends ``git`` over HTTPS with the token, and gives it a name and an
email for the commit. These settings are in the environment of this process only.
* ``czdev`` starts its branch from the default branch of the fork, and the store's check
compares the pull request's branch with the store's ``main`` directly. Thus, if the fork
is behind, each file that the store removed since then looks like a new file of the pull
request, and the check fails on it. This occurred on 2026-10-12, when M5Stack removed
all apps from the store to start the reviews again. The script brings the fork's
default branch up to the store's ``main`` before ``czdev`` starts. If it cannot, it
publishes nothing.
The corrections replace two functions of ``czdev``. Thus the release workflow pins
AppBuilder to one commit. If a later ``czdev`` does not have these functions, the script
stops with an error and does not publish.
The script publishes nothing when the store already has this version, or when a pull
request for it is open. Thus a second run of a release does not open a second pull request.
It also publishes nothing while a pull request for an earlier version is open, because
the two would add the same files and conflict. A release in that time goes out as usual,
and its store job only shows a warning. After the merge, run that store job again, and it
publishes the version.
python scripts/cardputer-zero/store-publish.py --czdev ~/CardputerZero-AppBuilder \
--deb dist/meshterm_0.10.4-1_arm64.deb
The token comes from ``$CARDPUTER_STORE_TOKEN`` (the release workflow), or from the
credentials of ``czdev login`` (a Cardputer). It must have the ``public_repo`` scope.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
#: The only email of MeshTerm.
EMAIL = "johnputer@meshterm.net"
NAME = "Jean-Pierre Martineau"
#: The directory with ``app-builder.json``, the icon, and the screenshots.
STORE = Path(__file__).resolve().parent / "store"
TARGET = "CardputerZero/packages"
PUBLISHED_INDEX = "https://cardputerzero.github.io/packages/dists/stable/main/binary-arm64/Packages"
TOKEN_ENV = "CARDPUTER_STORE_TOKEN"
def _api(path: str, token: str, body: dict[str, str] | None = None) -> object:
"""Call one GitHub API path, as JSON: a GET, or a POST of ``body``."""
request = urllib.request.Request(
f"https://api.github.com{path}",
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
)
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - a fixed https host
return json.load(response)
def _deb_field(deb: Path, field: str) -> str:
"""One field of the control file of ``deb``."""
done = subprocess.run(["dpkg-deb", "-f", str(deb), field], capture_output=True, text=True)
return done.stdout.strip()
def _published_versions(package: str) -> set[str]:
"""The versions of ``package`` in the store's index. Empty if the index cannot be read."""
try:
with urllib.request.urlopen(PUBLISHED_INDEX, timeout=30) as response: # noqa: S310
text = response.read().decode()
except OSError:
return set()
versions: set[str] = set()
for stanza in text.split("\n\n"):
fields = dict(line.split(": ", 1) for line in stanza.splitlines() if ": " in line)
if fields.get("Package") == package and "Version" in fields:
versions.add(fields["Version"])
return versions
def _open_pull_requests(package: str, login: str, token: str) -> dict[str, str]:
"""The open pull requests of ``login`` for ``package``, any version: title to URL."""
pulls = _api(f"/repos/{TARGET}/pulls?state=open&per_page=100", token)
prefix = f"publish: {package} "
return {
pull["title"]: pull["html_url"]
for pull in pulls
if pull["user"]["login"] == login and pull["title"].startswith(prefix)
}
def _sync_fork(login: str, token: str) -> str | None:
"""Bring the fork's default branch up to the store's ``main``. Return a problem, or ``None``.
GitHub's merge-upstream does a fast-forward when the fork has no commits of its own. If
it has some and the merge fails, the script must not publish (refer to the module
docstring). With no fork, there is nothing to do: ``czdev`` makes one from the store's
current ``main``.
"""
try:
fork = _api(f"/repos/{login}/{TARGET.split('/')[1]}", token)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
raise
if (fork.get("parent") or {}).get("full_name") != TARGET:
return f"{fork['full_name']} is not a fork of {TARGET}"
branch = fork["default_branch"]
try:
_api(f"/repos/{fork['full_name']}/merge-upstream", token, {"branch": branch})
except urllib.error.HTTPError as error:
return f"cannot bring {fork['full_name']} {branch} up to {TARGET} (HTTP {error.code})"
return None
def _set_up_git(login: str, token: str) -> None:
"""Send ``czdev``'s git over HTTPS with ``token``, as MeshTerm's author."""
os.environ["CZDEV_GIT_TOKEN"] = token
os.environ["CZDEV_GIT_LOGIN"] = login
helper = (
'!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" '
'"$CZDEV_GIT_LOGIN" "$CZDEV_GIT_TOKEN"; }; f'
)
settings = [
("url.https://github.com/.insteadOf", "git@github.com:"),
("credential.https://github.com.helper", helper),
]
os.environ["GIT_CONFIG_COUNT"] = str(len(settings))
for index, (key, value) in enumerate(settings):
os.environ[f"GIT_CONFIG_KEY_{index}"] = key
os.environ[f"GIT_CONFIG_VALUE_{index}"] = value
os.environ["GIT_TERMINAL_PROMPT"] = "0"
for role in ("AUTHOR", "COMMITTER"):
os.environ[f"GIT_{role}_NAME"] = NAME
os.environ[f"GIT_{role}_EMAIL"] = EMAIL
def _correct(publish: object, client_class: type) -> None:
"""Make the two corrections in ``czdev`` (refer to the module docstring)."""
get_user = client_class.get_user
def get_user_with_meshterm_email(self: object): # noqa: ANN202 - czdev's own User
user = get_user(self)
user.email = EMAIL
return user
client_class.get_user = get_user_with_meshterm_email
names: dict[tuple[str, str], str] = {}
branch_name = publish.branch_name
publish.branch_name = lambda meta: names.setdefault(
(meta["package"], meta["version"]), branch_name(meta)
)
def main() -> int:
"""Check, correct, and run ``czdev publish``."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--czdev", required=True, type=Path, help="a CardputerZero-AppBuilder")
parser.add_argument("--deb", required=True, type=Path, help="the package to publish")
args = parser.parse_args()
deb = args.deb.resolve()
sys.path.insert(0, str(args.czdev.resolve() / "scripts"))
from czdev import auth, publish # noqa: PLC0415 - from the AppBuilder given on the command line
from czdev.github_client import GitHubClient # noqa: PLC0415
for owner, name in ((publish, "branch_name"), (GitHubClient, "get_user"), (auth, "load_token")):
if not callable(getattr(owner, name, None)):
sys.stderr.write(
f"store-publish: czdev has no {name} -- this czdev is not the pinned one\n"
)
return 1
token = os.environ.get(TOKEN_ENV) or auth.load_token()
if os.environ.get(TOKEN_ENV):
auth.load_token = lambda: token
package, version = _deb_field(deb, "Package"), _deb_field(deb, "Version")
if not package or not version:
sys.stderr.write(f"store-publish: {deb} is not a Debian package\n")
return 1
if version in _published_versions(package):
print(f"the store already has {package} {version}: nothing to publish")
return 0
login = _api("/user", token)["login"]
title = f"publish: {package} {version}"
pending = _open_pull_requests(package, login, token)
if title in pending:
print(f"a pull request for {package} {version} is already open: {pending[title]}")
return 0
if pending:
# A second pull request would add the same files as the open one, and the two
# would conflict. Thus this version waits until the open one is merged. Then a
# rerun of the release's store job publishes it.
mark = "::warning::" if os.environ.get("GITHUB_ACTIONS") else ""
for other, url in pending.items():
print(f"{mark}{other} is still open ({url}); {version} waits for its merge")
return 0
problem = _sync_fork(login, token)
if problem:
sys.stderr.write(f"store-publish: {problem}\n")
return 1
_correct(publish, GitHubClient)
_set_up_git(login, token)
# czdev reads app-builder.json from the working directory.
os.chdir(STORE)
publish.run(deb=str(deb))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,59 @@
{
"package_name": "meshterm",
"app_name": "MeshTerm",
"bin_name": "meshterm",
"store": {
"summary": "A full-featured MeshCore client. Uses the Cap LoRa-1262 or a companion.",
"description": "MeshTerm is a full-featured MeshCore client. On the Cardputer Zero, it runs the Cap LoRa-1262 as its own radio, or it connects to a MeshCore companion over USB, Bluetooth, or TCP. Chat on channels and in direct messages, see the mesh on a map, trace paths, manage repeaters and rooms, and keep a record of every node heard. Fn+4 to Fn+8 drive the row of labels at the bottom of the display. Hold Esc to quit.",
"categories": [
"Radio & Comms"
],
"screenshots": [
"screenshots/splash.png",
"screenshots/menu.png",
"screenshots/chat.png",
"screenshots/contact.png",
"screenshots/map.png",
"screenshots/livefeed.png"
],
"icon": "icon.png",
"license": "Apache-2.0",
"source_repo": "https://github.com/jpmartineau/MeshTerm",
"author": {
"github": "jpmartineau",
"display_name": "Jean-Pierre Martineau"
},
"share_code": "MTRM",
"permissions": {
"camera": false,
"microphone": false,
"imu": false,
"network": true,
"additional_hardware": true,
"background_service": false,
"external_display": false
},
"locales": {
"en": {
"title": "MeshTerm",
"summary": "A full-featured MeshCore client. Uses the Cap LoRa-1262 or a companion.",
"description": "MeshTerm is a full-featured MeshCore client. On the Cardputer Zero, it runs the Cap LoRa-1262 as its own radio, or it connects to a MeshCore companion over USB, Bluetooth, or TCP. Chat on channels and in direct messages, see the mesh on a map, trace paths, manage repeaters and rooms, and keep a record of every node heard. Fn+4 to Fn+8 drive the row of labels at the bottom of the display. Hold Esc to quit."
},
"zh-CN": {
"title": "MeshTerm",
"summary": "功能完整的 MeshCore 客户端,可使用 Cap LoRa-1262 或 companion 设备。",
"description": "MeshTerm 是一款功能完整的 MeshCore 客户端。在 Cardputer Zero 上,它可以直接把 Cap LoRa-1262 用作自己的无线电,也可以通过 USB、蓝牙或 TCP 连接 MeshCore companion 设备。你可以在频道和私信中聊天、在地图上查看网络、追踪路径、管理中继器和房间,并记录收到过的每一个节点。Fn+4 至 Fn+8 操作屏幕底部的功能标签。长按 Esc 退出。"
},
"zh-TW": {
"title": "MeshTerm",
"summary": "功能完整的 MeshCore 用戶端,可使用 Cap LoRa-1262 或 companion 裝置。",
"description": "MeshTerm 是一款功能完整的 MeshCore 用戶端。在 Cardputer Zero 上,它可以直接把 Cap LoRa-1262 當作自己的無線電,也可以透過 USB、藍牙或 TCP 連線 MeshCore companion 裝置。你可以在頻道和私訊中聊天、在地圖上檢視網路、追蹤路徑、管理中繼器和房間,並記錄收到過的每一個節點。Fn+4 至 Fn+8 操作螢幕底部的功能標籤。長按 Esc 結束。"
},
"ja": {
"title": "MeshTerm",
"summary": "フル機能の MeshCore クライアント。Cap LoRa-1262 またはコンパニオン端末を使用。",
"description": "MeshTerm はフル機能の MeshCore クライアントです。Cardputer Zero では Cap LoRa-1262 を自身の無線機として直接使うことも、USB・Bluetooth・TCP 経由で MeshCore のコンパニオン端末に接続することもできます。チャンネルやダイレクトメッセージでのチャット、地図上でのメッシュの表示、経路のトレース、リピーターやルームの管理ができ、受信したすべてのノードを記録します。画面下部のラベルは Fn+4〜Fn+8 で操作します。Esc を長押しすると終了します。"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+6 -4
View File
@@ -10,8 +10,8 @@ It has the parts, the firmware build and an explanation of the patch, the flashi
wiring, the setup of the Lyra, and what we proved on only one bench. The header of
`uart1-mux.py` explains what the register write does. This page is the bench card.
**Licensing.** The firmware is MeshCore, under the MIT License. The `meshcore-uart1.patch`
changes the source of MeshCore, and `build-firmware.sh` makes a MeshCore binary. The licence
**Licensing.** The firmware is MeshCore, under the MIT License. The two patches
(`meshcore-uart1.patch` and `meshcore-frame-timeout.patch`) change the source of MeshCore, and `build-firmware.sh` makes a MeshCore binary. The licence
is [`LICENSE.MeshCore`](LICENSE.MeshCore) in this directory.
There are three steps on two machines:
@@ -20,8 +20,10 @@ There are three steps on two machines:
# 1. dev machine: build (needs git + PlatformIO)
cd scripts/picocalc-lyra/xiao-radio && sh build-firmware.sh
# 2. dev machine: flash, with the XIAO's own USB-C plugged in
python flash.py # double-tap reset if the touch doesn't take
# 2. dev machine: flash, with the XIAO's own USB-C plugged in. If the XIAO is already
# wired, shut the PicoCalc down and remove its batteries first: the USB power of the
# XIAO goes backward into the Lyra.
python3 flash.py # on Windows: py flash.py
# 3. the Lyra, as root
sh scripts/picocalc-lyra/xiao-radio/lyra-setup.sh # MT_USER=meshterm by default
@@ -11,15 +11,20 @@
# declares a HardwareSerial for ESP32 only, and nRF52 needs Serial1)
# * add a Xiao_nrf52_companion_radio_serial env: the companion is on D6/D7, and I2C
# moves off those pads (to internal pins 16/17), so that I2C cannot take them
# 3. builds that environment
# 4. converts the .hex to .uf2
# 3. applies meshcore-frame-timeout.patch, which makes the link recover by itself: the
# serial parser drops a frame whose bytes stop coming, so one lost byte cannot make
# the companion ignore every command after it
# 4. builds that environment
# 5. converts the .hex to .uf2
#
# The patch has the form that upstream needs, and we submitted it to MeshCore. After it is
# merged, delete the `git apply` step below. The env is then part of the firmware, and
# nothing needs a patch.
# Each patch has the form that upstream needs. When upstream merges one, remove it from
# PATCHES below. When both are merged, the env is part of the firmware, and nothing needs
# a patch.
#
# Prerequisites: git, and the PlatformIO CLI on PATH (`pio`). Install pio with:
# pip install platformio
# Prerequisites: git, and the PlatformIO CLI (`pio`). Install it with the official installer
# script (refer to Step 11 of docs/devices/picocalc-lyra.md). The script finds `pio` on the
# PATH, or else in ~/.platformio/penv, where the installer and the PlatformIO extension of
# VS Code put it. Thus you do not have to change the PATH.
#
# Usage:
# sh build-firmware.sh # clone+build into ./_meshcore-build
@@ -28,30 +33,50 @@
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PATCH="$HERE/meshcore-uart1.patch"
PATCHES="meshcore-uart1.patch meshcore-frame-timeout.patch"
MC="${MESHCORE_DIR:-$HERE/_meshcore-build}"
ENV=Xiao_nrf52_companion_radio_serial
# The commit that this patch is made against (a `dev` commit, because `dev` is the branch
# The commit that the patches are made against (a `dev` commit, because `dev` is the branch
# where MeshCore takes new work). A newer MeshCore can need the edits to be applied again by
# hand (refer to the README, "Updating"). Set MESHCORE_COMMIT= to follow upstream.
# hand (refer to the guide, "Building by hand"). Set MESHCORE_COMMIT= to follow upstream.
PIN="${MESHCORE_COMMIT:-e9edfc8e}"
command -v git >/dev/null 2>&1 || { echo "ERROR: git not found"; exit 1; }
command -v pio >/dev/null 2>&1 || command -v platformio >/dev/null 2>&1 || {
echo "ERROR: PlatformIO CLI (pio) not found. Install with: pip install platformio"; exit 1; }
PIO=$(command -v pio 2>/dev/null || command -v platformio)
PIO=$(command -v pio 2>/dev/null || command -v platformio 2>/dev/null) || PIO=
if [ -z "$PIO" ]; then
# The installer does not put pio on the PATH. Look where it does put it: bin/ on Linux
# and macOS, Scripts/ on Windows.
for c in "$HOME/.platformio/penv/bin/pio" "$HOME/.platformio/penv/Scripts/pio.exe"; do
if [ -x "$c" ]; then PIO=$c; break; fi
done
fi
[ -n "$PIO" ] || {
echo "ERROR: PlatformIO (pio) not found, on the PATH or in ~/.platformio/penv."
echo "Install it with the official installer script (Step 11 of the PicoCalc guide)."
exit 1; }
echo ">> using $PIO"
# The Python that runs uf2conv.py. PlatformIO's own Python comes first, because it is
# always there when pio is. A bare `python` is absent on many Linux systems, and on Windows
# `python3` can be a stub that opens the Microsoft Store.
PY=
for c in "$(dirname -- "$PIO")/python" "$(dirname -- "$PIO")/python.exe"; do
if [ -x "$c" ]; then PY=$c; break; fi
done
[ -n "$PY" ] || PY=$(command -v python3 2>/dev/null || command -v python)
if [ ! -d "$MC/.git" ]; then
echo ">> cloning MeshCore into $MC"
git clone https://github.com/meshcore-dev/MeshCore.git "$MC"
fi
echo ">> checkout $PIN + apply patch"
echo ">> checkout $PIN + apply patches"
git -C "$MC" fetch --quiet --tags origin 2>/dev/null || true
git -C "$MC" checkout -f "$PIN" 2>/dev/null || {
echo " (pinned commit not found; using current checkout)"; }
git -C "$MC" apply "$PATCH"
echo " applied meshcore-uart1.patch"
for p in $PATCHES; do
git -C "$MC" apply "$HERE/$p"
echo " applied $p"
done
echo ">> building $ENV (first build downloads the nRF52 toolchain; be patient)"
( cd "$MC" && "$PIO" run -e "$ENV" )
@@ -60,8 +85,9 @@ HEX="$MC/.pio/build/$ENV/firmware.hex"
UF2CONV="$MC/bin/uf2conv/uf2conv.py"
OUT="$HERE/meshcore-xiao-radio.uf2"
echo ">> converting to uf2"
python "$UF2CONV" "$HEX" -c -f 0xADA52840 -o "$OUT"
"$PY" "$UF2CONV" "$HEX" -c -f 0xADA52840 -o "$OUT"
echo ""
echo "DONE. Firmware: $OUT"
echo "Next: put the XIAO in bootloader (double-tap reset) and run: python flash.py"
echo "Next: connect the USB-C port of the XIAO to this machine, and run: python3 flash.py"
echo " (on Windows: py flash.py)"
+120 -14
View File
@@ -6,22 +6,94 @@ This script runs on your development machine. The USB-C port of the XIAO must be
to that machine. It works on Windows, Linux, and macOS.
The XIAO goes into bootloader mode in one of two ways. The first way is a physical
**double-tap of its reset button**. Then it mounts as the ``XIAO-SENSE`` drive. The second
way is for a XIAO that runs app firmware with a serial port. This script first tries an
automatic 1200-baud "touch" on that port.
**double-tap of its reset button**. The second way is for a XIAO that runs app firmware
with a serial port. This script first tries an automatic 1200-baud "touch" on that port.
A XIAO bootloader shows one of two things. Some bootloaders mount a UF2 drive
(``XIAO-SENSE``), and the script copies the .uf2 file to it. Other bootloaders show only a
serial port. Then the script loads the firmware through serial DFU itself: it runs the
PlatformIO upload in the MeshCore checkout that ``build-firmware.sh`` built.
The script needs ``pyserial`` for the touch and for the search of the serial port. When the
Python that runs the script does not have it, the script runs itself again with the Python
of PlatformIO, which always has it.
Usage:
python flash.py # flash ./meshcore-xiao-radio.uf2
python flash.py path/to/firmware.uf2 # flash a specific file
python3 flash.py # flash ./meshcore-xiao-radio.uf2
python3 flash.py path/to/firmware.uf2 # flash a specific file
"""
import glob
import os
import shutil
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
#: The PlatformIO environment that ``build-firmware.sh`` builds.
ENV = "Xiao_nrf52_companion_radio_serial"
#: The seconds that the script waits for a UF2 drive after the bootloader port shows. A
#: bootloader with a drive mounts it within this time. A bootloader without one never does.
DRIVE_GRACE_S = 5
def platformio_penv():
"""Return the directory of the PlatformIO environment, or None.
The official installer and the PlatformIO extension of VS Code both put it in
``~/.platformio/penv``. The programs are in ``Scripts`` on Windows and in ``bin`` on
other systems.
"""
base = os.path.join(os.path.expanduser("~"), ".platformio", "penv")
for sub in ("Scripts", "bin"):
path = os.path.join(base, sub)
if os.path.isdir(path):
return path
return None
def find_pio():
"""Return the path of the PlatformIO CLI: on the PATH first, else in its environment."""
found = shutil.which("pio") or shutil.which("platformio")
if found:
return found
penv = platformio_penv()
if penv:
for name in ("pio.exe", "pio"):
path = os.path.join(penv, name)
if os.path.isfile(path):
return path
return None
def ensure_pyserial():
"""Make sure that ``pyserial`` imports, or run this script again with a Python that has it.
Without ``pyserial``, the touch and the search of the bootloader port cannot run. In the
past, both stopped without a message, and the script then asked for a double-tap that
could not help.
"""
try:
import serial # noqa: F401 - only a check that the module exists
return
except ImportError:
pass
penv = platformio_penv()
if penv:
for name in ("python.exe", "python"):
python = os.path.join(penv, name)
same = os.path.abspath(python) == os.path.abspath(sys.executable)
if os.path.isfile(python) and not same:
print(f" (no pyserial here, so the script runs again with {python})", flush=True)
sys.exit(subprocess.call([python, os.path.abspath(__file__)] + sys.argv[1:]))
sys.exit(
"ERROR: flash.py needs pyserial. Install PlatformIO (Step 11), or run: pip install pyserial"
)
def find_uf2_drive():
"""Return the mount path of a UF2 bootloader volume, or None."""
@@ -123,6 +195,33 @@ def check_drive_is_ours(drive, force=False):
return True
def flash_serial_dfu(port, uf2_given=False):
"""Load the firmware through the serial DFU of a bootloader that shows no drive.
The PlatformIO upload makes the DFU package from the build and sends it. Thus it loads
the firmware that ``build-firmware.sh`` built in the MeshCore checkout, and not a .uf2
file. The checkout is ``MESHCORE_DIR``, or else ``_meshcore-build`` next to this script,
as in ``build-firmware.sh``.
"""
checkout = os.environ.get("MESHCORE_DIR") or os.path.join(HERE, "_meshcore-build")
command = ["run", "-e", ENV, "-t", "upload", "--upload-port", port]
pio = find_pio()
print(f">> the bootloader on {port} has no UF2 drive: loading through serial DFU")
if uf2_given:
print(" note: serial DFU loads the build in the MeshCore checkout, not the file you gave")
if not pio or not os.path.isdir(os.path.join(checkout, ".pio", "build", ENV)):
sys.exit(
f"ERROR: no {'PlatformIO' if not pio else 'build in ' + checkout} for serial DFU.\n"
"Run build-firmware.sh first. To load by hand, run in the MeshCore checkout:\n"
f" pio {' '.join(command)}"
)
sys.stdout.flush() # keep this script's lines before the lines of the upload
status = subprocess.call([pio] + command, cwd=checkout)
if status != 0:
sys.exit(f"ERROR: the serial DFU upload failed (exit {status}).")
print("DONE. XIAO flashed and rebooting into the radio firmware.")
def main():
"""Put the XIAO in its bootloader and copy the firmware onto the drive that it shows.
@@ -136,32 +235,39 @@ def main():
if not os.path.exists(uf2):
sys.exit(f"ERROR: firmware not found: {uf2}\n(run build-firmware.sh first)")
ensure_pyserial()
print(">> looking for XIAO bootloader drive ...")
drive = find_uf2_drive()
before = drive
port = None
if not drive:
touch_1200()
port_since = None
for _ in range(30):
drive = find_uf2_drive()
if drive:
break
port = bootloader_port()
if port:
# A bootloader that mounts a drive does it within a few seconds of its port.
# After that, it is a bootloader with only a serial port.
port_since = port_since or time.monotonic()
if time.monotonic() - port_since >= DRIVE_GRACE_S:
break
time.sleep(1)
if drive and drive == before:
# The letter was there before we touched anything, so it can belong to a different
# board. Check what the drive reports. Do not trust the letter.
print(f" note: {drive} was already mounted before the touch")
if not drive:
port = bootloader_port()
port = port or bootloader_port()
if port:
sys.exit(
f"ERROR: the XIAO is in its bootloader on {port} but exposes no UF2 drive.\n"
"This bootloader presents a serial port only, so copy-to-drive can't work.\n"
"Flash it over serial DFU instead, from your MeshCore checkout:\n"
f" pio run -e Xiao_nrf52_companion_radio_serial -t upload --upload-port {port}"
)
flash_serial_dfu(port, uf2_given=bool(args))
return
sys.exit(
"ERROR: no UF2 bootloader drive and no XIAO bootloader serial port found.\n"
"Double-tap the XIAO's reset button, then re-run."
"ERROR: no XIAO found: no UF2 bootloader drive, and no XIAO serial port.\n"
"Connect the USB-C port of the XIAO. If it is connected, double-tap its reset\n"
"button, then run this again."
)
check_drive_is_ours(drive, force=force)
@@ -0,0 +1,54 @@
diff --git a/src/helpers/ArduinoSerialInterface.cpp b/src/helpers/ArduinoSerialInterface.cpp
index a01fa58..49f98aa 100644
--- a/src/helpers/ArduinoSerialInterface.cpp
+++ b/src/helpers/ArduinoSerialInterface.cpp
@@ -5,6 +5,13 @@
#define RECV_STATE_LEN1_FOUND 2
#define RECV_STATE_LEN2_FOUND 3
+// A frame whose bytes stop coming is dropped after this long. Without it, one lost or
+// damaged byte can leave the parser waiting for a length of up to 65535 bytes, and every
+// command after it is swallowed until that many bytes have arrived.
+#ifndef RECV_FRAME_TIMEOUT_MILLIS
+ #define RECV_FRAME_TIMEOUT_MILLIS 200
+#endif
+
void ArduinoSerialInterface::enable() {
_isEnabled = true;
_state = RECV_STATE_IDLE;
@@ -37,9 +44,17 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
}
size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
+ // Only silence on the line counts: nothing waiting, and nothing read for a while. Bytes
+ // that queued up while the loop was busy still belong to the frame in progress.
+ if (_state != RECV_STATE_IDLE && !_serial->available()
+ && millis() - _last_rx > RECV_FRAME_TIMEOUT_MILLIS) {
+ _state = RECV_STATE_IDLE; // the frame broke off: look for the next '<'
+ }
+
while (_serial->available()) {
int c = _serial->read();
if (c < 0) break;
+ _last_rx = millis();
switch (_state) {
case RECV_STATE_IDLE:
diff --git a/src/helpers/ArduinoSerialInterface.h b/src/helpers/ArduinoSerialInterface.h
index c408635..b6b8be3 100644
--- a/src/helpers/ArduinoSerialInterface.h
+++ b/src/helpers/ArduinoSerialInterface.h
@@ -8,11 +8,12 @@ class ArduinoSerialInterface : public BaseSerialInterface {
uint8_t _state;
uint16_t _frame_len;
uint16_t rx_len;
+ unsigned long _last_rx;
Stream* _serial;
uint8_t rx_buf[MAX_FRAME_SIZE];
public:
- ArduinoSerialInterface() { _isEnabled = false; _state = 0; }
+ ArduinoSerialInterface() { _isEnabled = false; _state = 0; _last_rx = 0; }
void begin(Stream& serial) {
_serial = &serial;
+73 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
"""Shared fixtures and helpers: plain-text screen reading, and fixed path rendering.
"""Shared fixtures and helpers: plain-text screen reading, fixed path rendering, project files.
:func:`plain` is the only way that a test reads a rendered screen. Each screen test must
first remove the ANSI colour escapes and join the rendered lines. Only then can it make
@@ -18,13 +18,18 @@ powerline pass explicit modes, or they patch the widget's own switch with
and keeps attributes. This silently makes each colour assertion in the suite useless (a
shell that an agent harness runs sets it). The autouse fixture below deletes it. Thus the
tests always see the colours that the app really sends.
:func:`not_ignored` keeps a test that walks a folder to the files of the project. Git
ignores the other files in the checkout, and these files are not ours to examine.
"""
from __future__ import annotations
import os
import re
import subprocess
from collections.abc import Callable, Iterable, Iterator
from pathlib import Path
import pytest
@@ -47,6 +52,73 @@ def plain(rendered: str | Iterable[str]) -> str:
return _ANSI.sub("", rendered)
def recording_locator(markers: list) -> object:
"""A real :class:`~meshterm.ui.map_screen.Locator` whose map is a list, ``shown``.
It finds markers with the rule of the app. Instead of a map, :meth:`show` appends the
marker that the map would open on to ``shown``.
"""
from meshterm.ui.map_screen import Locator
class _Recording(Locator):
async def show(self, marker) -> None: # noqa: ANN001 - a MapMarker
self.shown.append(marker)
locator = _Recording(None, markers) # type: ignore[arg-type]
locator.shown = [] # type: ignore[attr-defined]
return locator
def recording_pages(contacts: list) -> object:
"""A real :class:`~meshterm.ui.node_detail_screen.ContactPages` whose pages are a list.
It finds contacts with the rule of the app. Instead of a node page, :meth:`show` appends
the contact that the page would open on to ``shown`` (``None`` is our node).
"""
from meshterm.ui.node_detail_screen import ContactPages
class _Recording(ContactPages):
async def show(self, contact) -> None: # noqa: ANN001 - a Contact, or None for us
self.shown.append(contact)
pages = _Recording(None, contacts) # type: ignore[arg-type]
pages.shown = [] # type: ignore[attr-defined]
return pages
#: The root of the checkout.
_ROOT = Path(__file__).resolve().parent.parent
def not_ignored(paths: Iterable[Path]) -> list[Path]:
"""The paths in ``paths`` that git does not ignore, in their order.
A test that walks a folder of the checkout must examine only the files of the project.
A folder can also hold files that git ignores. An example is the MeshCore checkout that
``build-firmware.sh`` makes in ``scripts/``, with its third-party code and its build
tree. Git never ignores a tracked file. When git is not available (for example, in an
unpacked source archive), the function returns all the paths, and the test examines
each file, as it did before.
"""
paths = list(paths)
relative = [path.resolve().relative_to(_ROOT).as_posix() for path in paths]
try:
result = subprocess.run(
["git", "check-ignore", "--stdin", "-z"],
cwd=_ROOT,
input="\0".join(relative),
capture_output=True,
encoding="utf-8",
check=False,
)
except OSError:
return paths
if result.returncode not in (0, 1): # 1 is "nothing ignored", 128 is "not a repository"
return paths
ignored = set(result.stdout.split("\0"))
return [path for path, name in zip(paths, relative, strict=True) if name not in ignored]
@pytest.fixture(autouse=True)
def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("MESHTERM_POWERLINE", "0")
+396 -63
View File
@@ -23,6 +23,7 @@ from meshterm.core.models import (
ChatMessage,
Contact,
Conversation,
MapMarker,
Message,
conversation_key,
)
@@ -414,6 +415,17 @@ def _keys_of(mapping: dict[str, str]):
return lambda name: mapping.get(name.casefold())
def _ink(text, run: str) -> str:
"""The style that colours ``run`` in ``text``: its identity span, not the path line's stamp."""
from meshterm.ui.pathline import PATH_INK
return next(
str(s.style)
for s in text.spans
if text.plain[s.start : s.end] == run and str(s.style) != PATH_INK
)
def test_preview_prefixes_own_messages_only() -> None:
"""Only an outbound message gets a ``you:`` prefix. Inbound text shows exactly as it is.
@@ -447,6 +459,23 @@ def test_preview_colours_channel_sender_and_mentions() -> None:
assert any(s.style == "node.unknown" and s.start <= zed < s.end for s in preview.spans)
def test_preview_draws_the_author_and_the_mentions_as_the_transcript_does(powerline) -> None:
"""With chips, the author closes on the point and a mention opens on the notch.
The point says what the colon said, so the chip has no colon. Our own author is the
``★``, as the sender chip of our messages in the transcript.
"""
from meshterm.ui.pathline import POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP
powerline(True)
theirs = ChatMessage(text="Bob: hi @[Alice]", is_channel=True, channel_idx=0)
author = POWERLINE_ROUND_OPEN + " Bob " + POWERLINE_SEP
mention = POWERLINE_SEP + " @Alice " + POWERLINE_ROUND_CLOSE
assert _preview_text(theirs, _NO_KEYS).plain == f"{author} hi {mention}"
mine = ChatMessage(text="yo", outbound=True, is_channel=True, channel_idx=0)
assert _preview_text(mine, _NO_KEYS).plain == f"{POWERLINE_ROUND_OPEN} ★ {POWERLINE_SEP} yo"
def test_preview_keeps_the_whole_message() -> None:
"""The preview is built whole. The cut of the row is the part that the ←→ scroll passes."""
long = ChatMessage(text="x" * 300, is_channel=True, channel_idx=0)
@@ -981,16 +1010,10 @@ def test_at_mention_renders_as_name_in_sender_hue() -> None:
assert "@Alice" in text.plain # the token with brackets became a bare mention
assert "@[Alice]" not in text.plain and "[Alice]" not in text.plain
# The "@Alice" text has the hue of the key (the same hue that the header uses).
hue = screen._sender_style("Alice")
assert hue == node_style("60") # the spectrum hue from the key of the sender
at = text.plain.index("@Alice")
hue_spans = [
s for s in text.spans if s.style == hue and s.start <= at and at + len("@Alice") <= s.end
]
assert hue_spans
# The "@Alice" text has the spectrum hue of the key (the same hue that the header uses).
assert _ink(text, "@Alice") == node_style("60")
# The unknown @Zed gets the unknown-node grey, because it has no key and no hue.
assert screen._sender_style("Zed") == "node.unknown"
assert _ink(text, "@Zed") == "node.unknown"
def test_direct_chat_unknown_mention_stays_muted() -> None:
@@ -1018,13 +1041,14 @@ def test_direct_chat_unknown_mention_stays_muted() -> None:
key_of=_keys_of({"alice": "60" + "0" * 62}),
)
body = screen._render_mentions("@[Alice] and @[Zed]", selected=False)
# A mention that resolves still lights in the hue of its own key…
assert screen._sender_style("Alice", mention=True) == node_style("60")
assert _ink(body, "@Alice") == node_style("60")
# …but an unknown mention is grey. It does not get the hue (d4…) of the peer.
assert screen._sender_style("Zed", mention=True) == "node.unknown"
assert screen._sender_style("Zed", mention=True) != node_style("d4")
assert _ink(body, "@Zed") == "node.unknown"
# The sender label keeps the fallback to the peer key (the behaviour is the same).
assert screen._sender_style("Zed") == node_style("d4")
inbound = ChatMessage(text="hi", peer="d4e5f6a7")
assert _ink(screen._group_header("Zed", inbound), "Zed") == node_style("d4")
def test_direct_transcript_groups_under_sender_headers() -> None:
@@ -1084,14 +1108,14 @@ async def test_chat_fkey_lane_dims_retry_and_the_nav_slots() -> None:
failed = ChatMessage(text="oops", outbound=True, peer="d4e5f6a7", acked=False)
screen = _screen(_StubSession(), send=None, messages=[failed], resend=resend)
assert all(pair.enabled for pair in screen.picocalc_lyra_lane[3:]) # the pager moves the pick
assert screen.picocalc_lyra_lane[2].opp_enabled is True # F8 Retry: the message has no ack
assert screen.picocalc_lyra_lane[0].opp_enabled is True # F6 Retry: the message has no ack
screen.handle("retry")
await asyncio.sleep(0)
assert failed.acked is True
assert screen.picocalc_lyra_lane[2].opp_enabled is False # acknowledged: nothing to retry
assert screen.picocalc_lyra_lane[0].opp_enabled is False # acknowledged: nothing to retry
assert (
screen.picocalc_lyra_lane[2].opp_label == "Retry"
screen.picocalc_lyra_lane[0].opp_label == "Retry"
) # dim, not removed: it is part of this screen
@@ -1106,7 +1130,7 @@ def test_chat_fkey_lane_speaks_the_transcript_and_drops_channel_retry() -> None:
assert [pair.opp_label for pair in direct.picocalc_lyra_lane[3:]] == ["Latest", "Oldest"]
assert [pair.opp_action for pair in direct.picocalc_lyra_lane[3:]] == ["ctrl_end", "ctrl_home"]
assert [pair.label for pair in direct.picocalc_lyra_lane[3:]] == ["Page ↓", "Page ↑"]
assert direct.picocalc_lyra_lane[2].opp_label == "Retry"
assert direct.picocalc_lyra_lane[0].opp_label == "Retry"
channel = ChatScreen(
Conversation(label="#general", is_channel=True, channel_id="1"),
@@ -1117,8 +1141,8 @@ def test_chat_fkey_lane_speaks_the_transcript_and_drops_channel_retry() -> None:
)
# A channel message has no acknowledgement, so a retry is not possible here. The slot is
# empty (it is not dim), and its key resolves to nothing.
assert channel.picocalc_lyra_lane[2].opp_label == ""
assert fkeys.PICOCALC_LYRA_DECK.action_for(channel.picocalc_lyra_lane, 8) is None
assert channel.picocalc_lyra_lane[0].opp_label == ""
assert fkeys.PICOCALC_LYRA_DECK.action_for(channel.picocalc_lyra_lane, 6) is None
def test_chat_screen_up_picks_and_ctrl_end_returns_to_compose() -> None:
@@ -1181,9 +1205,16 @@ def test_channel_transcript_groups_by_sender() -> None:
assert stamps[0] != stamps[1] # the grouped Alice messages show different times
def test_transcript_inscribes_the_sender_label_but_never_a_mention(powerline) -> None:
"""The name that opens a group is a chip. The same name in a body is prose."""
from meshterm.ui.widgets import name_chip
def test_transcript_draws_the_sender_as_an_origin_and_a_mention_as_a_destination(
powerline,
) -> None:
"""The sender chip closes on the point, and a mention chip opens on the notch.
A message goes from its author toward the node that it names. Thus the two chips are
the two ends of one route that is not drawn (JP, 2026-10-10).
"""
from meshterm.ui.pathline import POWERLINE_SEP
from meshterm.ui.widgets import DESTINATION, ORIGIN, name_chip
powerline(True) # this test is about the chip, so ask for a terminal that draws one
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
@@ -1193,11 +1224,34 @@ def test_transcript_inscribes_the_sender_label_but_never_a_mention(powerline) ->
screen = ChatScreen(conv, messages, send=None, names={}, session=_StubSession())
rendered = _strip_ansi("\n".join(screen._render_grouped(80)))
# The test builds the chip through the widget, not as literal text. The end caps are the
# caps of the path line, and the test asserts the framing, not the glyph.
assert name_chip("Alice").plain in rendered # the header, as a chip
assert "@Bob" in rendered # the mention, as the user typed it
assert name_chip("Bob").plain not in rendered # …and not framed in the middle of a sentence
# The test builds the chips through the widget, not as literal text. The end caps are
# the caps of the path line, and the test asserts the framing, not the glyph.
sender = name_chip("Alice", role=ORIGIN).plain
mention = name_chip("@Bob", role=DESTINATION).plain
assert sender.endswith(POWERLINE_SEP) and sender in rendered # the header goes on
assert mention.startswith(POWERLINE_SEP) and mention in rendered # the mention is reached
assert f"{mention} you around?" in rendered # in its place in the sentence
def test_a_mention_chip_moves_to_the_next_line_whole(powerline) -> None:
"""The wrap breaks a body at a space, but never at a space inside a mention chip.
A chip has a pad at each side of its label, and a name can have spaces. Without the
hold, the notch stays at the end of one line and the name starts the next line.
"""
from meshterm.ui.widgets import DESTINATION, name_chip
powerline(True)
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
text = "Alice: the relay on the hill looks bad @[Bob Smith] you around?"
messages = [ChatMessage(text=text, is_channel=True, channel_idx=0)]
screen = ChatScreen(conv, messages, send=None, names={}, session=_StubSession())
mention = name_chip("@Bob Smith", role=DESTINATION).plain
for width in range(30, 60):
lines = [_strip_ansi(line).rstrip() for line in screen._render_grouped(width)]
assert sum(mention.strip() in line for line in lines) == 1, width
assert all(cell_len(line) <= width for line in lines), width
def test_unidentified_sender_takes_the_node_grey_not_the_chrome_grey() -> None:
@@ -1209,9 +1263,10 @@ def test_unidentified_sender_takes_the_node_grey_not_the_chrome_grey() -> None:
"""
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
screen = ChatScreen(conv, [], send=None, names={}, session=_StubSession())
inbound = ChatMessage(text="hi", is_channel=True, channel_idx=0)
assert screen._sender_style("·") == "node.unknown"
assert screen._sender_style("nobody-knows-me") == "node.unknown" # no key and no hue
for sender in ("·", "nobody-knows-me"): # no prefix, and a name with no key and no hue
assert _ink(screen._group_header(sender, inbound), sender) == "node.unknown"
def _two_day_messages():
@@ -1320,7 +1375,7 @@ async def test_chat_paths_key_needs_a_picked_message() -> None:
await asyncio.sleep(0)
assert shown == []
assert "^P" not in screen.footer_hint
assert screen.picocalc_lyra_lane[2].enabled is False
assert screen.picocalc_lyra_lane[0].enabled is False
screen.handle("up")
screen.handle("up") # select the older message
@@ -1331,7 +1386,7 @@ async def test_chat_paths_key_needs_a_picked_message() -> None:
# direct chat has no reply to prepare. (The hint of a channel keeps the ^P atom beside
# its Enter reply.)
assert "Enter paths" in screen.footer_hint
assert screen.picocalc_lyra_lane[2].enabled is True
assert screen.picocalc_lyra_lane[0].enabled is True
async def test_chat_direct_enter_on_a_pick_opens_paths() -> None:
@@ -1441,11 +1496,13 @@ def test_channel_self_style_keyed_on_concept_not_label() -> None:
"""
from meshterm.ui.theme import node_style
ours = ChatMessage(text="hi", is_channel=True, channel_idx=0, outbound=True)
theirs = ChatMessage(text="you: hi", is_channel=True, channel_idx=0)
screen = _channel_screen([])
assert screen._sender_style("you", is_self=True) == "you" # us: white
assert screen._sender_style("you", is_self=False) == "node.unknown" # no key: grey
assert _ink(screen._group_header("you", ours), "★") == "you" # us: the white star
assert _ink(screen._group_header("you", theirs), "you") == "node.unknown" # no key: grey
keyed = _channel_screen([], key_of=_keys_of({"you": "d4" + "0" * 62}))
remote = keyed._sender_style("you", is_self=False)
remote = _ink(keyed._group_header("you", theirs), "you")
assert remote == node_style("d4") # the spectrum hue of the key
@@ -1616,14 +1673,19 @@ class _ScriptedDevice:
def __init__(self, acks: list) -> None:
self._acks = list(acks)
self.sent: list[str] = []
# The timestamp and the try number of each send, in order.
self.stamps: list[tuple[int | None, int]] = []
async def connect(self) -> None: # for _StubContext.device()
return None
async def send_direct_message(self, contact: Contact, text: str):
async def send_direct_message(
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
):
from meshterm.core.models import Delivery
self.sent.append(text)
self.stamps.append((timestamp, attempt))
ack = self._acks.pop(0) if self._acks else None
return Delivery(f"{len(self.sent):08x}", ack)
@@ -2298,29 +2360,16 @@ async def test_channel_slot_change_drops_the_cached_channel_list(repo: Repositor
await device.disconnect()
def test_chat_fkey_lane_steps_by_day_on_the_free_left_pair() -> None:
"""The sections of the transcript are days, so F1 and F2 name the section step ``Day``.
def test_chat_fkey_lane_has_no_day_jump() -> None:
"""No chip sends the day jump: JP removed ``Day ↑`` and ``Day ↓`` (2026-10-10).
The keys and the actions are the same as ``Sect ↑`` and ``Sect ↓`` of a grouped select
list. The lane names what the sections are here. The chips are lit only if there is
more than one day to step between, as a list with one section has no section jump.
The desktop keeps the jump on ^PgUp and ^PgDn.
"""
from datetime import timedelta
from meshterm.core.models import utcnow
now = utcnow()
one_day = [ChatMessage(text="hi", peer="d4e5f6a7", created_at=now)]
single = _screen(_StubSession(), send=None, messages=one_day)
assert [pair.label for pair in single.picocalc_lyra_lane[:2]] == ["Day ↑", "Day ↓"]
assert [pair.action for pair in single.picocalc_lyra_lane[:2]] == [
"ctrl_pageup",
"ctrl_pagedown",
]
assert not any(
pair.enabled for pair in single.picocalc_lyra_lane[:2]
) # one day: no place to step to
spread = _screen(
_StubSession(),
send=None,
@@ -2329,12 +2378,57 @@ def test_chat_fkey_lane_steps_by_day_on_the_free_left_pair() -> None:
ChatMessage(text="now", peer="d4e5f6a7", created_at=now),
],
)
assert all(pair.enabled for pair in spread.picocalc_lyra_lane[:2])
# A left-hand pair rises toward F1, so a step back through the transcript is the outer
# key. This is the same direction as the pager on the right.
assert fkeys.PICOCALC_LYRA_DECK.action_for(spread.picocalc_lyra_lane, 1) == "ctrl_pageup"
spread.handle(fkeys.PICOCALC_LYRA_DECK.action_for(spread.picocalc_lyra_lane, 1))
assert spread._selected == 0 # moved to the first message of the older day
lane = spread.picocalc_lyra_lane
actions = {action for pair in lane if pair for action in (pair.action, pair.opp_action)}
assert not actions & {"ctrl_pageup", "ctrl_pagedown"}
deck = fkeys.PICOCALC_LYRA_DECK
assert "Day" not in deck.lane_text(lane).plain + deck.lane_text(lane, shifted=True).plain
spread.handle("ctrl_pageup")
assert spread._selected == 0 # the key still moves to the first message of the older day
def test_channel_and_dm_lanes_share_f1_to_f3_and_keep_their_own_verbs_behind_shift() -> None:
"""The plain bank of F1 to F3 is the same in each conversation (JP, 2026-10-10).
The verbs that the screens share are there, in the order of the hint. The Shift bank
has the verbs of one screen: ``Retry`` in a DM and ``Resend`` in a channel, both ^R.
"""
from tests.conftest import recording_locator
messages = [ChatMessage(text="hi", peer="d4e5f6a7")]
direct = ChatScreen(
Conversation(
label="Alice",
is_channel=False,
contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"),
),
messages,
send=None,
names={},
session=_StubSession(),
locator=recording_locator([]), # type: ignore[arg-type]
)
async def resend_unscoped(message: ChatMessage) -> ChatMessage:
return message
channel = ChatScreen(
Conversation(label="#general", is_channel=True, channel_id="1"),
[ChatMessage(text="Bob: hi", is_channel=True, channel_idx=0)],
send=None,
names={},
session=_StubSession(),
resend_unscoped=resend_unscoped,
locator=recording_locator([]), # type: ignore[arg-type]
)
for screen in (direct, channel):
lane = screen.picocalc_lyra_lane
assert [pair.label for pair in lane[:3]] == ["Paths", "URL→QR", "Map"]
assert [pair.action for pair in lane[:3]] == ["paths", "url_code", "locate"]
assert direct.picocalc_lyra_lane[0].opp_label == "Retry"
assert channel.picocalc_lyra_lane[0].opp_label == "Resend"
assert channel.picocalc_lyra_lane[0].opp_action == direct.picocalc_lyra_lane[0].opp_action
# -- URL codes (the PicoCalc) ---------------------------------------------------
@@ -2522,10 +2616,8 @@ def test_ctrl_u_is_named_only_on_a_picked_message_with_a_link() -> None:
linked.handle("up")
plain.handle("up")
assert "^U QR" in linked.footer_hint and "^U" not in plain.footer_hint
assert (
fkeys.PICOCALC_LYRA_DECK.action_for(linked.picocalc_lyra_lane, 7) == "url_code"
) # Shift+F2
assert linked.picocalc_lyra_lane[1].opp_enabled and not plain.picocalc_lyra_lane[1].opp_enabled
assert fkeys.PICOCALC_LYRA_DECK.action_for(linked.picocalc_lyra_lane, 2) == "url_code" # F2
assert linked.picocalc_lyra_lane[1].enabled and not plain.picocalc_lyra_lane[1].enabled
# The hint of each selection state, with a link in it, fits in the 72-cell budget.
direct = _screen(
@@ -2558,7 +2650,7 @@ def test_on_the_cardputer_a_link_takes_the_share_screen_as_on_the_desktop() -> N
screen = _url_chat(f"the road list is at {url}")
assert not _code_rows(_strip_ansi(screen._render_grouped(53)).splitlines())
screen.handle("up")
assert fkeys.CARDPUTER_ZERO_DECK.action_for(screen.cardputer_zero_lane, 17) == "url_code"
assert fkeys.CARDPUTER_ZERO_DECK.action_for(screen.cardputer_zero_lane, 5) == "url_code"
share = QrScreen(url, title="Links")
share.note_viewport(14)
@@ -2623,7 +2715,7 @@ class _AckingMeshCore:
if sub.event_type is EventType.ACK:
sub.callback(event)
async def send_msg(self, pub, text): # noqa: ANN001, ANN201
async def send_msg(self, pub, text, timestamp=None, attempt=0): # noqa: ANN001, ANN201
from types import SimpleNamespace
if self._during:
@@ -2744,3 +2836,244 @@ def test_only_the_acks_the_radio_could_still_push_are_awaited(repo: Repository)
assert messages[0].acked is False
chat._settle_ack(f"{39:08x}")
assert messages[39].acked is True
# --- ^L: the map on the author ------------------------------------------------------------
#: A node with a location, and our node.
_ALICE_AT = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="a1" * 32)
_US_AT = MapMarker(label="Base", lat=45.4, lon=-73.5, is_self=True, key="ee" * 32)
async def test_chat_locate_opens_the_map_on_the_author_of_the_selected_message() -> None:
"""^L opens the map on the author of the selected message, and is named only where it acts."""
from tests.conftest import recording_locator
locator = recording_locator([_ALICE_AT, _US_AT])
keys = {"alice": "a1" * 32, "bob": "b2" * 32}
screen = ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
[
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0),
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0),
ChatMessage(text="hello", outbound=True, is_channel=True, channel_idx=0),
],
send=None,
names={},
session=_StubSession(),
key_of=lambda name: keys.get(name.casefold()),
locator=locator, # type: ignore[arg-type]
)
# With no selected message, ^L does nothing. The hint does not name it, and its chip on
# F3 is dim.
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == []
assert "^L" not in screen.footer_hint
assert screen.picocalc_lyra_lane[2].label == "Map"
assert screen.picocalc_lyra_lane[2].enabled is False
# Our own message: its author is our node.
screen.handle("up")
assert "^L map" in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [_US_AT]
# Bob has a key but no location, so there is nothing to show.
screen.handle("up")
assert "^L" not in screen.footer_hint
assert screen.picocalc_lyra_lane[2].enabled is False
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [_US_AT]
# Alice has a location. The chip is lit, and F3 sends the same action as ^L.
screen.handle("up")
assert "^L map" in screen.footer_hint
assert screen.picocalc_lyra_lane[2].enabled is True
assert fkeys.PICOCALC_LYRA_DECK.action_for(screen.picocalc_lyra_lane, 3) == "locate"
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [_US_AT, _ALICE_AT]
def test_chat_without_a_locator_has_no_map_chip() -> None:
"""A chat that cannot locate leaves F3 empty, not dim."""
screen = _screen(_StubSession(), send=None, messages=[ChatMessage(text="hi", peer="d4e5f6a7")])
assert screen.picocalc_lyra_lane[2] is None
async def test_chat_author_page_opens_the_node_page_of_the_author_of_the_selection() -> None:
"""^O opens the node page of the author of the selected message, named only where it acts.
It is the twin of ^L: the same author, found by the same key. Its chip is ``Author``,
on the Shift half of F3, behind ``Map``.
"""
from tests.conftest import recording_pages
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
pages = recording_pages([alice])
keys = {"alice": "a1" * 32, "bob": "b2" * 32}
screen = ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
[
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0),
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0),
ChatMessage(text="hello", outbound=True, is_channel=True, channel_idx=0),
],
send=None,
names={},
session=_StubSession(),
key_of=lambda name: keys.get(name.casefold()),
pages=pages, # type: ignore[arg-type]
)
# With no selected message, ^O does nothing. The hint does not name it, and its chip is
# dim. The screen has no locator, so the plain half of F3 is empty.
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == []
assert "^O" not in screen.footer_hint
lane = screen.picocalc_lyra_lane
assert (lane[2].label, lane[2].opp_label) == ("", "Author")
assert lane[2].opp_enabled is False
# Our own message: its author is our node, whose page has no contact.
screen.handle("up")
assert "^O author" in screen.footer_hint
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == [None]
# Bob has a key but he is not a contact, so there is no page to open.
screen.handle("up")
assert "^O" not in screen.footer_hint
assert screen.picocalc_lyra_lane[2].opp_enabled is False
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == [None]
# Alice is a contact. The chip is lit, and Shift+F3 sends the same action as ^O.
screen.handle("up")
assert "^O author" in screen.footer_hint
assert screen.picocalc_lyra_lane[2].opp_enabled is True
assert fkeys.PICOCALC_LYRA_DECK.action_for(screen.picocalc_lyra_lane, 8) == "author_page"
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == [None, alice]
def test_a_direct_chat_finds_the_page_of_its_peer() -> None:
"""In a DM, the author of an inbound message is the peer, and the peer is a contact."""
from tests.conftest import recording_pages
peer = Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7")
screen = ChatScreen(
Conversation(label="Alice", is_channel=False, contact=peer),
[ChatMessage(text="hi", peer="d4e5f6a7")],
send=None,
names={"d4e5f6a7": "Alice"},
session=_StubSession(),
pages=recording_pages([peer]), # type: ignore[arg-type]
)
screen.handle("up")
assert "^O author" in screen.footer_hint
assert cell_len(screen.footer_hint) <= 72
def test_chat_hint_with_all_its_keys_fits_in_72_cells() -> None:
"""The longest hint (a scoped message of ours, with a link, on the map) fits in 72 cells.
The ↑↓ atom goes first, then ^P. The keys that only this message offers stay.
"""
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
return None
from tests.conftest import recording_locator
screen = ChatScreen(
Conversation(label="#ops", is_channel=True, channel_idx=1),
[
ChatMessage(
text="map at https://meshterm.net",
outbound=True,
is_channel=True,
channel_idx=1,
scope="yul",
)
],
send=None,
names={},
session=_StubSession(),
scope="yul",
resend_unscoped=resend_unscoped,
locator=recording_locator([_US_AT]), # type: ignore[arg-type]
)
screen.handle("up")
hint = screen.footer_hint
assert cell_len(hint) <= 72
for atom in ("Enter reply", "^U QR", "^L map", "^R resend unscoped", "Esc cancel"):
assert atom in hint
assert "↑↓ pick" not in hint and "^P paths" not in hint
def test_chat_resend_hint_drops_our_own_page_third() -> None:
"""With ^O too, the resend hint drops ↑↓, then ^P, then ^O: the message is ours."""
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
return None
from tests.conftest import recording_locator, recording_pages
screen = ChatScreen(
Conversation(label="#ops", is_channel=True, channel_idx=1),
[
ChatMessage(
text="map at https://meshterm.net",
outbound=True,
is_channel=True,
channel_idx=1,
scope="yul",
)
],
send=None,
names={},
session=_StubSession(),
scope="yul",
resend_unscoped=resend_unscoped,
locator=recording_locator([_US_AT]), # type: ignore[arg-type]
pages=recording_pages([]), # type: ignore[arg-type]
)
screen.handle("up")
hint = screen.footer_hint
assert cell_len(hint) <= 72
for atom in ("Enter reply", "^U QR", "^L map", "^R resend unscoped", "Esc cancel"):
assert atom in hint
assert "↑↓ pick" not in hint and "^P paths" not in hint and "^O" not in hint
def test_chat_reply_hint_with_every_key_of_a_selection_fits_in_72_cells() -> None:
"""A message of a located contact, with a link: the reply hint takes its short words.
With ^U, ^L, and ^O, the long words (``(@mention)``, ``^End/Esc``) do not fit. The
short words of the resend hint do, and then each key of the selection stays.
"""
from tests.conftest import recording_locator, recording_pages
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
screen = ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
[ChatMessage(text="Alice: map at https://meshterm.net", is_channel=True, channel_idx=0)],
send=None,
names={},
session=_StubSession(),
key_of=lambda name: "a1" * 32 if name.casefold() == "alice" else None,
locator=recording_locator([_ALICE_AT]), # type: ignore[arg-type]
pages=recording_pages([alice]), # type: ignore[arg-type]
)
screen.handle("up")
hint = screen.footer_hint
assert cell_len(hint) <= 72
for atom in ("Enter reply", "^P paths", "^U QR", "^L map", "^O author", "Esc cancel"):
assert atom in hint
+48
View File
@@ -19,7 +19,9 @@ Each command here is read-only or uses the simulator, so nothing transmits.
from __future__ import annotations
import json
import os
import re
import stat
from pathlib import Path
import pytest
@@ -579,6 +581,52 @@ def test_a_destructive_command_without_yes_is_a_silent_usage_error(run, args) ->
assert "--yes" in result.stderr
def test_import_key_reads_a_file_and_keeps_the_key_out_of_the_run_log(run, tmp_path: Path) -> None: # noqa: ANN001
"""The run log stores the parameters of each run, and a private key is not one to keep.
The key of ``import-key`` was stored as plain text in the ``runs`` table. Now the table
holds ``<secret>`` where the key was.
"""
import sqlite3
key = "cd" * 64
path = tmp_path / "node.key"
path.write_text(key + "\n", encoding="utf-8")
assert run("config", "import-key", "--from", str(path), "--yes").exit_code == exitcodes.OK
assert run("config", "import-key", key, "--yes").exit_code == exitcodes.OK
with sqlite3.connect(tmp_path / "test.db") as db:
stored = [row[0] for row in db.execute("SELECT args_json FROM runs")]
assert len(stored) == 2
assert all("<secret>" in args and key not in args for args in stored)
@pytest.mark.parametrize(
"args,problem",
[
((), "but not both"),
(("cd" * 64, "--from", "node.key"), "but not both"),
(("not-a-key",), "hex digits only"),
(("cd" * 40,), "This one has 40"),
(("--from", "no-such-file"), "no-such-file"),
],
ids=["no-key", "two-keys", "not-hex", "wrong-length", "no-file"],
)
def test_import_key_refuses_a_key_that_is_not_one(run, args, problem) -> None: # noqa: ANN001
"""A bad key is a bad argument: a usage error, before anything goes to the device."""
result = run("config", "import-key", *args, "--yes")
assert result.exit_code == exitcodes.USAGE
assert result.stdout == ""
assert problem in " ".join(result.stderr.split())
@pytest.mark.skipif(os.name != "posix", reason="Windows has no owner-only mode bits")
def test_export_key_writes_a_file_for_the_owner_only(run, tmp_path: Path) -> None: # noqa: ANN001
"""The file holds a private key, so nobody else on the machine can read it."""
path = tmp_path / "node.key"
assert run("config", "export-key", "--out", str(path)).exit_code == exitcodes.OK
assert stat.S_IMODE(path.stat().st_mode) == 0o600
@pytest.mark.parametrize(
"args,flag",
[
+128 -1
View File
@@ -22,10 +22,12 @@ from meshterm.context import AppContext
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.device_store import DeviceStore
from meshterm.core.privatekey import expand_seed
from meshterm.persistence.repository import Repository
from meshterm.platforms import PICOCALC_LYRA, REGULAR, set_platform
from meshterm.ui.config_editor import (
_ConfigMenu,
_is_private_key,
_menu_items,
config_table,
contact_share_url,
@@ -1100,7 +1102,7 @@ async def test_actions_factory_reset_gates_on_typed_confirmation(ctx: AppContext
async def test_actions_import_key_gates_on_typed_confirmation(ctx: AppContext) -> None:
"""An import of an identity key needs the typed IMPORT confirmation."""
device = await ctx.device()
new_key = "ab" * 32
new_key = "ab" * 64
_install(
ctx,
[
@@ -1115,6 +1117,131 @@ async def test_actions_import_key_gates_on_typed_confirmation(ctx: AppContext) -
assert await device.export_private_key() == new_key
async def test_actions_show_key_draws_the_key(
ctx: AppContext, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Show private key draws the key itself. For a time, it drew its warning and no key."""
monkeypatch.setattr("meshterm.ui.surface.TuiUi", _ScriptedUi)
key = await (await ctx.device()).export_private_key()
ui = _install(
ctx,
[
("select", "__identity_key__"),
("select", "show"),
("dialog", "show"),
("select", None),
],
)
await edit_config(ctx)
assert key in ui.notes
assert ui.presented == ["Private key"]
async def test_actions_import_key_expands_a_seed(ctx: AppContext) -> None:
"""A pasted 32-byte seed goes to the device as the 64-byte key that a firmware accepts."""
device = await ctx.device()
seed = "ab" * 32
_install(
ctx,
[
("select", "__identity_key__"),
("select", "import"),
("text", seed),
("typed_confirm", True),
("select", None),
],
)
await edit_config(ctx)
assert await device.export_private_key() == expand_seed(bytes.fromhex(seed)).hex()
@pytest.mark.parametrize(
"content",
[("cd" * 64 + "\n").encode(), bytes.fromhex("cd" * 64)],
ids=["hex-text", "raw-bytes"],
)
async def test_actions_import_key_reads_a_file(
ctx: AppContext, tmp_path: Path, content: bytes
) -> None:
"""A file from ``export-key --out`` and a raw ``identity.key`` import the same key."""
device = await ctx.device()
path = tmp_path / "node.key"
path.write_bytes(content)
_install(
ctx,
[
("select", "__identity_key__"),
("select", "load"),
("path", str(path)),
("typed_confirm", True),
("select", None),
],
)
await edit_config(ctx)
assert await device.export_private_key() == "cd" * 64
async def test_actions_import_key_stops_at_a_file_that_holds_no_key(
ctx: AppContext, tmp_path: Path
) -> None:
"""A file that holds no key says why, and never gets to the confirmation."""
device = await ctx.device()
before = await device.export_private_key()
path = tmp_path / "notes.txt"
path.write_bytes(b"\x00" * 10)
ui = _install(
ctx,
[
("select", "__identity_key__"),
("select", "load"),
("path", str(path)),
("select", None), # no typed confirmation: the script would fail on one
],
)
await edit_config(ctx)
assert await device.export_private_key() == before
assert any("not imported" in note for note in ui.notes)
def test_a_pasted_key_of_the_wrong_length_says_why() -> None:
"""The prompt refuses the key before the confirmation, and names the problem."""
assert _is_private_key("ab" * 64) is True
assert _is_private_key("AB" * 32 + "\n") is True
assert "This one has 40" in str(_is_private_key("ab" * 40))
assert "hex digits only" in str(_is_private_key("not a key"))
async def test_actions_import_key_restarts_a_node_that_reads_its_key_at_start(
ctx: AppContext, monkeypatch: pytest.MonkeyPatch
) -> None:
"""On the node of an SPI radio, the import ends the visit and gives control to the reconnect.
The staged change goes with the visit. Thus the user agrees to discard it first.
"""
from meshterm.ui import config_editor
device = await ctx.device()
monkeypatch.setattr(type(device), "import_restarts", True)
monkeypatch.setattr(AppContext, "active_transport", property(lambda _self: "spi"))
monkeypatch.setattr(config_editor, "_REBOOT_HANDOFF_TIMEOUT_S", 0)
ui = _install(
ctx,
[
("select", "name"),
("text", "NewName"),
("select", "__identity_key__"),
("select", "import"),
("text", "ab" * 64),
("dialog", "discard"),
("typed_confirm", True),
],
)
assert await edit_config(ctx) is None
assert ctx.reboot_in_progress
assert not ui.script
assert await device.export_private_key() == "ab" * 64
async def test_actions_reboot_on_simulator_stays_on_the_screen(ctx: AppContext) -> None:
"""The simulator has no link to drop, so a confirmed reboot only adds a note and returns."""
ui = _install(
+15
View File
@@ -543,6 +543,21 @@ def test_outbox_rows_recompute_live_state_per_repaint(tmp_path: Path) -> None:
assert "try 1" in _outbox_plain(screen)
def test_outbox_draws_each_recipient_as_a_destination_chip(tmp_path: Path, powerline) -> None:
"""A queued message goes to its recipient, so the recipient opens on the notch.
A waiting recipient has the hue of its key. A finished one is muted with the rest of its
row, because the finished section is history.
"""
from meshterm.ui.pathline import POWERLINE_ROUND_CLOSE, POWERLINE_SEP
powerline(True)
titles = _outbox_titles(tmp_path)
for name in ("Waiting", "Landed", "Lost"):
row = next(title for title in titles if name in title)
assert f"{POWERLINE_SEP} {name} {POWERLINE_ROUND_CLOSE}" in row
def _title_plain(choice) -> str:
"""The label of a row as plain text.
+37
View File
@@ -1689,3 +1689,40 @@ async def test_a_locked_contact_is_never_offered_to_the_sweep() -> None:
ranked = {s.contact.name: s for s in await _rank(ctx, contacts, "cc" * 32)}
assert ranked["Kept"].protection == PROTECT_LOCKED
assert not ranked["Loose"].protected
async def test_contacts_screen_l_opens_the_map_on_the_highlighted_node() -> None:
"""^L asks for the map on the highlighted node. The hint and F6 name it only where it acts."""
from meshterm.core.models import Contact, MapMarker
from meshterm.ui.contacts_screen import ContactsScreen
from meshterm.ui.tui import KeyRequest, fkeys
from tests.conftest import recording_locator
contacts = [
Contact(name="Alice", public_key="aa" * 32),
Contact(name="Bob", public_key="bb" * 32),
]
alice = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="aa" * 32)
screen = ContactsScreen(
"Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), locator=recording_locator([alice])
)
resolved: list = []
screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
# Our node is first, and it has no location here.
assert "^L" not in screen.footer_hint
lane = screen.picocalc_lyra_lane
assert (lane[0].label, lane[0].opp_label) == ("Sect ↑", "Map") # Map is Shift+F1 (F6)
assert lane[0].opp_enabled is False
screen.handle("locate")
assert resolved == []
screen.handle("down") # Alice
assert "^L map" in screen.footer_hint
assert screen.picocalc_lyra_lane[0].opp_enabled is True
assert fkeys.PICOCALC_LYRA_DECK.action_for(screen.picocalc_lyra_lane, 6) == "locate"
screen.handle("locate")
assert resolved == [KeyRequest("locate", alice)]
screen.handle("down") # Bob, with no location
assert "^L" not in screen.footer_hint
+9 -4
View File
@@ -244,7 +244,8 @@ def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> N
screen, and does not name in the list, fails here. Without the test, the mark would get
a blank cell next to it with no warning. The test skips prose, because MeshTerm reads
docstrings and bare strings and does not draw them. It also skips the module that defines
the list.
the list. It also skips the vocabulary of reactions, which is content that other apps
send.
"""
from rich._unicode_data import load
@@ -254,13 +255,17 @@ def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> N
if not char.isascii() and char not in cells._SINGLE_CELLS
}
# Inputs to the substitution table of the PicoCalc, which maps them to glyphs that its
# font has. A terminal that shows emoji never draws them, so they are not marks.
fold_inputs = {"↪", "✔", "✖"}
# font has. A terminal that shows emoji never draws them, so they are not marks. The
# heart is the key of an emoticon (theme._EMOTICONS), which only a handheld uses.
fold_inputs = {"↪", "✔", "✖", "❤"}
package = pathlib.Path(ew.__file__).parents[2]
drawn: set[str] = set()
for path in package.rglob("*.py"):
if path.name == "emoji_width.py":
# The reactions module holds the emoji that other apps send (the defaults of
# MeshCore One, the table of meshcore-open). These are content, not marks of the
# app, and each one that is text-default carries its variation selector.
if path.name in ("emoji_width.py", "reactions.py"):
continue
tree = ast.parse(path.read_text(encoding="utf-8"))
prose = {
+7 -2
View File
@@ -242,17 +242,22 @@ def test_the_raster_draws_a_cell_in_its_colours() -> None:
@pytest.mark.skipif(not hasattr(mmap, "MAP_SHARED"), reason="the panel is Linux's")
def test_the_panel_is_written_through_its_mapping(tmp_path) -> None:
def test_the_panel_is_written_through_its_mapping(tmp_path, monkeypatch) -> None:
"""The rows go to their stride through the map.
The panel draws again only what the map makes dirty. A ``pwrite`` to the ``/dev/fb0`` of
the Cardputer reaches the buffer, but it never reaches the glass. Thus MeshTerm ran
behind the loading screen of the launcher. A plain file is a stand-in for the device.
The test makes sure that the bytes go through the mapping, to the correct place.
The test removes the sysfs read. A Linux CI runner has a real
``/sys/class/graphics/fb0``, and its stride is not the stride of this small file. With
that stride, the map was longer than the file, and ``mmap`` refused it.
"""
monkeypatch.setattr(Framebuffer, "_sysfs", lambda self, name: None)
path = tmp_path / "fb0"
path.write_bytes(bytes(8 * 4))
fb = Framebuffer(str(path), width=4, height=4) # no sysfs here: the stride is width * 2
fb = Framebuffer(str(path), width=4, height=4) # no sysfs: the stride is width * 2
write = os.pwrite
try:
os.pwrite = lambda *a: pytest.fail("the panel must not be written with pwrite") # type: ignore[assignment]
+45 -2
View File
@@ -540,7 +540,7 @@ def _room(cols: int, rows: int, *, access=None, retry: bool = False) -> Screen:
The board has a post by a contact (with a name, in its hue), a post by an author that
nothing names (the grey hash), a notice that the room posted itself, and our own post.
If ``retry`` is set, our post has no ack, so ^R joins ^L on the hint. This is the longest
If ``retry`` is set, our post has no ack, so ^R joins ^G on the hint. This is the longest
that the hint gets.
"""
from meshterm.core.models import LoginResult, RoomAccess, RoomLogin
@@ -676,7 +676,7 @@ def _room_read_only(cols: int, rows: int) -> Screen:
def _room_retry(cols: int, rows: int) -> Screen:
"""A post of ours with no ack: ^R retry joins ^L on the hint."""
"""A post of ours with no ack: ^R retry joins ^G on the hint."""
return _room(cols, rows, retry=True)
@@ -735,6 +735,45 @@ def _chat_urls(cols: int, rows: int) -> Screen:
return ChatScreen(conv, messages, send=None, names={}, session=_GallerySession(cols, rows))
def _chat_reactions(cols: int, rows: int) -> Screen:
"""A selected channel message with reactions: the names of the nodes take the counts' place.
Five nodes react with three emoji, one of them ours. The selection draws the widest line
that reactions make, and it wraps at the indent of the body. On the PicoCalc, emoticons
stand in for the emoji that have one.
"""
from datetime import datetime, timezone
from meshterm.core import reactions
stamp = 1_760_000_000
at = datetime.fromtimestamp(stamp, tz=timezone.utc)
def react(name: str, emoji: str) -> ChatMessage:
body = reactions.channel_text(emoji, "Alice", "bridge on 3rd is open again", stamp)
return ChatMessage(text=f"{name}: {body}", is_channel=True, created_at=at)
conv = Conversation(label="Lakeside emergency", is_channel=True, channel_idx=2)
messages = [
ChatMessage(text="Alice: bridge on 3rd is open again", is_channel=True, created_at=at),
react("Hilltop-Repeater", "👍"),
react("Bob", "👍"),
react("Carol-Mobile", "❤️"),
react("Dave", "🔥"),
ChatMessage(
text=reactions.channel_text("👍", "Alice", "bridge on 3rd is open again", stamp),
outbound=True,
is_channel=True,
created_at=at,
),
]
screen = ChatScreen(
conv, messages, send=None, names={}, session=_GallerySession(cols, rows), self_name="Me"
)
screen.handle("up")
return screen
def _chat_long_url(cols: int, rows: int) -> Screen:
"""A picked message with a link that is wider than the body lane.
@@ -795,6 +834,9 @@ class _PickerRepo:
def node_names(self) -> dict:
return {}
def node_names_with_times(self) -> tuple[dict, dict]:
return {}, {}
def _chat_picker(cols: int, rows: int) -> Screen:
"""The conversation picker, built through the tool's own row funnel.
@@ -1735,6 +1777,7 @@ _ENTRIES: list[_Entry] = [
_Entry("chat", _chat),
_Entry("chat_channel_scoped", _chat_channel_scoped),
_Entry("chat_urls", _chat_urls),
_Entry("chat_reactions", _chat_reactions),
_Entry("chat_long_url", _chat_long_url),
_Entry("chat_picker", _chat_picker),
_Entry("room", _room),
+56 -7
View File
@@ -243,6 +243,36 @@ def test_livefeed_addressed_frame_is_about_its_two_ends() -> None:
assert "a1 → Hilltop-Repe" in _rows(wide, 100)[0]
def test_livefeed_addressed_frame_draws_its_two_ends_as_chips(powerline) -> None:
"""With chips, the sender's point and the recipient's notch face each other across a cell.
A seam would claim that the two are neighbours, and the relays between them are not
drawn. A pair that is too wide for the lane cracks at the lane, and is not elided.
"""
from meshterm.ui.pathline import CRACK_TAIL, POWERLINE_SEP
powerline(True)
screen = _screen(self_name="Us")
raw = {"payload_typename": "TEXT_MSG", "dest_hash": "c0", "src_hash": "a1"}
screen.on_event(MeshEvent.observation_event(_obs(node="", kind="packet", raw=raw)))
assert f" Alice {POWERLINE_SEP} {POWERLINE_SEP} ★ " in _rows(screen, 100)[0]
wide = _screen()
wide._resolve = lambda h: {"a1": "Alice-With-A-Long-Name", "3d": "Hilltop-Repeater"}.get(h, "")
wide.on_event(
MeshEvent.observation_event(
_obs(
node="",
kind="packet",
raw={"payload_typename": "REQ", "dest_hash": "3d", "src_hash": "a1"},
)
)
)
row = _rows(wide, 100)[0]
assert f" a1 {POWERLINE_SEP} {POWERLINE_SEP} Hilltop" in row
assert CRACK_TAIL in row and "…" not in row
def test_livefeed_tokened_classes_are_about_their_token() -> None:
"""An ack names the message that it answers. A trace names its tag. Neither says "packet"."""
screen = _screen()
@@ -366,12 +396,30 @@ def test_livefeed_home_resumes_following_from_deep_in_the_history() -> None:
assert screen._selected == 0 # the feed follows again, and does not stay on the old newest
def test_livefeed_opening_a_packet_drops_the_pin() -> None:
"""Enter names this packet, so an arrival during a long read cannot move the highlight."""
def test_livefeed_opening_a_packet_keeps_the_pin() -> None:
"""Enter on the pin opens the viewer on the pin, so the feed and the card both follow.
JP decided this on 2026-10-10. Before, Enter removed the pin.
"""
screen = _screen(seed=[_obs(node="n0", age_s=1)])
screen.future = _Fut()
assert screen._pinned
screen._select_row(0) # this is what _open_packet does before it floats the viewer
screen.handle("enter")
viewer = screen._session.opened
assert viewer is not None and viewer._pinned and "following" in viewer.title
assert screen._pinned, "Enter knocked the feed out of the stream"
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
assert screen._selected == 0, "the pin let go of the top"
assert "Alice" in _plain(viewer.render_body(72)), "the card did not follow the stream"
def test_livefeed_opening_a_selected_packet_holds_it() -> None:
"""Enter on a selected packet holds it, so an arrival during a long read cannot move it."""
screen = _screen(seed=[_obs(node="n0", age_s=1)])
screen.handle("down") # off the pin, onto the newest packet
screen.handle("enter")
viewer = screen._session.opened
assert viewer is not None and not viewer._pinned
assert not screen._pinned and screen._selected == 0
screen.on_event(MeshEvent.observation_event(_obs(node="a1b2")))
assert screen._selected == 1, "the cursor left the packet being viewed"
@@ -380,11 +428,12 @@ def test_livefeed_opening_a_packet_drops_the_pin() -> None:
def test_livefeed_viewer_that_walks_up_to_the_stream_re_pins_the_feed() -> None:
"""The viewer has the second stop of this screen, and it returns the pin through it.
Enter still names this packet, so the dialog opens with one packet. The user can press
up at the top of the viewer. This is the same move as up at the top of the feed. Thus
the feed follows the stream again, and it still follows when the dialog closes.
Enter on a selected packet opens the dialog on that packet. The user can press up at
the top of the viewer. This is the same move as up at the top of the feed. Thus the
feed follows the stream again, and it still follows when the dialog closes.
"""
screen = _screen(seed=[_obs(node="n0", age_s=2), _obs(node="n1", age_s=1)])
screen.handle("down") # off the pin, onto the newest packet
screen.handle("enter")
viewer = screen._session.opened
assert viewer is not None and not screen._pinned
+19
View File
@@ -3205,3 +3205,22 @@ def test_stamping_leaves_the_callers_rows_untouched() -> None:
before = list(rows)
attribution.stamp(rows, 70, full=True)
assert rows == before
def test_the_locator_finds_a_node_by_its_key_or_its_key_prefix() -> None:
"""A key prefix finds the node with the full key, and a full key finds a key prefix."""
from meshterm.core.models import MapMarker
from meshterm.ui.map_screen import Locator
full = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="A1B2C3D4" + "0" * 56)
short = MapMarker(label="Bob", lat=45.4, lon=-73.5, key="b2c3d4e5f6a7")
us = MapMarker(label="Base", lat=45.3, lon=-73.4, is_self=True, key="ee" * 32)
locator = Locator(None, [full, short, us]) # type: ignore[arg-type]
assert locator.find("a1b2c3d4") is full # the four bytes that sign a room post
assert locator.find("0xA1B2C3D4" + "0" * 56) is full
assert locator.find("b2c3d4e5f6a7" + "9" * 52) is short # stored history knows six bytes
assert locator.find("c3c3c3c3") is None
assert locator.find(None) is None and locator.find("") is None
assert locator.you() is us
assert Locator(None, [full]).you() is None # type: ignore[arg-type]
+33
View File
@@ -1369,6 +1369,39 @@ def test_node_detail_screen_tabs_switch_the_stage() -> None:
assert screen.picocalc_lyra_lane[2].label == "Info"
def test_node_detail_preview_keeps_its_minimum_except_on_a_short_frame() -> None:
"""The preview shows at its minimum when the actions leave less. A short frame has none.
A contact page has many action rows, so on a terminal of 24 rows they leave the preview
less than its minimum. The preview keeps its minimum and the page scrolls. The 11-row
viewport of the Cardputer is a short frame, and there the preview is not shown.
"""
mini = MiniMap(
_FakeSession(),
_OfflineSource(),
14,
center_lat=45.5,
center_lon=-73.6,
zoom=13,
markers=[MapMarker(label="Hub", lat=45.5, lon=-73.6, key="3d63aa")],
)
actions = [_Action(f"a{i}", "⚙", "", f"Action {i}") for i in range(8)]
screen = _screen(
minimap=mini,
map_caption=Text("Hub · centred here", style="faint"),
info_actions=actions,
)
screen.note_viewport(21)
body = _plain(screen.render_body(72)).splitlines()
caption = next(i for i, line in enumerate(body) if "centred here" in line)
vitals = next(i for i, line in enumerate(body) if line.startswith("packets"))
assert caption - vitals - 2 >= 5 # the rows between the blank line and the caption
assert len(body) > 21 # the preview did not shrink under its minimum: the page scrolls
screen.note_viewport(11)
assert "centred here" not in _plain(screen.render_body(53))
def test_node_detail_single_tab_hides_the_switch_hint() -> None:
"""A page with only one tab does not have the ←→ tab atom in its footer."""
screen = _screen(tabs=[_Tab("Info", "info")]) # our node: no Routes tab
+31
View File
@@ -165,3 +165,34 @@ def test_every_vendored_license_gap_has_its_file() -> None:
path = Path(notices.__file__).parent / rel
assert path.is_file(), f"{name}: {rel} is missing"
assert path.read_text(encoding="utf-8").strip(), f"{name}: {rel} is empty"
def _fake_dist(site: Path, name: str, version: str) -> None:
"""Write a minimal installed distribution with a licence file into ``site``."""
info = site / f"{name.replace('-', '_')}-{version}.dist-info"
info.mkdir(parents=True)
(info / "METADATA").write_text(
f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\nLicense-Expression: MIT\n",
encoding="utf-8",
)
(info / "LICENSE").write_text(f"The licence of {name}.\n", encoding="utf-8")
(info / "RECORD").write_text(
f"{info.name}/METADATA,,\n{info.name}/LICENSE,,\n{info.name}/RECORD,,\n",
encoding="utf-8",
)
def test_site_mode_lists_the_directory_and_not_the_closure(tmp_path: Path) -> None:
"""With ``site``, the notices are the distributions in that directory, less MeshTerm.
The Debian package for the Cardputer Zero bundles the ``spi`` extra. The walk of the
closure asks for no extras, so the package reads its ``pip install --target``
directory instead. The Python notice stays first in this mode too.
"""
_fake_dist(tmp_path, "mesh-term", "1.0")
_fake_dist(tmp_path, "zeta-lib", "2.0")
_fake_dist(tmp_path, "alpha-lib", "3.0")
got = notices.collect_notices(site=tmp_path)
assert [n.name for n in got] == ["Python", "alpha-lib", "zeta-lib"]
assert got[1].summary == "MIT"
assert "The licence of alpha-lib." in got[1].text
+61 -1
View File
@@ -158,7 +158,7 @@ def test_packet_viewer_follows_the_stream_off_the_top_of_a_live_list() -> None:
source=lambda: list(feed),
on_pin=lambda: pinned_by_viewer.append(True),
)
assert not viewer._pinned, "opening a packet named that packet, not the stream"
assert not viewer._pinned, "an open without pinned holds the packet, not the stream"
assert "↑ follow" in viewer.footer_hint
viewer.handle("up")
@@ -176,6 +176,35 @@ def test_packet_viewer_follows_the_stream_off_the_top_of_a_live_list() -> None:
assert "1/2" in viewer.title # the viewer holds that packet, which now moves down the list
def test_packet_viewer_opens_on_the_pin_of_a_live_list() -> None:
"""``pinned`` opens on the pin: the card follows the stream from the first paint.
The open does not call ``on_pin``, because the list is already on its own pin. Over a
snapshot, ``pinned`` does nothing, because there is no stream to follow.
"""
old = PacketEntry(when=utcnow(), kind="advert", node="aa")
feed = [old]
pinned_by_viewer = []
viewer = PacketViewer(
list(feed),
0,
pinned=True,
resolve=lambda h: "",
source=lambda: list(feed),
on_pin=lambda: pinned_by_viewer.append(True),
)
assert viewer._pinned and viewer.title.endswith("· following")
assert "↓ hold packet" in viewer.footer_hint
assert pinned_by_viewer == []
newer = PacketEntry(when=utcnow(), kind="message", node="bb", text="new!")
feed.insert(0, newer)
assert "new!" in _plain(viewer.render_body(80))
snapshot = PacketViewer([newer, old], 0, pinned=True, resolve=lambda h: "")
assert not snapshot._pinned and "following" not in snapshot.title
def test_packet_viewer_over_a_snapshot_has_nothing_to_follow() -> None:
"""With no live source there is no pin. ``↑`` at the newest packet clamps, as before."""
newer = PacketEntry(when=utcnow(), kind="message", node="bb", text="new!")
@@ -406,6 +435,37 @@ def test_packet_viewer_lays_out_what_a_frame_addressed() -> None:
assert _plain(lines).count("dest_hash") == 0
def test_packet_viewer_draws_each_end_of_the_route_as_the_chip_of_that_end(powerline) -> None:
"""The sender closes on the point and the recipient opens on the notch, around ``via``."""
from meshterm.ui.pathline import POWERLINE_ROUND_CLOSE, POWERLINE_ROUND_OPEN, POWERLINE_SEP
powerline(True)
entry = PacketEntry(
when=utcnow(),
kind="packet",
path="3d",
raw={
"payload_typename": "TEXT_MSG",
"route_typename": "FLOOD",
"dest_hash": "c0",
"src_hash": "a1",
},
)
viewer = PacketViewer(
[entry],
0,
resolve=lambda h: {"c0": "Waymarker", "a1": "Alice"}.get(h, ""),
prefix_bytes=1,
self_name="Waymarker",
)
lines = _stripped(viewer.render_body(80))
to_row = next(ln for ln in lines if ln.startswith("to"))
from_row = next(ln for ln in lines if ln.startswith("from"))
assert f"{POWERLINE_SEP} Waymarker {POWERLINE_ROUND_CLOSE} c0" in to_row # us, reached
assert f"{POWERLINE_ROUND_OPEN} Alice {POWERLINE_SEP} a1" in from_row # the route goes on
def test_packet_viewer_names_a_tokened_frame_by_its_token() -> None:
"""An ack points to the message that it answers. A trace points to its own tag."""
ack = PacketEntry(
+5 -5
View File
@@ -392,15 +392,15 @@ def test_map_locate_is_reachable_from_both_control_keys() -> None:
Thus both Ctrl keys and F2 do the same action.
The letter is the mnemonic of the action: ^Y for "you". Thus it is the same chord on each
screen that can point to our node. It is not the initial of one screen.
The letter is the mnemonic of the action: ^L to locate. Thus it is the same chord on each
screen that can show a node on the map. It is not the initial of one screen.
"""
from prompt_toolkit.keys import Keys
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS
assert _CTRL_LETTER_CHORDS["y"] == "locate" # the half for the right-Ctrl rescue
assert _KEY_ACTIONS[Keys.ControlY] == "locate" # the ordinary binding, made from it
assert _CTRL_LETTER_CHORDS["l"] == "locate" # the half for the right-Ctrl rescue
assert _KEY_ACTIONS[Keys.ControlL] == "locate" # the ordinary binding, made from it
def test_host_battery_reads_the_sysfs_supply(tmp_path, monkeypatch) -> None:
@@ -500,7 +500,7 @@ def test_an_atom_the_lane_only_half_covers_stands() -> None:
# The atom also describes the arrows, and no slot has them.
assert strip_lane_atoms("↑↓ PgUp/PgDn scroll", DEFAULT_LANE) == "↑↓ PgUp/PgDn scroll"
# A second key in the verb half keeps the whole atom (region/you of the map).
assert strip_lane_atoms("Home/^Y region/you", DEFAULT_LANE) == "Home/^Y region/you"
assert strip_lane_atoms("Home/^L region/you", DEFAULT_LANE) == "Home/^L region/you"
def test_a_dimmed_chip_still_covers_its_atom() -> None:
+212
View File
@@ -0,0 +1,212 @@
# SPDX-License-Identifier: Apache-2.0
"""The private key of our node: the forms that MeshTerm accepts, and how each device takes it.
A companion exports and imports a 64-byte key. A node of an SPI radio keeps a 32-byte seed.
MeshTerm accepts the two forms from the user and sends only the first form, and these tests
hold each step of that path.
"""
from __future__ import annotations
import os
import stat
from pathlib import Path
from types import SimpleNamespace
import pytest
from Crypto.PublicKey import ECC
from meshcore import EventType
from meshcore.events import Event
from meshterm.core import spiradio
from meshterm.core.config import SpiWiring
from meshterm.core.connection import DeviceCommandError, MeshCoreDevice
from meshterm.core.privatekey import (
expand_seed,
normalize,
parse_private_key,
read_private_key_file,
)
#: The base point of Ed25519 (RFC 8032, section 5.1).
_GX = 15112221349535400772501151409588531511454012693041857206046113283949847762202
_GY = 46316835694926478169428394003475163141307993866256225615783033603165251855960
# --- the forms -----------------------------------------------------------------------------
def test_an_expanded_seed_has_the_public_key_of_the_seed() -> None:
"""The firmware gets the public key from the first half of the key, with no more clamp.
That point must be the public key that Ed25519 makes from the seed. If it is not, the
import gives the device a different identity from the one the user exported.
"""
seed = bytes(range(32))
key = expand_seed(seed)
assert len(key) == 64
point = ECC.EccPoint(_GX, _GY, curve="Ed25519") * int.from_bytes(key[:32], "little")
expected = ECC.construct(curve="Ed25519", seed=seed).pointQ
assert (point.x, point.y) == (expected.x, expected.y)
def test_an_expanded_seed_is_clamped_as_the_firmware_clamps_it() -> None:
"""The three low bits are clear, the top bit is clear, and the bit below it is set."""
key = expand_seed(b"\xff" * 32)
assert key[0] & 0x07 == 0
assert key[31] & 0x80 == 0
assert key[31] & 0x40
def test_a_key_passes_as_it_is_and_a_seed_is_expanded() -> None:
"""The device always gets 64 bytes."""
key = bytes.fromhex("cd" * 64)
assert normalize(key) == key
assert normalize(key[:32]) == expand_seed(key[:32])
def test_a_pasted_key_can_have_line_breaks_and_capitals() -> None:
"""A key that the user copies from a terminal has the breaks where the terminal wrapped it."""
pasted = "CD" * 32 + "\n " + "cd" * 32 + "\n"
assert parse_private_key(pasted) == bytes.fromhex("cd" * 64)
@pytest.mark.parametrize(
"text,problem",
[
("", "hex digits only"),
("not a key", "hex digits only"),
("abc", "even number"),
("ab" * 40, "This one has 40"),
],
ids=["empty", "not-hex", "odd", "wrong-length"],
)
def test_a_bad_key_says_why(text: str, problem: str) -> None:
"""The message is for the user, who must correct what they pasted."""
with pytest.raises(ValueError, match=problem):
parse_private_key(text)
@pytest.mark.parametrize(
"content,expected",
[
(("cd" * 64 + "\n").encode(), bytes.fromhex("cd" * 64)),
(bytes.fromhex("cd" * 64), bytes.fromhex("cd" * 64)),
(bytes(range(32)), expand_seed(bytes(range(32)))),
(("ab" * 32).encode(), expand_seed(bytes.fromhex("ab" * 32))),
],
ids=["hex-text", "raw-key", "raw-seed", "seed-as-hex-text"],
)
def test_a_key_file_can_hold_hex_text_or_raw_bytes(
tmp_path: Path, content: bytes, expected: bytes
) -> None:
"""``export-key --out`` writes hex text, and the ``identity.key`` of a node holds raw bytes.
A seed that someone wrote into a file as hex text is 64 bytes on disk. As raw bytes, it
would be a different key with no error, so the file is read as text first.
"""
path = tmp_path / "node.key"
path.write_bytes(content)
assert read_private_key_file(path) == expected
def test_a_file_that_holds_no_key_says_so(tmp_path: Path) -> None:
"""A file of the wrong size or contents is refused, with its name in the message."""
path = tmp_path / "notes.bin"
path.write_bytes(b"\x00" * 10)
with pytest.raises(ValueError, match="notes.bin does not hold a private key"):
read_private_key_file(path)
path.write_bytes(b"\x00" * 5000)
with pytest.raises(ValueError, match="too large"):
read_private_key_file(path)
# --- the devices ---------------------------------------------------------------------------
def _device_that_answers(event: Event) -> tuple[MeshCoreDevice, list[bytes]]:
"""A device whose client answers each command with ``event``, and the frames it got."""
sent: list[bytes] = []
async def send(data: bytes, expected: list, timeout: float | None = None) -> Event:
sent.append(data)
assert event.type in expected, "the device must wait for this answer"
return event
device = MeshCoreDevice(port="mock")
device._mc = SimpleNamespace(commands=SimpleNamespace(send=send))
return device, sent
async def test_a_firmware_import_sends_the_key() -> None:
"""The command is 0x18 and the 64 bytes of the key."""
device, sent = _device_that_answers(Event(EventType.OK, {}))
await device.import_private_key("cd" * 64)
assert sent == [b"\x18" + bytes.fromhex("cd" * 64)]
async def test_a_firmware_with_the_import_off_says_so() -> None:
"""The firmware answers "disabled". Before, MeshTerm waited for its timeout and said nothing."""
device, _sent = _device_that_answers(Event(EventType.DISABLED, {}))
with pytest.raises(DeviceCommandError, match="does not accept an imported key"):
await device.import_private_key("cd" * 64)
async def test_an_spi_node_gets_its_imported_key_as_a_file(tmp_path: Path) -> None:
"""The node of the radio library refuses an import, so MeshTerm writes its key file.
The node reads the file when it starts again, so the device says that the import
restarts it.
"""
device = spiradio.SpiRadioDevice(SpiWiring(), tmp_path / "state")
assert device.import_restarts
key = expand_seed(bytes(range(32)))
await device.import_private_key(key.hex())
path = tmp_path / "state" / "identity.key"
assert path.read_bytes() == key
if os.name == "posix":
assert stat.S_IMODE(path.stat().st_mode) == 0o600
def test_a_firmware_companion_uses_its_new_key_at_once() -> None:
"""Only a node that MeshTerm runs must start again to use the key."""
assert MeshCoreDevice.import_restarts is False
# --- the run log ---------------------------------------------------------------------------
def test_a_v18_database_forgets_the_keys_that_import_key_stored(tmp_path: Path) -> None:
"""Before v19, the run of ``config import-key`` stored its key. The migration removes it.
The other runs and the other arguments stay as they were.
"""
import json
from meshterm.persistence import db as db_module
path = tmp_path / "old.db"
conn = db_module.connect(path)
conn.execute("UPDATE schema_meta SET value = '18' WHERE key = 'version'")
for tool, args in (
("config", {"ops": [["import_key", "cd" * 64]]}),
("config", {"ops": [["set", "name", "Lakeside"]]}),
("trace", {"to": "Lakeside"}),
):
conn.execute(
"INSERT INTO runs (tool, args_json, started_at) VALUES (?, ?, '2026-10-01')",
(tool, json.dumps(args)),
)
conn.commit()
conn.close()
conn = db_module.connect(path)
stored = [json.loads(row[0]) for row in conn.execute("SELECT args_json FROM runs ORDER BY id")]
version = conn.execute("SELECT value FROM schema_meta WHERE key = 'version'").fetchone()
conn.close()
assert stored == [
{"ops": [["import_key", "<secret>"]]},
{"ops": [["set", "name", "Lakeside"]]},
{"to": "Lakeside"},
]
assert int(version[0]) >= 19
+436
View File
@@ -0,0 +1,436 @@
# SPDX-License-Identifier: Apache-2.0
"""Emoji reactions: the wire format of MeshCore One, the fold, the send path, and the chat.
The parse cases come from the tests of MeshCore One (``ReactionParserTests.swift`` and
``MeshCoreOpenReactionParserTests.swift``). Thus MeshTerm reads the same bodies as the app
whose format it shares. The hash test calculates the expected value a second way, with the
Base32 of the standard library, so that the test does not only repeat the code.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
from datetime import datetime, timezone
from pathlib import Path
import pytest
from meshterm.core import reactions
from meshterm.core.config import Settings
from meshterm.core.models import ChatMessage, Contact, Conversation, Delivery, Message
from meshterm.core.preferences import Preferences
from meshterm.core.reactions import MC1, MCO, MCO_V1, ReactionFold, parse
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.ui.chat import ChatScreen
from tests.conftest import plain
_T = 1_704_067_200 # 2024-01-01 00:00:00 UTC
def _at(seconds: int) -> datetime:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
# -- the hash -----------------------------------------------------------------------------
def test_the_hash_is_crockford_base32_of_the_first_five_bytes_of_sha256() -> None:
"""SHA-256 of the UTF-8 body and the little-endian timestamp, five bytes, Crockford Base32."""
digest = hashlib.sha256(b"Hello" + _T.to_bytes(4, "little")).digest()[:5]
rfc = base64.b32encode(digest).decode() # five bytes make exactly eight characters
expected = rfc.translate(
str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "0123456789abcdefghjkmnpqrstvwxyz")
)
assert reactions.message_hash("Hello", _T) == expected
assert reactions.message_hash("Hello", _T + 1) != expected
assert reactions.message_hash("World", _T) != expected
def test_the_texts_that_meshterm_sends_name_their_target() -> None:
"""A channel reaction names the author first. A direct one has the emoji and the hash."""
digest = reactions.message_hash("hi all", _T)
assert reactions.channel_text("👍", "Alice", "hi all", _T) == f"@[Alice]👍\n{digest}"
assert reactions.direct_text("👍", "hi all", _T) == f"👍\n{digest}"
assert parse(reactions.channel_text("👍", "Alice", "hi all", _T), channel=True) == (
reactions.Reaction("👍", MC1, digest, author="Alice")
)
# -- the parse ----------------------------------------------------------------------------
@pytest.mark.parametrize(
("body", "emoji", "author", "digest"),
[
("@[AlphaNode]👍\n7f3a9c12", "👍", "AlphaNode", "7f3a9c12"),
("❤️@[BetaNode]\ne4d8b1a0", "❤️", "BetaNode", "e4d8b1a0"), # before 1.4.1
("@[Node]👍\nOOOOOOOO", "👍", "Node", "00000000"),
("@[Node]👍\niiiiiiii", "👍", "Node", "11111111"),
("@[Node]👍\nLLLLLLLL", "👍", "Node", "11111111"),
("@[Node:Alpha]👍\na1b2c3d4", "👍", "Node:Alpha", "a1b2c3d4"),
("@[Alice]👍\nreceived", "👍", "Alice", "rece1ved"),
("@[A]👍🏽\na1b2c3d4", "👍🏽", "A", "a1b2c3d4"),
("@[A]👨‍👩‍👧\na1b2c3d4", "👨‍👩‍👧", "A", "a1b2c3d4"),
("@[A]🇨🇦\na1b2c3d4", "🇨🇦", "A", "a1b2c3d4"),
],
)
def test_a_channel_reaction_parses(body: str, emoji: str, author: str, digest: str) -> None:
"""The two orders, the Crockford substitutions, a colon in a name, and each glyph shape."""
assert parse(body, channel=True) == reactions.Reaction(emoji, MC1, digest, author=author)
@pytest.mark.parametrize(
"body",
[
"Just a normal message",
"@[Node]👍",
"👍 [Node]\na1b2c3d4",
"👍@Node\na1b2c3d4",
"@[Node]👍\nabc",
"@[Node]👍\nuuuuuuuu",
"@[]👍\na1b2c3d4",
"@[Node]\na1b2c3d4",
"@[Alice]🎉 on my way\npassword",
"🎉 on my way@[Alice]\npassword",
"A@[Node]\na1b2c3d4",
],
)
def test_a_usual_channel_message_is_not_a_reaction(body: str) -> None:
"""A body that only starts with an emoji, or is short of a part, stays a message."""
assert parse(body, channel=True) is None
def test_a_direct_reaction_has_no_author() -> None:
"""A direct reaction is the emoji and the hash. The channel shape is not one."""
assert parse("👍\nb45pc4ek", channel=False) == reactions.Reaction("👍", MC1, "b45pc4ek")
assert parse("@[Node]👍\na1b2c3d4", channel=False) is None
assert parse("🎉 on my way\npassword", channel=False) is None
assert parse("1\na1b2c3d4", channel=False) is None
def test_the_reactions_of_meshcore_open_parse() -> None:
"""The v3 form indexes a table of 184 emoji. The v1 form carries the time and two hashes."""
assert len(reactions.MCO_EMOJI) == 184
assert parse("r:a1b2:00", channel=True) == reactions.Reaction("👍", MCO, "a1b2")
assert parse("r:0000:45", channel=False).emoji == "😶" # the last smiley
assert parse("r:0000:66", channel=False).emoji == "💪" # the last gesture
assert parse("r:0000:67", channel=False).emoji == "❤️" # the first heart
assert parse("r:0000:b7", channel=False).emoji == "🚀" # the last object
for bad in ("r:a1b2:b8", "r:A1B2:00", "r:a1b2:0A", "r:a1b:00", "x:a1b2:00", ""):
assert parse(bad, channel=True) is None
old = parse("r:1704067200000_12345_67890:👍", channel=True)
assert old == reactions.Reaction(
"👍", MCO_V1, "1704067200000_12345_67890", timestamp=_T, author_hash=12345, body_hash=67890
)
def test_the_dart_hash_is_thirty_bits_and_never_zero() -> None:
"""The ``String.hashCode`` of Dart: 1 for no input, and always under 2**30."""
assert reactions.dart_hash([]) == 1
value = reactions.dart_hash([ord(c) for c in "test string"])
assert 0 < value < 1 << 30
assert reactions.dart_hash([ord(c) for c in "hello"]) != reactions.dart_hash(
[ord(c) for c in "world"]
)
def test_the_reaction_set_keeps_only_single_emoji() -> None:
"""A word that is not one emoji is ignored. With nothing left, the default set is used."""
assert reactions.reaction_set("🔥 ok 👍 🔥 👍👍") == ["🔥", "👍"]
assert reactions.reaction_set("") == reactions.DEFAULT_REACTIONS.split()
assert reactions.reaction_set("abc") == reactions.DEFAULT_REACTIONS.split()
# -- the fold -----------------------------------------------------------------------------
def _channel(text: str, seconds: int = _T, *, outbound: bool = False) -> ChatMessage:
return ChatMessage(text=text, outbound=outbound, is_channel=True, created_at=_at(seconds))
def test_a_channel_reaction_folds_onto_its_target_once_for_each_node() -> None:
"""The target stays. The reaction leaves the transcript. A copy from the same node is one."""
fold = ReactionFold(channel=True, self_name="Me")
target = _channel("Alice: hi all")
assert fold.take(target) is False
react = reactions.channel_text("👍", "Alice", "hi all", _T)
assert fold.take(_channel(f"Bob: {react}", _T + 5)) is True
assert fold.take(_channel(f"Bob: {react}", _T + 6)) is True # a relayed copy
assert fold.take(_channel(f"Carol: {react}", _T + 7)) is True
assert fold.take(_channel(reactions.channel_text("❤️", "Alice", "hi all", _T), outbound=True))
badges = fold.badges(target)
assert [(b.emoji, [r.name for r in b.reactors]) for b in badges] == [
("👍", ["Bob", "Carol"]),
("❤️", [None]),
]
assert badges[1].ours and fold.reacted(target, "❤️") and not fold.reacted(target, "👍")
assert fold.count(target) == 3
def test_a_reaction_to_our_channel_message_names_our_node() -> None:
"""Our channel messages have no prefix in the history. Our name is their author."""
fold = ReactionFold(channel=True, self_name="Me")
ours = _channel("lunch?", outbound=True)
fold.take(ours)
fold.take(_channel("Bob: " + reactions.channel_text("😂", "Me", "lunch?", _T)))
assert [b.emoji for b in fold.badges(ours)] == ["😂"]
assert fold.target_of(ours) == ("Me", "lunch?", _T)
def test_a_reaction_that_arrives_first_waits_for_its_target() -> None:
"""On a mesh, a reaction can arrive before its target. It attaches when the target comes."""
fold = ReactionFold(channel=True)
assert fold.take(_channel("Bob: " + reactions.channel_text("👍", "Alice", "late", _T)))
target = _channel("Alice: late")
assert fold.take(target) is False
assert [b.emoji for b in fold.badges(target)] == ["👍"]
def test_the_author_tells_two_identical_messages_apart() -> None:
"""Two nodes that send the same body in the same second get their own reactions."""
fold = ReactionFold(channel=True)
alice, carol = _channel("Alice: ok"), _channel("Carol: ok")
fold.take(alice)
fold.take(carol)
fold.take(_channel("Bob: " + reactions.channel_text("👍", "Carol", "ok", _T)))
assert fold.badges(alice) == [] and len(fold.badges(carol)) == 1
def test_a_direct_reaction_folds_by_hash_alone() -> None:
"""In a direct exchange, the peer is the only other node. Its reactor has no name."""
fold = ReactionFold(channel=False)
ours = ChatMessage(text="see you", outbound=True, created_at=_at(_T))
fold.take(ours)
assert fold.take(ChatMessage(text=reactions.direct_text("👍", "see you", _T), peer="d4e5"))
(badge,) = fold.badges(ours)
assert badge.emoji == "👍" and badge.reactors == [reactions.Reactor(None)]
def test_a_reaction_of_meshcore_open_finds_its_target_near_in_time() -> None:
"""The short hash of v3 is compared with the messages within the window of the reaction."""
fold = ReactionFold(channel=True)
target = _channel("Alice: hello there")
fold.take(target)
digest = reactions.mco_hash(_T, "Alice", "hello there")
assert fold.take(_channel(f"Bob: r:{digest}:05", _T + 30))
assert [b.emoji for b in fold.badges(target)] == ["🔥"]
far = _channel("Carol: hello there", _T + 10_000)
fold.take(far)
late = reactions.mco_hash(_T + 10_000, "Carol", "hello there")
assert fold.take(_channel(f"Bob: r:{late}:05", _T)) # out of the window: no target
assert fold.badges(far) == []
# -- the send path ------------------------------------------------------------------------
class _Recorder:
"""A device that records the timestamp and the try number of each send."""
def __init__(self, acks: list[bool]) -> None:
self._acks = list(acks)
self.direct: list[tuple[str, int | None, int]] = []
self.channel: list[tuple[str, int | None]] = []
async def send_direct_message(self, contact, text, *, timestamp=None, attempt=0): # noqa: ANN001, ANN201
self.direct.append((text, timestamp, attempt))
acked = self._acks.pop(0) if self._acks else False
code = f"{len(self.direct):08x}"
return Delivery(code, object() if acked else None)
async def send_channel_in_scope(self, index, text, scope, *, timestamp=None): # noqa: ANN001, ANN201
self.channel.append((text, timestamp))
class _Ctx:
def __init__(self, device: _Recorder, repo: Repository) -> None:
self._device = device
self.repo = repo
self.log = logging.getLogger("test.reactions")
self.settings = Settings()
self.preferences = Preferences()
async def device(self) -> _Recorder:
return self._device
@pytest.fixture()
def repo(tmp_path: Path) -> Repository:
"""A Repository on a scratch database. It closes when the test ends."""
r = Repository(tmp_path / "reactions.db")
yield r
r.close()
_ALICE = Contact(name="Alice", public_key="d4e5" + "0" * 60, key_prefix="d4e5f6a7")
async def test_a_sent_message_stores_the_timestamp_that_went_out(repo: Repository) -> None:
"""The stored time is the wire time to the second. A reaction to it then names it right."""
device = _Recorder([True])
chat = ChatService(_Ctx(device, repo))
sent = await chat.send_channel(0, "hello")
assert device.channel[0][1] == int(sent.created_at.timestamp())
assert sent.created_at.microsecond == 0
dm = await chat.send_direct(_ALICE, "hey")
assert device.direct[0][1] == int(dm.created_at.timestamp())
stored = repo.recent_chat_messages(is_channel=False, peer=_ALICE.key_prefix)
assert stored[-1].created_at == dm.created_at
async def test_retries_keep_the_timestamp_and_count_the_try(repo: Repository) -> None:
"""Soft retries and ^R send one message: the same timestamp, the next try number, up to 3."""
device = _Recorder([False, False, False, False, False])
ctx = _Ctx(device, repo)
ctx.preferences.direct_message_soft_retries = 1
chat = ChatService(ctx)
sent = await chat.send_direct(_ALICE, "hey")
await chat.resend_direct(_ALICE, sent)
await chat.resend_direct(_ALICE, sent)
stamps = {timestamp for _, timestamp, _ in device.direct}
assert stamps == {int(sent.created_at.timestamp())}
assert [attempt for *_, attempt in device.direct] == [0, 1, 2, 3, 3, 3]
async def test_a_reaction_does_not_raise_the_unread_badge(repo: Repository) -> None:
"""A reaction shows on its target, so a badge for it would point at nothing new."""
chat = ChatService(_Ctx(_Recorder([]), repo))
react = Message(text=reactions.direct_text("👍", "hey", _T), sender="d4e5f6a7")
assert await chat._notifies(react, None) is False
channel = Message(
text="Bob: " + reactions.channel_text("👍", "Alice", "hi", _T), channel=0, is_channel=True
)
assert await chat._notifies(channel, "abc") is False
# -- the chat screen ----------------------------------------------------------------------
class _Session:
"""A session stand-in that answers the reaction dialog with ``answer``."""
def __init__(self, answer: str | None) -> None:
self.answer = answer
self.asked: list[str] = []
self.items: list = []
self.filterable: bool | None = None
def invalidate(self) -> None:
pass
async def select(self, title, items, *, filterable=True, **_kw): # noqa: ANN001, ANN003, ANN201
self.asked.append(title)
self.items, self.filterable = items, filterable
return self.answer
def _screen(messages: list[ChatMessage], session: _Session, sent: list[str]) -> ChatScreen:
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True, is_channel=True, created_at=_at(_T + 60))
return ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
messages,
send=send,
names={},
session=session,
self_name="Me",
)
def test_the_transcript_shows_reactions_under_their_target() -> None:
"""The reaction leaves the transcript. Its emoji and count show under the target."""
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi all", _T)
screen = _screen([_channel("Alice: hi all"), _channel(react, _T + 5)], _Session(None), [])
assert len(screen._messages) == 1
body = plain(screen.render_body(60))
assert "👍 1" in body
assert reactions.message_hash("hi all", _T) not in body and "@[Alice]" not in body
screen.handle("up") # the selection names the nodes instead of the count
assert "👍 Bob" in plain(screen.render_body(60))
async def test_ctrl_e_sends_the_reaction_and_folds_our_copy() -> None:
"""^E asks which emoji, sends the text of MeshCore One, and shows our reaction on the target."""
session, sent = _Session("😂"), []
target = _channel("Alice: hi all")
screen = _screen([target], session, sent)
screen.handle("up")
assert "^E react" in screen.footer_hint
screen.handle("react")
await asyncio.sleep(0)
await asyncio.gather(*screen._tasks)
assert session.asked == ["React — Alice"]
assert sent == [reactions.channel_text("😂", "Alice", "hi all", _T)]
assert screen._messages == [target]
assert screen._fold.reacted(target, "😂")
def test_a_room_shows_reaction_text_as_it_arrived() -> None:
"""A room has no reactions. Its messages stay messages, and ^E offers nothing."""
body = reactions.direct_text("👍", "x", _T)
room = Contact(name="Board", public_key="ab" * 32, key_prefix="ab" * 6, node_type=3)
screen = ChatScreen(
Conversation(label="Board", is_channel=False, contact=room),
[ChatMessage(text=body, peer="abababababab", created_at=_at(_T))],
send=None,
names={},
session=_Session(None),
)
assert len(screen._messages) == 1
screen.handle("up")
assert not screen._can_react()
def test_the_messages_list_previews_a_reaction_in_words() -> None:
"""The latest message of a conversation can be a reaction. Its preview says what it is."""
from meshterm.tools.chat import _preview_text
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi", _T)
preview = _preview_text(_channel(react), lambda name: None).plain
assert "Bob" in preview and "reacted 👍" in preview and "@[" not in preview
ours = ChatMessage(text=reactions.direct_text("😂", "hi", _T), outbound=True)
assert "reacted 😂" in _preview_text(ours, lambda name: None).plain
def test_a_reaction_from_a_node_with_a_long_name_folds() -> None:
"""A node name can have 31 characters. A sender of 22 is still split off its reaction.
The two messages are from the hardware log that found the fault: the name limit was 20,
so the reaction of ``Hazelwood r0b3.Anduril`` showed as a new message.
"""
fold = ReactionFold(channel=True)
target = _channel("Hazelwood: ⚽ Union 2-1 at Chicago Fire (59')", 1_792_029_645)
fold.take(target)
react = _channel("Hazelwood r0b3.Anduril: @[Hazelwood]🔥\nmzjsjjpk", 1_792_029_654)
assert fold.take(react) is True
(badge,) = fold.badges(target)
assert badge.emoji == "🔥" and badge.reactors == [reactions.Reactor("Hazelwood r0b3.Anduril")]
async def test_the_reaction_dialog_searches_every_emoji_by_name() -> None:
"""The preferred emoji come first, then each other emoji. A typed name filters them all."""
from meshterm.ui.chat import emoji_catalog
from meshterm.ui.tui import Choice, Separator
session = _Session(None)
screen = _screen([_channel("Alice: hi all")], session, [])
screen.handle("up")
screen.handle("react")
await asyncio.sleep(0)
await asyncio.gather(*screen._tasks)
assert session.filterable is True
values = [item.value for item in session.items if isinstance(item, Choice)]
assert values[:6] == reactions.DEFAULT_REACTIONS.split()
assert "🔥" in values and values.count("👍") == 1 # a preferred emoji is listed once
assert isinstance(session.items[0], Separator) # "Your reactions", then "All emoji"
assert sum(isinstance(item, Separator) for item in session.items) == 2
fire = next(i for i in session.items if isinstance(i, Choice) and i.value == "🔥")
assert "fire" in fire.label.plain
assert len(emoji_catalog()) > 1000
assert all(reactions.is_reaction_emoji(emoji) for emoji in emoji_catalog())
+19
View File
@@ -20,6 +20,25 @@ def repo(tmp_path: Path) -> Repository:
r.close()
def test_node_names_with_times_gives_each_latest_name_and_when_it_was_heard(
repo: Repository,
) -> None:
"""One scan gives the names and their times. ``node_names`` is the first half of it."""
from meshterm.core.models import Observation
run = repo.start_run("monitor", {})
early = datetime(2026, 9, 20, 12, 0, tzinfo=timezone.utc)
late = datetime(2026, 10, 10, 12, 0, tzinfo=timezone.utc)
repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog", observed_at=early))
repo.record_observation(run, Observation(node="aa11bb22cc33", name="Smog 2", observed_at=late))
repo.record_observation(run, Observation(node="dd44ee55ff66", name="Bob", observed_at=early))
names, times = repo.node_names_with_times()
assert names == {"aa11bb22cc33": "Smog 2", "dd44ee55ff66": "Bob"}
assert times == {"aa11bb22cc33": late, "dd44ee55ff66": early}
assert repo.node_names() == names
def _make_trace(
target: str,
*hops: tuple[str | None, float],
+75 -10
View File
@@ -696,6 +696,8 @@ def _view(
access: RoomAccess | None = RoomAccess.MEMBER,
joined: bool = True,
login: RoomLogin | None = None,
locator: object = None,
pages: object = None,
) -> tuple[RoomScreen, dict]:
"""A board with stub hooks. ``calls`` records the requests to the hooks."""
calls: dict = {"auto": 0, "joins": [], "sent": []}
@@ -726,6 +728,8 @@ def _view(
join=join,
joined=joined,
access=access,
locator=locator, # type: ignore[arg-type]
pages=pages, # type: ignore[arg-type]
)
return screen, calls
@@ -806,7 +810,7 @@ async def test_after_silence_nothing_can_be_posted_and_the_line_says_why() -> No
screen.begin_auto_login()
await _settled(screen)
assert screen.title.endswith(NO_REPLY)
assert "no answer" in _compose(screen) and "^L" in _compose(screen)
assert "no answer" in _compose(screen) and "^G" in _compose(screen)
for ch in "hi":
screen.handle("text", ch)
screen.handle("enter")
@@ -816,18 +820,18 @@ async def test_after_silence_nothing_can_be_posted_and_the_line_says_why() -> No
async def test_control_l_runs_the_explained_login() -> None:
"""^L gives the login to the Rooms flow, with its dialogs over the board, and shows the end."""
"""^G gives the login to the Rooms flow, with its dialogs over the board, and shows the end."""
screen, calls = _view(access=None, login=RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN))
screen.handle("login")
assert screen._login_open
assert "^L log in" not in screen.footer_hint # a second press does nothing
assert "^G log in" not in screen.footer_hint # a second press does nothing
await _settled(screen)
assert calls["joins"] == [False]
assert screen.title == f"{ROOM.name} · admin" and screen._composing
async def test_a_read_only_member_is_asked_for_another_password_and_offered_no_retry() -> None:
"""^L asks, because the room password lets a read-only member post. ^R is only dropped."""
"""^G asks, because the room password lets a read-only member post. ^R is only dropped."""
unacked = ChatMessage(text="lost", outbound=True, peer=ROOM.key_prefix, acked=False)
screen, calls = _view([unacked], access=RoomAccess.READ_ONLY)
assert "read-only" in _compose(screen)
@@ -876,20 +880,81 @@ async def test_a_read_only_member_picks_a_post_to_see_its_paths_not_to_reply() -
def test_the_hint_names_log_in_and_fits() -> None:
"""The hint names ^L where it acts, in 72 cells, with or without a selected post."""
"""The hint names ^G where it acts, in 72 cells, with or without a selected post."""
screen, _ = _view(_board())
assert "^L log in" in screen.footer_hint
assert "^G log in" in screen.footer_hint
assert cell_len(screen.footer_hint) <= 72
screen.handle("up")
assert cell_len(screen.footer_hint) <= 72
def test_the_handheld_lane_puts_log_in_behind_f1() -> None:
"""Each chord has a chip where the keyboard has no Ctrl key to reach it."""
def test_the_handheld_lane_puts_log_in_behind_f2() -> None:
"""Each chord has a chip where the keyboard has no Ctrl key to reach it.
Log in is a verb of the room only, so it is in the Shift bank, next to Retry.
"""
screen, _ = _view(_board())
lane = screen.picocalc_lyra_lane
assert lane[0].opp_label == "Log in"
assert fkeys.PICOCALC_LYRA_DECK.action_for(lane, 6) == "login"
assert lane[1].opp_label == "Log in"
assert fkeys.PICOCALC_LYRA_DECK.action_for(lane, 7) == "login"
assert lane[0].opp_label == "Retry"
# The plain bank of F1 to F3 is the one of each conversation (refer to test_chat).
assert [pair.label for pair in lane[:2]] == ["Paths", "URL→QR"]
async def test_o_opens_the_node_page_of_the_author_of_a_post() -> None:
"""^O opens the page of the contact that signed the selected post, by its key prefix."""
from tests.conftest import recording_pages
alice = Contact(name="Alice", public_key=ALICE_KEY + "0" * 56, key_prefix=ALICE_KEY + "0000")
pages = recording_pages([alice])
screen, _ = _view(_board(), pages=pages)
for _ in range(4):
screen.handle("up") # the oldest post, by Alice
assert "^O author" in screen.footer_hint
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == [alice]
# No contact has the key of the stranger, so ^O does nothing, and the hint does not name it.
screen.handle("down")
assert "^O" not in screen.footer_hint
screen.handle("author_page")
await asyncio.sleep(0)
assert pages.shown == [alice]
assert screen.picocalc_lyra_lane[2].opp_label == "Author"
def test_the_login_key_is_g_and_o_opens_the_author_page() -> None:
"""^G logs in to a room, and ^O opens the page of the author of a message (JP, 2026-10-11)."""
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS
assert _CTRL_LETTER_CHORDS["g"] == "login"
assert _CTRL_LETTER_CHORDS["o"] == "author_page"
async def test_l_opens_the_map_on_the_author_of_a_post() -> None:
"""^L opens the map on the author of the selected post. ^G keeps the login."""
from meshterm.core.models import MapMarker
from tests.conftest import recording_locator
alice = MapMarker(label="Alice", lat=45.5, lon=-73.6, key=ALICE_KEY + "0" * 56)
locator = recording_locator([alice])
screen, _ = _view(_board(), locator=locator)
for _ in range(4):
screen.handle("up") # the oldest post, by Alice
assert "^L map" in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [alice]
# No marker places the stranger, so ^L does nothing, and the hint does not name it.
screen.handle("down")
assert "^L" not in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [alice]
# Map is on F3, as in each conversation, and Log in is on the Shift half of F2.
assert screen.picocalc_lyra_lane[2].label == "Map"
assert screen.picocalc_lyra_lane[1].opp_label == "Log in"
# --- joining: the explained flow ------------------------------------------------------
+11 -9
View File
@@ -71,12 +71,14 @@ class _TracingDevice(MockDevice):
await super().set_flood_scope(region)
self.trail.append(("scope", key))
async def send_channel_message(self, index: int, text: str) -> None:
await super().send_channel_message(index, text)
async def send_channel_message(
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
await super().send_channel_message(index, text, timestamp=timestamp)
self.trail.append(("channel", text, self._send_scope))
async def send_direct_message(self, contact, text): # noqa: ANN001, ANN201
ack = await super().send_direct_message(contact, text)
async def send_direct_message(self, contact, text, **sent): # noqa: ANN001, ANN003, ANN201
ack = await super().send_direct_message(contact, text, **sent)
self.trail.append(("dm", text, self._send_scope))
return ack
@@ -148,10 +150,10 @@ async def test_a_dm_cannot_interleave_into_the_window() -> None:
release = asyncio.Event()
original = device.send_channel_message
async def slow_send(index: int, text: str) -> None:
async def slow_send(index: int, text: str, **sent) -> None: # noqa: ANN003
entered.set()
await release.wait()
await original(index, text)
await original(index, text, **sent)
device.send_channel_message = slow_send # type: ignore[method-assign]
scoped = asyncio.ensure_future(device.send_channel_in_scope(0, "scoped", "lakeside"))
@@ -186,7 +188,7 @@ async def test_a_send_that_fails_still_restores_the_scope() -> None:
"""If the channel send raises an error, the session scope must not stay on its region."""
device = _TracingDevice()
async def boom(index: int, text: str) -> None:
async def boom(index: int, text: str, **sent) -> None: # noqa: ANN003
raise DeviceCommandError("rejected")
device.send_channel_message = boom # type: ignore[method-assign]
@@ -426,7 +428,7 @@ async def test_resend_unscoped_targets_only_a_newest_scoped_message() -> None:
assert screen._retry_target() is scoped
assert "^R resend unscoped" in screen.footer_hint
lane = screen.picocalc_lyra_lane
assert lane[2].opp_label == "Resend" and lane[2].opp_enabled
assert lane[0].opp_label == "Resend" and lane[0].opp_enabled # Shift+F1, the ^R of each chat
screen.handle("retry")
await asyncio.sleep(0.05)
@@ -498,7 +500,7 @@ def test_a_channel_with_nothing_to_resend_dims_the_chip() -> None:
screen = _channel_chat(_Session(), [unscoped], resend=resend)
lane = screen.picocalc_lyra_lane
assert lane[2].opp_label == "Resend" and not lane[2].opp_enabled
assert lane[0].opp_label == "Resend" and not lane[0].opp_enabled
assert fkeys.PICOCALC_LYRA_DECK.lane_text(lane, shifted=True).plain
+3 -1
View File
@@ -10,6 +10,8 @@ identifier. Thus this gate makes sure that no file has no marking.
from pathlib import Path
from tests.conftest import not_ignored
def test_spdx_headers():
"""Each Python file must have an SPDX-License-Identifier header."""
@@ -33,7 +35,7 @@ def test_spdx_headers():
python_files.append(spi_bridge)
missing_spdx = []
for filepath in sorted(python_files):
for filepath in not_ignored(sorted(python_files)):
# A file that MeshTerm cannot read as UTF-8 is a finding. The test must not skip it.
# Each source file here is UTF-8, and a skip with no message lets a file with no
# marking pass.
+1 -1
View File
@@ -373,7 +373,7 @@ def test_walk_trail_names_carry_their_node_hues() -> None:
def test_walk_locate_refocuses_us_and_home_end_walk_the_list() -> None:
"""^Y resets the walk to our own node from anywhere. Home and End go to the ends of the list."""
"""^L resets the walk to our own node from anywhere. Home and End go to the ends of the list."""
# A hub with spokes from one of our neighbours: a focus away from us, with a list to move in.
topo = MeshTopology(US, contacts=[Hub])
hub = topo.canonical(Hub.public_key)
+140 -2
View File
@@ -13,19 +13,25 @@ from rich.text import Text
from meshterm.core.models import node_type_label
from meshterm.ui.pathline import (
PATH_INK,
POWERLINE_ROUND_CLOSE,
POWERLINE_ROUND_OPEN,
POWERLINE_SEP,
PathHop,
PathLine,
_chip_fill,
)
from meshterm.ui.theme import node_style
from meshterm.ui.widgets import (
DESTINATION,
ORIGIN,
format_age,
format_ago,
hop_chip,
name_chip,
path_text,
revisit_note,
route_ends,
)
@@ -156,12 +162,12 @@ def test_path_text_dims_the_tail_from_dim_from() -> None:
def test_make_name_key_resolver_contacts_win_over_stored_names() -> None:
"""Contacts resolve first, stored advert names resolve strangers, and misses are None."""
"""Contacts give the canonical key, stored advert names resolve strangers, misses are None."""
from meshterm.core.models import Contact
from meshterm.services.trace_runner import make_name_key_resolver
contacts = [Contact(name="Alice", public_key="d4" + "0" * 62)]
stored = {"60aabbccdd11": "Bob", "77ee00112233": "Alice"} # an old stored Alice
stored = {"60aabbccdd11": "Bob", "d40000000000": "Alice"} # Alice's own stored id
key_of = make_name_key_resolver(contacts, stored)
assert key_of("Alice") == "d4" + "0" * 62 # the key of the contact, not the stored id
assert key_of("alice") == "d4" + "0" * 62 # case-folded
@@ -169,6 +175,84 @@ def test_make_name_key_resolver_contacts_win_over_stored_names() -> None:
assert key_of("Zed") is None # no node has the name
def test_a_name_that_two_keys_use_at_the_same_time_resolves_to_no_key() -> None:
"""Two nodes that used one name within the window: MeshTerm cannot tell them apart.
A channel message carries only the name of its sender. A second key that takes a name
while the first is still in use must not get the hue, the map, and the page of either.
"""
from datetime import datetime, timedelta, timezone
from meshterm.core.models import Contact
from meshterm.services.trace_runner import make_name_key_resolver
now = datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc)
old = Contact(name="LOG.IC 3R", public_key="5d" + "0" * 62, last_seen=now - timedelta(days=3))
new = Contact(name="LOG.IC 3R", public_key="47" + "0" * 62, last_seen=now)
assert make_name_key_resolver([old, new], overlap_days=7)("LOG.IC 3R") is None
assert make_name_key_resolver([new, old], overlap_days=7)("log.ic 3r") is None # any order
# A stranger that we only heard is a claim too, and it makes the name of a contact unsure.
alice = Contact(name="Alice", public_key="d4" + "0" * 62, last_seen=now)
stranger = {"77ee00112233": "Alice"}
heard = {"77ee00112233": now - timedelta(hours=1)}
assert make_name_key_resolver([alice], stranger, heard=heard, overlap_days=7)("Alice") is None
assert make_name_key_resolver([alice], overlap_days=7)("Alice") == "d4" + "0" * 62
def test_a_name_goes_to_the_newest_key_when_the_old_one_went_quiet() -> None:
"""A new key for a name is usual: a reflash makes a new identity (JP, 2026-10-11).
An old key that has been silent for longer than the window is the earlier identity of
the owner. It does not make the name unsure, and the newest key gets the name.
"""
from datetime import datetime, timedelta, timezone
from meshterm.core.models import Contact
from meshterm.services.trace_runner import make_name_key_resolver
now = datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc)
smog = Contact(name="Smog", public_key="fa" + "0" * 62, last_seen=now - timedelta(days=1))
september = {"def5e1786fff": "Smog", "caae7a09503d": "Smog"}
heard = {"def5e1786fff": now - timedelta(days=21), "caae7a09503d": now - timedelta(days=19)}
key_of = make_name_key_resolver([smog], september, heard=heard, overlap_days=7)
assert key_of("Smog") == "fa" + "0" * 62
# A wider window reaches the old keys again, and the name is unsure.
wide = make_name_key_resolver([smog], september, heard=heard, overlap_days=30)
assert wide("Smog") is None
# The window is measured from the newest key, whichever list it is in.
renamed = make_name_key_resolver(
[smog], {"0123456789ab": "Smog"}, heard={"0123456789ab": now}, overlap_days=0
)
assert renamed("Smog") == "0123456789ab"
def test_a_key_with_no_time_never_wins_and_no_times_at_all_is_unsure() -> None:
"""A contact that was never heard is not in use. With no time at all, nothing decides."""
from datetime import datetime, timezone
from meshterm.core.models import Contact
from meshterm.services.trace_runner import make_name_key_resolver
heard = Contact(
name="Bob", public_key="b0" + "0" * 62, last_seen=datetime(2026, 10, 1, tzinfo=timezone.utc)
)
never = Contact(name="Bob", public_key="c0" + "0" * 62)
assert make_name_key_resolver([never, heard], overlap_days=7)("Bob") == "b0" + "0" * 62
other = Contact(name="Bob", public_key="d0" + "0" * 62)
assert make_name_key_resolver([never, other], overlap_days=7)("Bob") is None
def test_the_window_is_the_name_overlap_preference() -> None:
"""With no window given, the resolver reads the preference: a value that a user can change."""
from meshterm.core.models import NAME_OVERLAP_DAYS
from meshterm.core.preferences import current
assert current().name_overlap_days == NAME_OVERLAP_DAYS == 7
def test_name_style_without_a_key_is_the_unknown_grey() -> None:
"""A keyless name has no hue, because colour is only for identities that have a key."""
from meshterm.ui.theme import name_style, node_style
@@ -272,6 +356,60 @@ def test_name_chip_greys_a_node_it_cannot_place(powerline) -> None:
assert _span_style(keyless, "·").bgcolor.name == "#64748b" # the keyless grey
def test_name_chip_of_an_origin_closes_on_the_point(powerline) -> None:
"""The author of a message is where a route starts, and the route goes on from it."""
powerline(True)
chip = name_chip("Alice", "a1b2", role=ORIGIN)
hop = PathLine([PathHop("Alice", key="a1b2")], to_destination=False).text()
assert chip.plain == POWERLINE_ROUND_OPEN + " Alice " + POWERLINE_SEP
assert [(s.start, s.end, str(s.style)) for s in chip.spans] == [
(s.start, s.end, str(s.style)) for s in hop.spans
]
def test_name_chip_of_a_destination_opens_on_the_notch(powerline) -> None:
"""The node that a mention names is where a route ends, and the route came to it."""
powerline(True)
chip = name_chip("@Bob", "c3d4", role=DESTINATION)
assert chip.plain == POWERLINE_SEP + " @Bob " + POWERLINE_ROUND_CLOSE
assert _span_style(chip, POWERLINE_SEP).reverse # the notch, cut out of the fill
def test_name_chip_role_changes_nothing_without_powerline(powerline) -> None:
"""In arrow mode, a lone name has no ends to shape, and a stray arrow reads as a cut route."""
powerline(False)
for role in (ORIGIN, DESTINATION):
assert name_chip("Alice", "a1b2", role=role).plain == "Alice"
def test_a_lone_hop_in_arrow_mode_is_a_name_and_not_a_route(powerline) -> None:
"""Only a route keeps its hues on a highlighted row. A lone name turns white like any name."""
powerline(False)
chip = hop_chip(PathHop("Alice", key="a1b2"), role=ORIGIN)
assert str(chip.style) != PATH_INK
assert all(str(s.style) != PATH_INK for s in chip.spans)
def test_route_ends_face_each_other_across_one_cell_of_page(powerline) -> None:
"""The middle is not drawn, so no seam joins the two ends. A seam makes them neighbours."""
powerline(True)
line = route_ends(PathHop("Alice", key="a1"), PathHop("Bob", key="c3"))
opening = POWERLINE_ROUND_OPEN + " Alice " + POWERLINE_SEP
assert line.plain == opening + " " + POWERLINE_SEP + " Bob " + POWERLINE_ROUND_CLOSE
def test_route_ends_are_the_arrow_of_the_app_without_powerline(powerline) -> None:
"""A terminal with no chips reads the two ends as it always did."""
powerline(False)
assert route_ends(PathHop("Alice", key="a1"), PathHop("Bob", key="c3")).plain == "Alice → Bob"
def test_name_chip_falls_back_to_the_bare_name_without_powerline(powerline) -> None:
"""If there is no separator glyph to draw a chip, the chip is the bare name.
+4 -2
View File
@@ -20,6 +20,8 @@ import re
import tokenize
from pathlib import Path
from tests.conftest import not_ignored
ROOT = Path(__file__).resolve().parent.parent
#: The folders whose Python files are examined.
@@ -81,7 +83,7 @@ def _blank_quotes(text: str) -> str:
def _python_files() -> list[Path]:
files = [p for d in PYTHON_DIRS for p in (ROOT / d).rglob("*.py")]
files += [ROOT / p for p in PYTHON_EXTRA]
return sorted(p for p in files if p.exists() and "__pycache__" not in p.parts)
return not_ignored(sorted(p for p in files if p.exists() and "__pycache__" not in p.parts))
def _python_prose(path: Path) -> list[tuple[int, str]]:
@@ -125,7 +127,7 @@ def _documents() -> list[Path]:
for name in DOCUMENTS:
path = ROOT / name
files += sorted(path.rglob("*.md")) if path.is_dir() else [path]
return files
return not_ignored(files)
def _markdown_prose(path: Path) -> list[tuple[int, str]]: