Compare commits
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -255,6 +255,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
@@ -1995,7 +2001,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:
|
||||
@@ -4555,7 +4566,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()
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -54,6 +54,7 @@ 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 .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -488,6 +489,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",
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]))
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.pathline import SELF_GLYPH, PathHop
|
||||
from ..ui.termfont import powerline_enabled
|
||||
from ..ui.theme import 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,14 +1031,16 @@ 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
|
||||
@@ -1037,13 +1050,13 @@ def _preview_text(
|
||||
body_raw = 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,48 @@ 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 _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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ import re
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
@@ -31,8 +32,9 @@ 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 snr_style
|
||||
from .tui.prompt import (
|
||||
CHANNEL_BYTE_LIMIT,
|
||||
DM_BYTE_LIMIT,
|
||||
@@ -42,10 +44,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:
|
||||
@@ -77,6 +82,25 @@ def _bind(platform: Platform) -> None:
|
||||
_URL_CODES = platform.url_codes
|
||||
|
||||
|
||||
#: 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):
|
||||
"""A live conversation: a transcript that scrolls, above a pinned input line.
|
||||
|
||||
@@ -88,7 +112,9 @@ 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. When no message is selected, Esc leaves the
|
||||
chat.
|
||||
|
||||
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 +130,81 @@ 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.
|
||||
``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.
|
||||
|
||||
``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.
|
||||
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,
|
||||
)
|
||||
lane[1] = FPair("URL→QR", "url_code", enabled=bool(self._picked_urls()))
|
||||
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 +220,8 @@ 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,
|
||||
) -> None:
|
||||
"""Build the chat screen.
|
||||
|
||||
@@ -224,6 +253,12 @@ 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.
|
||||
"""
|
||||
super().__init__()
|
||||
self._scope = scope if conversation.is_channel else None
|
||||
@@ -251,6 +286,8 @@ class ChatScreen(Screen):
|
||||
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,6 +300,8 @@ 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
|
||||
@@ -299,23 +338,61 @@ 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 ""
|
||||
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,
|
||||
"^R resend unscoped",
|
||||
"↑↓ pick",
|
||||
"Esc cancel",
|
||||
],
|
||||
spare=("↑↓ pick", "^P paths", "^O author"),
|
||||
)
|
||||
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,
|
||||
"↑↓ pick",
|
||||
"^End/Esc cancel",
|
||||
],
|
||||
spare=("↑↓ pick",),
|
||||
)
|
||||
if cell_len(hint) <= _HINT_CELLS:
|
||||
return hint
|
||||
return _fit_hint(
|
||||
["Enter reply", "^P paths", code, where, who, "↑↓ pick", "Esc cancel"],
|
||||
spare=("↑↓ pick",),
|
||||
)
|
||||
return _fit_hint(
|
||||
["Enter paths", code, where, who, "↑↓ pick", "^End/Esc cancel"],
|
||||
spare=("↑↓ pick",),
|
||||
)
|
||||
if self._retry_target() is not None:
|
||||
if self._is_channel:
|
||||
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
|
||||
@@ -604,14 +681,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,6 +733,8 @@ 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
|
||||
)
|
||||
@@ -693,13 +777,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 +798,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 +835,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 +872,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, and ^L the map on its author. The
|
||||
three 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 +891,10 @@ 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 == "retry":
|
||||
if self._is_channel:
|
||||
self._begin_resend_unscoped()
|
||||
@@ -879,6 +954,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:
|
||||
@@ -1314,7 +1468,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,6 +1500,9 @@ 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)
|
||||
screen = ChatScreen(
|
||||
conversation,
|
||||
@@ -1357,6 +1515,8 @@ 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),
|
||||
)
|
||||
ctx.chat.set_active(conversation.key)
|
||||
|
||||
@@ -1568,10 +1728,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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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 = ("^", "⇧", "⌫", "↑", "↓", "←", "→")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,14 @@ 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",
|
||||
# ^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 +120,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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))"
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -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())
|
||||
@@ -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 を長押しすると終了します。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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)"
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2298,29 +2355,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 +2373,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 +2611,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 +2645,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)
|
||||
@@ -2744,3 +2831,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
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -795,6 +795,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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],
|
||||
|
||||
@@ -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 ------------------------------------------------------
|
||||
|
||||
@@ -426,7 +426,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 +498,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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||