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 |
|
||||
|
||||
@@ -194,6 +194,7 @@ and in quoted text.
|
||||
| packet | One radio transmission | frame (for radio data) |
|
||||
| protocol frame | One unit of the companion protocol on USB, Bluetooth, or TCP: a command, a reply, or a push from the companion. In code that handles only this protocol, "frame" alone is permitted | packet (for this) |
|
||||
| message | A text message, direct or on a channel | |
|
||||
| reaction | An emoji that a node sends to answer one message. It is a message on the wire, and the chat shows it under the message that it names | |
|
||||
| send | MeshTerm gives a message, a packet, or a command to the device | emit |
|
||||
| transmit | A radio puts a packet on the air | |
|
||||
| broadcast | Transmit to all nodes, with no destination | |
|
||||
@@ -255,6 +256,7 @@ and in quoted text.
|
||||
| visible | On the screen | in view |
|
||||
| viewport | The part of a page, or of the map, that the screen shows. A page that is taller than its viewport scrolls | view, window (for this) |
|
||||
| list window | The visible rows of a list that scrolls inside a screen (`ListWindow`) | window (alone) |
|
||||
| locate | Open the map on a node, or move the map to our node (the `locate` action, ^L) | find (for this) |
|
||||
|
||||
### The program
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ This page lists each interactive screen with its scripted equivalent, where one
|
||||
|
||||
| Feature | What it does | Scripted |
|
||||
| --- | --- | --- |
|
||||
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
|
||||
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. Select a message and press **^E** to react to it with an emoji. The reaction shows under the message, and MeshCore One and meshcore-open show it too. The emoji that ^E offers are a preference. MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
|
||||
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels. You can share a channel with a QR code or a `meshcore://` link. If you give a channel a **send scope**, its messages are flooded into one region. Only the repeaters that carry that region relay them. When you change to a new device, export all the channels to a file, then import the file on the new device. The **channel guide** lists the public channels that MeshTerm heard, also the channels that are not on your device, and adds one with Enter. | `meshterm channels list / add / join / import / share / clear / scope / export / guide` |
|
||||
| **📌 Rooms** | Each room server that your radio knows. A room server is a message board on a radio. It keeps the latest posts and sends you the posts that you missed. Join a room with its password. If a room does not answer, MeshTerm tells you what it knows and offers the next step to try. For example, it can send again by flood when the route that your radio learned is no longer good. | `meshterm rooms list / join / forget` |
|
||||
| **📨 Courier** | A store-and-forward outbox for contacts that you cannot reach yet. Queue a message. MeshTerm sends it when the contact is next heard, or at a time that you set. It tracks the acks, and it waits longer between each try (exponential backoff). | `meshterm courier queue / list / send / cancel / clear` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,17 +35,21 @@ from urllib.parse import parse_qs, quote, urlsplit
|
||||
# not one time for each candidate channel. Thus the reception path pays almost nothing.
|
||||
|
||||
#: Matches the ``Name: message`` convention that channel senders use to identify themselves
|
||||
#: (the protocol has no sender field). The name is 1–20 characters that are not colons, and
|
||||
#: (the protocol has no sender field). The name is 1–31 characters that are not colons, and
|
||||
#: ``": "`` must follow it. This rule is strict enough that it does not match ``http://…``
|
||||
#: or ``note:x``.
|
||||
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
|
||||
#: or ``note:x``. The firmware keeps a node name in 32 bytes with its terminator, so 31 is
|
||||
#: the longest name that a sender can have. A limit of 20 left
|
||||
#: a longer name in the body. Then the transcript showed no sender for it, and a reaction
|
||||
#: from it did not read as a reaction.
|
||||
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,30}):[ \t]+(.*)$", re.DOTALL)
|
||||
|
||||
#: Matches an ``@[Name]`` mention token, which the reply flow puts into the compose line in
|
||||
#: advance. A transcript renders each token as a bare ``@Name`` in the hue of that sender,
|
||||
#: and does not show the brackets. The history face of the CLI also takes out the same
|
||||
#: names. Thus the token is defined here, with the remainder of the message-body parsing, and
|
||||
#: not in one of the two renderers. The name is 1–20 characters that are not ``]``.
|
||||
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
|
||||
#: not in one of the two renderers. The name is 1–31 characters that are not ``]``, the
|
||||
#: same limit as :data:`SENDER_PREFIX`.
|
||||
MENTION = re.compile(r"@\[([^\]]{1,31})\]")
|
||||
|
||||
|
||||
def split_channel_sender(text: str) -> tuple[str | None, str]:
|
||||
|
||||
@@ -28,7 +28,7 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcon
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from . import transmit_gate
|
||||
from .channels import CHANNEL_SLOT_PROBE_CAP
|
||||
@@ -1216,6 +1216,12 @@ class Device(ABC):
|
||||
events into the domain models in :mod:`meshterm.core.models`.
|
||||
"""
|
||||
|
||||
#: Whether an imported private key takes effect only when the node starts again. A
|
||||
#: firmware companion uses the new key at once. The node that MeshTerm runs on an SPI
|
||||
#: radio reads its key only when it starts (refer to
|
||||
#: :meth:`meshterm.core.spiradio.SpiRadioDevice.import_private_key`).
|
||||
import_restarts: ClassVar[bool] = False
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
"""Open the connection to the device. Idempotent."""
|
||||
@@ -1551,12 +1557,21 @@ class Device(ABC):
|
||||
# -- messaging ---------------------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
async def send_direct_message(self, contact: Contact, text: str) -> Delivery:
|
||||
async def send_direct_message(
|
||||
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
|
||||
) -> Delivery:
|
||||
"""Send a direct text message to a contact, and wait some time for its ack.
|
||||
|
||||
Args:
|
||||
contact: The recipient. Its ``public_key`` addresses the message.
|
||||
text: The message body.
|
||||
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
|
||||
the clock of this computer. The recipient sees this value, and a reaction
|
||||
names the message by it (refer to :mod:`~meshterm.core.reactions`). Thus the
|
||||
caller gives it when it must know the value.
|
||||
attempt: The number of the try (0 to 3). A retry of the same message has the
|
||||
same timestamp and the next number, as the protocol specifies. The number
|
||||
makes the packet of the retry different from the first one.
|
||||
|
||||
Returns:
|
||||
The :class:`~meshterm.core.models.Delivery`: the ack code that the radio
|
||||
@@ -1568,12 +1583,16 @@ class Device(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def send_channel_message(self, index: int, text: str) -> None:
|
||||
async def send_channel_message(
|
||||
self, index: int, text: str, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
"""Broadcast a text message on a channel slot.
|
||||
|
||||
Args:
|
||||
index: The zero-based channel slot on which to transmit.
|
||||
text: The message body.
|
||||
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
|
||||
the clock of this computer (refer to :meth:`send_direct_message`).
|
||||
|
||||
Raises:
|
||||
DeviceCommandError: If the companion rejected the send.
|
||||
@@ -1625,7 +1644,9 @@ class Device(ABC):
|
||||
self._scope_leaked = None
|
||||
yield
|
||||
|
||||
async def send_channel_in_scope(self, index: int, text: str, scope: str | None) -> None:
|
||||
async def send_channel_in_scope(
|
||||
self, index: int, text: str, scope: str | None, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
"""Broadcast on a channel in a given scope, and leave the session scope as it was.
|
||||
|
||||
This is the only way that MeshTerm sends a channel message. ``None`` is the plain
|
||||
@@ -1653,6 +1674,8 @@ class Device(ABC):
|
||||
text: The message body.
|
||||
scope: A region name, :data:`~meshterm.core.regions.WILDCARD` for unscoped, or
|
||||
``None`` for the default of the device.
|
||||
timestamp: The timestamp that the message carries (refer to
|
||||
:meth:`send_channel_message`).
|
||||
|
||||
Raises:
|
||||
FloodScopeError: If the companion cannot send in ``scope``. Nothing was sent.
|
||||
@@ -1665,10 +1688,10 @@ class Device(ABC):
|
||||
bare = validate_region(bare)
|
||||
async with self.transmitting():
|
||||
if not bare:
|
||||
await self.send_channel_message(index, text)
|
||||
await self.send_channel_message(index, text, timestamp=timestamp)
|
||||
return
|
||||
if bare == REGION_WILDCARD and await self._unscoped_is_plain():
|
||||
await self.send_channel_message(index, text)
|
||||
await self.send_channel_message(index, text, timestamp=timestamp)
|
||||
return
|
||||
try:
|
||||
await self.set_flood_scope(bare)
|
||||
@@ -1676,7 +1699,7 @@ class Device(ABC):
|
||||
raise FloodScopeError(bare, _scope_refusal(bare, exc)) from exc
|
||||
self._scope_active = True
|
||||
try:
|
||||
await self.send_channel_message(index, text)
|
||||
await self.send_channel_message(index, text, timestamp=timestamp)
|
||||
finally:
|
||||
self._scope_active = False
|
||||
try:
|
||||
@@ -1995,7 +2018,12 @@ class Device(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def import_private_key(self, key_hex: str) -> None:
|
||||
"""Import a private key from a hex string (this replaces the device identity)."""
|
||||
"""Import a private key, and replace the identity of the device with it.
|
||||
|
||||
Args:
|
||||
key_hex: The 64-byte key in hex, as :func:`meshterm.core.privatekey.normalize`
|
||||
makes it. A caller that has a seed expands it first.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def factory_reset(self) -> None:
|
||||
@@ -4106,7 +4134,7 @@ class MeshCoreDevice(Device):
|
||||
return stop_pump
|
||||
|
||||
async def send_direct_message( # noqa: D102 - inherited docstring
|
||||
self, contact: Contact, text: str
|
||||
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
|
||||
) -> Delivery:
|
||||
transmit_gate.mark()
|
||||
from meshcore import EventType
|
||||
@@ -4135,7 +4163,9 @@ class MeshCoreDevice(Device):
|
||||
try:
|
||||
# Only the hand-over holds the transmit lock. The ack wait below is not a send.
|
||||
async with self.transmitting():
|
||||
result = await mc.commands.send_msg(pub, text)
|
||||
result = await mc.commands.send_msg(
|
||||
pub, text, timestamp=timestamp, attempt=attempt & 3
|
||||
)
|
||||
if result is None or getattr(result, "is_error", lambda: False)():
|
||||
# A recipient for which the firmware has no entry is the only rejection with
|
||||
# an obvious repair. Thus it gets its own class, so the chat screen can offer
|
||||
@@ -4191,11 +4221,11 @@ class MeshCoreDevice(Device):
|
||||
return Delivery(code[0], ack, flood)
|
||||
|
||||
async def send_channel_message( # noqa: D102 - inherited docstring
|
||||
self, index: int, text: str
|
||||
self, index: int, text: str, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
transmit_gate.mark()
|
||||
async with self.transmitting():
|
||||
self._ok(await self._require().commands.send_chan_msg(index, text))
|
||||
self._ok(await self._require().commands.send_chan_msg(index, text, timestamp))
|
||||
|
||||
@staticmethod
|
||||
def _ok(event): # type: ignore[no-untyped-def]
|
||||
@@ -4555,7 +4585,19 @@ class MeshCoreDevice(Device):
|
||||
return key.hex() if isinstance(key, (bytes, bytearray)) else str(key)
|
||||
|
||||
async def import_private_key(self, key_hex: str) -> None: # noqa: D102
|
||||
self._ok(await self._require().commands.import_private_key(bytes.fromhex(key_hex)))
|
||||
from meshcore import EventType
|
||||
|
||||
# The firmware has a build flag that turns the import off. Then it answers "disabled",
|
||||
# and not with an error. The import command of the library waits only for "ok" or an
|
||||
# error, thus it waited for its full timeout and returned a refusal with no cause.
|
||||
# This call waits for the third answer too, and names it.
|
||||
event = await self._require().commands.send(
|
||||
b"\x18" + bytes.fromhex(key_hex),
|
||||
[EventType.OK, EventType.ERROR, EventType.DISABLED],
|
||||
)
|
||||
if getattr(event, "type", None) == EventType.DISABLED:
|
||||
raise DeviceCommandError("this device's firmware does not accept an imported key")
|
||||
self._ok(event)
|
||||
|
||||
async def factory_reset(self) -> None: # noqa: D102 - inherited docstring
|
||||
mc = self._require()
|
||||
@@ -4863,7 +4905,7 @@ class MockDevice(Device):
|
||||
self._tx_power = value
|
||||
|
||||
async def send_direct_message( # noqa: D102 - inherited docstring
|
||||
self, contact: Contact, text: str
|
||||
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
|
||||
) -> Ack | None:
|
||||
transmit_gate.mark()
|
||||
async with self.transmitting():
|
||||
@@ -4908,7 +4950,7 @@ class MockDevice(Device):
|
||||
task.add_done_callback(self._bg_tasks.discard)
|
||||
|
||||
async def send_channel_message( # noqa: D102 - inherited docstring
|
||||
self, index: int, text: str
|
||||
self, index: int, text: str, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
transmit_gate.mark()
|
||||
async with self.transmitting():
|
||||
|
||||
@@ -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,8 @@ from .advert_store import DEFAULT_QUIET_S, QUIET_CHOICES_S
|
||||
from .atomicwrite import write_atomically
|
||||
from .courier_store import DONE_CAP
|
||||
from .geo import DEFAULT_VIEW_FRACTION
|
||||
from .models import NAME_OVERLAP_DAYS
|
||||
from .reactions import DEFAULT_REACTIONS
|
||||
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -289,6 +291,16 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
minimum=0,
|
||||
maximum=2,
|
||||
),
|
||||
PrefSpec(
|
||||
key="reaction_emoji",
|
||||
label="Reactions",
|
||||
help="Emoji to offer when you react to a message",
|
||||
group="Sending",
|
||||
value_type="str",
|
||||
# The default set of MeshCore One. A word that is not one emoji is ignored when the
|
||||
# chat reads the list (refer to meshterm.core.reactions.reaction_set).
|
||||
default=DEFAULT_REACTIONS,
|
||||
),
|
||||
PrefSpec(
|
||||
key="room_relogin_minutes",
|
||||
label="Room re-login",
|
||||
@@ -488,6 +500,21 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
choices=_CONSOLE_FONT_CHOICES,
|
||||
platforms=frozenset({"picocalc-lyra"}),
|
||||
),
|
||||
PrefSpec(
|
||||
key="name_overlap_days",
|
||||
label="Shared names",
|
||||
help="Grey a name two nodes used within this many days",
|
||||
group="Display",
|
||||
value_type="int",
|
||||
# A channel message gives only the name of its sender. When two keys used that
|
||||
# name within this window, MeshTerm cannot know which one sent it, and the name
|
||||
# is grey. Outside the window, the key heard most recently gets the name. A larger
|
||||
# value makes more names grey. 0 always gives the name to the newest key.
|
||||
default=NAME_OVERLAP_DAYS,
|
||||
minimum=0,
|
||||
maximum=3650,
|
||||
unit="days",
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="log_level",
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -0,0 +1,538 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Emoji reactions to chat messages, in the wire format of MeshCore One.
|
||||
|
||||
A reaction is a usual text message. The firmware and the repeaters do not know the format.
|
||||
Only the apps do. MeshTerm writes the format of MeshCore One, and reads it and the two
|
||||
formats of meshcore-open. The protocol is in ``docs/Reactions.md`` of MeshCore One. In short:
|
||||
|
||||
* A reaction names its target message by a hash of the body of the target and the
|
||||
timestamp of its sender: SHA-256 of the UTF-8 body and the timestamp as a little-endian
|
||||
32-bit integer, the first five bytes, in Crockford Base32 (eight characters). Each node
|
||||
that received the target calculates the same hash, because the sender's timestamp travels
|
||||
with the message.
|
||||
* On a channel, the reaction also names the author of the target, because two nodes can
|
||||
send the same body in the same second: ``@[Author]👍`` then a newline and the hash.
|
||||
Before MeshCore One 1.4.1, the emoji came first (``👍@[Author]``). MeshTerm reads the two
|
||||
orders and writes the new one.
|
||||
* In a direct exchange, there are two persons only, so the reaction is ``👍``, a newline,
|
||||
and the hash.
|
||||
|
||||
A body that only starts with an emoji is a usual message. The emoji part must be exactly
|
||||
one emoji (one glyph) for the body to be a reaction.
|
||||
|
||||
MeshTerm stores a reaction as the usual message that it is. Thus the history keeps all that
|
||||
was received. The chat screen folds the reactions of a transcript onto their targets when it
|
||||
shows the transcript (refer to :class:`ReactionFold`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from .channels import split_channel_sender
|
||||
from .models import ChatMessage
|
||||
|
||||
#: The reactions that the reaction dialog offers when the preference is empty. They are the
|
||||
#: default set of MeshCore One, in its order.
|
||||
DEFAULT_REACTIONS = "👍 👎 ❤️ 😂 😮 😢"
|
||||
|
||||
#: How far apart in time, in seconds, a reaction of meshcore-open and its target can be. The
|
||||
#: short hash of meshcore-open (16 bits) matches many messages in a long history. Thus
|
||||
#: MeshTerm compares it only with the messages near the reaction in time. MeshCore One uses
|
||||
#: the same window.
|
||||
WINDOW_S = 300
|
||||
|
||||
#: The maximum number of reactions that wait for a target that did not arrive yet.
|
||||
PENDING_CAP = 100
|
||||
|
||||
# -- Crockford Base32 ---------------------------------------------------------------------
|
||||
|
||||
_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz"
|
||||
_DECODE = {char: char for char in _CROCKFORD}
|
||||
_DECODE.update({char.upper(): char for char in _CROCKFORD})
|
||||
# The letters that a person can mistake for a digit read as that digit.
|
||||
_DECODE.update({"o": "0", "O": "0", "i": "1", "I": "1", "l": "1", "L": "1"})
|
||||
|
||||
|
||||
def _crockford(data: bytes) -> str:
|
||||
"""Encode five bytes as eight Crockford Base32 characters, the most significant first."""
|
||||
bits = int.from_bytes(data[:5], "big")
|
||||
return "".join(_CROCKFORD[(bits >> shift) & 0x1F] for shift in range(35, -5, -5))
|
||||
|
||||
|
||||
def _normal_hash(raw: str) -> str | None:
|
||||
"""The canonical (lowercase) form of an eight-character hash, or ``None`` if it is not one."""
|
||||
if len(raw) != 8:
|
||||
return None
|
||||
out = [_DECODE.get(char) for char in raw]
|
||||
if None in out:
|
||||
return None
|
||||
return "".join(out) # type: ignore[arg-type] - None is excluded above
|
||||
|
||||
|
||||
def message_hash(body: str, timestamp: int) -> str:
|
||||
"""The hash with which a reaction names its target message.
|
||||
|
||||
Args:
|
||||
body: The body of the target. On a channel, this is the text after the ``Name: ``
|
||||
prefix of the sender.
|
||||
timestamp: The timestamp that the sender gave the target, in seconds.
|
||||
|
||||
Returns:
|
||||
Eight lowercase Crockford Base32 characters.
|
||||
"""
|
||||
data = body.encode("utf-8") + (timestamp & 0xFFFFFFFF).to_bytes(4, "little")
|
||||
return _crockford(hashlib.sha256(data).digest())
|
||||
|
||||
|
||||
def wire_timestamp(when: datetime) -> int:
|
||||
"""The timestamp of a message as its sender put it on the wire: whole seconds."""
|
||||
return int(when.timestamp())
|
||||
|
||||
|
||||
# -- the emoji part -----------------------------------------------------------------------
|
||||
|
||||
_VS = frozenset("︎️")
|
||||
_ZWJ = ""
|
||||
_KEYCAP = "⃣"
|
||||
_TONES = frozenset(chr(cp) for cp in range(0x1F3FB, 0x1F400))
|
||||
_TAGS = frozenset(chr(cp) for cp in range(0xE0020, 0xE0080))
|
||||
|
||||
|
||||
def _is_regional(char: str) -> bool:
|
||||
"""Whether ``char`` is a Regional Indicator (one half of a flag)."""
|
||||
return 0x1F1E6 <= ord(char) <= 0x1F1FF
|
||||
|
||||
|
||||
def _is_pictograph(char: str) -> bool:
|
||||
"""Whether ``char`` can start an emoji: a symbol outside the Latin and punctuation blocks."""
|
||||
return ord(char) >= 0x2000 and unicodedata.category(char) == "So"
|
||||
|
||||
|
||||
def is_reaction_emoji(value: str) -> bool:
|
||||
"""Whether ``value`` is exactly one emoji: the emoji part of a reaction.
|
||||
|
||||
One emoji is one glyph. It can be a flag (two Regional Indicators), a keycap (``1️⃣``),
|
||||
or pictographs joined with zero-width joiners (``❤️🔥``). Each pictograph can have a
|
||||
variation selector, a skin tone, and a tag sequence. Text, or an emoji with text after
|
||||
it, is not a reaction.
|
||||
"""
|
||||
if not value or value.isascii():
|
||||
return False
|
||||
if len(value) == 2 and _is_regional(value[0]) and _is_regional(value[1]):
|
||||
return True
|
||||
if value[0] in "0123456789#*":
|
||||
return value[1:] in (_KEYCAP, "️" + _KEYCAP)
|
||||
for part in value.split(_ZWJ):
|
||||
if not part or not _is_pictograph(part[0]):
|
||||
return False
|
||||
if any(char not in _VS and char not in _TONES and char not in _TAGS for char in part[1:]):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def emoji_name(emoji: str) -> str:
|
||||
"""A name for an emoji, for a person to read: its Unicode name in lowercase."""
|
||||
try:
|
||||
return unicodedata.name(emoji[0]).lower()
|
||||
except (ValueError, IndexError):
|
||||
return "emoji"
|
||||
|
||||
|
||||
def reaction_set(preference: str) -> list[str]:
|
||||
"""The emoji that the reaction dialog offers, from the ``reaction_emoji`` preference.
|
||||
|
||||
The preference is a list of emoji with spaces between them. A word that is not one emoji
|
||||
is ignored, and so is a second copy of an emoji. An empty result gives
|
||||
:data:`DEFAULT_REACTIONS`, because a reaction dialog with no rows is of no use.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
for word in (preference or "").split():
|
||||
if is_reaction_emoji(word) and word not in seen:
|
||||
seen.append(word)
|
||||
return seen or DEFAULT_REACTIONS.split()
|
||||
|
||||
|
||||
# -- the wire format ----------------------------------------------------------------------
|
||||
|
||||
|
||||
#: The three forms of a reaction on the wire.
|
||||
MC1 = "mc1" # MeshCore One: an eight-character hash
|
||||
MCO = "mco" # meshcore-open v3: a 16-bit hash, and an index into its table of emoji
|
||||
MCO_V1 = "mco1" # meshcore-open v1 (before January 2026): the time and two 30-bit hashes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Reaction:
|
||||
"""A reaction, as read from the body of a message.
|
||||
|
||||
Attributes:
|
||||
emoji: The emoji of the reaction.
|
||||
form: :data:`MC1`, :data:`MCO`, or :data:`MCO_V1`.
|
||||
hash: How the reaction names its target. For :data:`MC1`, the eight-character hash
|
||||
(refer to :func:`message_hash`). For :data:`MCO`, four lowercase hex digits.
|
||||
For :data:`MCO_V1`, the message id of meshcore-open.
|
||||
author: For a channel reaction of :data:`MC1`, the name of the author of the
|
||||
target. ``None`` in all other cases.
|
||||
timestamp: For :data:`MCO_V1`, the timestamp of the target, in seconds.
|
||||
author_hash: For :data:`MCO_V1`, the Dart hash of the name of the author.
|
||||
body_hash: For :data:`MCO_V1`, the Dart hash of the body of the target.
|
||||
"""
|
||||
|
||||
emoji: str
|
||||
form: str
|
||||
hash: str
|
||||
author: str | None = None
|
||||
timestamp: int | None = None
|
||||
author_hash: int | None = None
|
||||
body_hash: int | None = None
|
||||
|
||||
|
||||
def parse(body: str, *, channel: bool) -> Reaction | None:
|
||||
"""Read a message body as a reaction.
|
||||
|
||||
Args:
|
||||
body: The body of the message. On a channel, this is the text after the
|
||||
``Name: `` prefix of the sender.
|
||||
channel: Whether the message is on a channel. A channel reaction of MeshCore One
|
||||
names the author of its target, and a direct one does not.
|
||||
|
||||
Returns:
|
||||
The reaction, or ``None`` when the body is a usual message.
|
||||
"""
|
||||
if body.startswith("r:"):
|
||||
return _parse_mco(body) or _parse_mco_v1(body)
|
||||
head, newline, tail = body.rpartition("\n")
|
||||
if not newline:
|
||||
return None
|
||||
digest = _normal_hash(tail)
|
||||
if digest is None:
|
||||
return None
|
||||
if not channel:
|
||||
if "@[" in head or not is_reaction_emoji(head):
|
||||
return None
|
||||
return Reaction(head, MC1, digest)
|
||||
before, mark, after = head.partition("@[")
|
||||
if not mark:
|
||||
return None
|
||||
if before:
|
||||
# The order before MeshCore One 1.4.1: the emoji, then the mention.
|
||||
if not after.endswith("]"):
|
||||
return None
|
||||
emoji, author = before, after[:-1]
|
||||
else:
|
||||
author, close, emoji = after.partition("]")
|
||||
if not close:
|
||||
return None
|
||||
if not author or not is_reaction_emoji(emoji):
|
||||
return None
|
||||
return Reaction(emoji, MC1, digest, author=author)
|
||||
|
||||
|
||||
def channel_text(emoji: str, author: str, body: str, timestamp: int) -> str:
|
||||
"""The body of a channel reaction to the message ``body`` of ``author``.
|
||||
|
||||
Args:
|
||||
emoji: The emoji of the reaction.
|
||||
author: The name of the author of the target, as the target carried it.
|
||||
body: The body of the target, without the ``Name: `` prefix.
|
||||
timestamp: The timestamp that the author gave the target.
|
||||
"""
|
||||
return f"@[{author}]{emoji}\n{message_hash(body, timestamp)}"
|
||||
|
||||
|
||||
def direct_text(emoji: str, body: str, timestamp: int) -> str:
|
||||
"""The body of a direct reaction to the message ``body`` sent at ``timestamp``."""
|
||||
return f"{emoji}\n{message_hash(body, timestamp)}"
|
||||
|
||||
|
||||
# -- meshcore-open ------------------------------------------------------------------------
|
||||
|
||||
#: The table of emoji of meshcore-open (its ``emoji_picker.dart``), in order: the quick set,
|
||||
#: then the smileys, gestures, hearts, and objects. A reaction of v3 sends an index into it.
|
||||
# fmt: off
|
||||
MCO_EMOJI: tuple[str, ...] = (
|
||||
"👍", "❤️", "😂", "🎉", "👏", "🔥",
|
||||
"😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊",
|
||||
"😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘",
|
||||
"😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪",
|
||||
"🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏",
|
||||
"😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖",
|
||||
"😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡",
|
||||
"🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
|
||||
"😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶",
|
||||
"👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️",
|
||||
"🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆",
|
||||
"👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏",
|
||||
"🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳",
|
||||
"💪",
|
||||
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍",
|
||||
"🤎", "💔", "❤️🔥", "❤️🩹", "💕", "💞", "💓", "💗",
|
||||
"💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫",
|
||||
"💦", "💨", "🕳️", "💬", "👁️🗨️", "🗨️", "🗯️", "💭",
|
||||
"🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆",
|
||||
"🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐",
|
||||
"🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒",
|
||||
"🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥",
|
||||
"⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔",
|
||||
"📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶",
|
||||
"🚀",
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
_HEX = frozenset("0123456789abcdef")
|
||||
|
||||
|
||||
def _parse_mco(body: str) -> Reaction | None:
|
||||
"""Read ``r:{hash}:{index}``, a reaction of meshcore-open v3 (four and two hex digits)."""
|
||||
if len(body) != 9 or body[6] != ":":
|
||||
return None
|
||||
digest, index = body[2:6], body[7:]
|
||||
if not set(digest + index) <= _HEX:
|
||||
return None
|
||||
position = int(index, 16)
|
||||
if position >= len(MCO_EMOJI):
|
||||
return None
|
||||
return Reaction(MCO_EMOJI[position], MCO, digest)
|
||||
|
||||
|
||||
def _parse_mco_v1(body: str) -> Reaction | None:
|
||||
"""Read ``r:{millis}_{author hash}_{body hash}:{emoji}``, a reaction of meshcore-open v1."""
|
||||
ident, colon, emoji = body[2:].rpartition(":")
|
||||
if not colon or not ident or not emoji:
|
||||
return None
|
||||
parts = ident.split("_")
|
||||
if len(parts) != 3 or not all(part.isdigit() for part in parts):
|
||||
return None
|
||||
seconds = int(parts[0]) // 1000
|
||||
if seconds > 0xFFFFFFFF:
|
||||
return None
|
||||
return Reaction(
|
||||
emoji,
|
||||
MCO_V1,
|
||||
ident,
|
||||
timestamp=seconds,
|
||||
author_hash=int(parts[1]),
|
||||
body_hash=int(parts[2]),
|
||||
)
|
||||
|
||||
|
||||
def dart_hash(units: list[int]) -> int:
|
||||
"""The ``String.hashCode`` of the Dart VM over UTF-16 code units: a 30-bit value, never 0."""
|
||||
mask = 0xFFFFFFFF
|
||||
value = 0
|
||||
for unit in units:
|
||||
value = (value + unit) & mask
|
||||
value = (value + (value << 10)) & mask
|
||||
value ^= value >> 6
|
||||
value = (value + (value << 3)) & mask
|
||||
value ^= value >> 11
|
||||
value = (value + (value << 15)) & mask
|
||||
value &= (1 << 30) - 1
|
||||
return value or 1
|
||||
|
||||
|
||||
def _units(text: str) -> list[int]:
|
||||
"""The UTF-16 code units of ``text``."""
|
||||
raw = text.encode("utf-16-le")
|
||||
return [int.from_bytes(raw[i : i + 2], "little") for i in range(0, len(raw), 2)]
|
||||
|
||||
|
||||
def mco_hash(timestamp: int, author: str | None, body: str) -> str:
|
||||
"""The 16-bit hash with which meshcore-open v3 names a target, as four hex digits.
|
||||
|
||||
It is the Dart hash of the timestamp in decimal, the name of the author (on a channel
|
||||
only), and the first five UTF-16 code units of the body.
|
||||
"""
|
||||
units = _units(str(timestamp)) + (_units(author) if author else []) + _units(body)[:5]
|
||||
return f"{dart_hash(units) & 0xFFFF:04x}"
|
||||
|
||||
|
||||
# -- the fold -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Reactor:
|
||||
"""Who sent a reaction: our node, or another node by the name that it gave.
|
||||
|
||||
Attributes:
|
||||
name: The name of the node. ``None`` for our node, and for the peer of a direct
|
||||
exchange (the conversation names the peer).
|
||||
ours: Whether our node sent the reaction.
|
||||
"""
|
||||
|
||||
name: str | None
|
||||
ours: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Badge:
|
||||
"""One emoji on one message, and the nodes that reacted with it, the first first."""
|
||||
|
||||
emoji: str
|
||||
reactors: list[Reactor] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ours(self) -> bool:
|
||||
"""Whether our node is one of the nodes that reacted with this emoji."""
|
||||
return any(reactor.ours for reactor in self.reactors)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Target:
|
||||
"""A message of the transcript, as the fold matches reactions against it."""
|
||||
|
||||
message: ChatMessage
|
||||
author: str | None
|
||||
body: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class ReactionFold:
|
||||
"""Fold the reactions of one conversation onto the messages that they react to.
|
||||
|
||||
Give each message of the transcript to :meth:`take`, in the order of arrival. A message
|
||||
that is not a reaction becomes a target, and :meth:`take` returns ``False``. A reaction
|
||||
attaches to its target, and :meth:`take` returns ``True``. Then the transcript does not
|
||||
show it as a message. A reaction whose target is not known yet waits (at most
|
||||
:data:`PENDING_CAP` of them), because on a mesh a reaction can arrive before its target.
|
||||
:meth:`badges` gives the reactions on one message.
|
||||
|
||||
The fold counts each emoji one time for each node. A node can send the same reaction
|
||||
again, and a repeater can deliver a copy two times.
|
||||
"""
|
||||
|
||||
def __init__(self, *, channel: bool, self_name: str | None = None) -> None:
|
||||
"""Make an empty fold.
|
||||
|
||||
Args:
|
||||
channel: Whether the conversation is a channel. A channel reaction names the
|
||||
author of its target, and a direct one does not.
|
||||
self_name: The name of our node. On a channel, it is the author of each message
|
||||
that we sent. Without it, the reactions to our channel messages of
|
||||
MeshCore One cannot find their target.
|
||||
"""
|
||||
self._channel = channel
|
||||
self._self_name = self_name
|
||||
self._targets: list[_Target] = []
|
||||
self._by_id: dict[int, _Target] = {}
|
||||
self._by_hash: dict[tuple[str | None, str], _Target] = {}
|
||||
self._badges: dict[int, list[Badge]] = {}
|
||||
self._pending: list[tuple[Reaction, Reactor]] = []
|
||||
|
||||
def take(self, message: ChatMessage) -> bool:
|
||||
"""Fold one message in, and return whether it is a reaction.
|
||||
|
||||
Args:
|
||||
message: The next message of the transcript.
|
||||
|
||||
Returns:
|
||||
``True`` when the message is a reaction. The transcript then does not show it.
|
||||
``False`` when it is a usual message, which other reactions can now name.
|
||||
"""
|
||||
author, body = self._author_and_body(message)
|
||||
reaction = parse(body, channel=self._channel)
|
||||
if reaction is None:
|
||||
self._index(message, author, body)
|
||||
return False
|
||||
reactor = Reactor(
|
||||
None if message.outbound else (author if self._channel else None), ours=message.outbound
|
||||
)
|
||||
target = self._find(reaction, wire_timestamp(message.created_at))
|
||||
if target is not None:
|
||||
self._attach(target.message, reaction.emoji, reactor)
|
||||
elif reaction.form == MC1:
|
||||
self._pending.append((reaction, reactor))
|
||||
del self._pending[:-PENDING_CAP]
|
||||
return True
|
||||
|
||||
def badges(self, message: ChatMessage) -> list[Badge]:
|
||||
"""The reactions on ``message``: the most frequent emoji first, then the earliest."""
|
||||
badges = self._badges.get(id(message), [])
|
||||
order = {id(badge): index for index, badge in enumerate(badges)}
|
||||
return sorted(badges, key=lambda badge: (-len(badge.reactors), order[id(badge)]))
|
||||
|
||||
def count(self, message: ChatMessage) -> int:
|
||||
"""The number of reactions on ``message``. A render cache compares it to see a change."""
|
||||
return sum(len(badge.reactors) for badge in self._badges.get(id(message), []))
|
||||
|
||||
def reacted(self, message: ChatMessage, emoji: str) -> bool:
|
||||
"""Whether our node already reacted to ``message`` with ``emoji``."""
|
||||
return any(
|
||||
badge.emoji == emoji and badge.ours for badge in self._badges.get(id(message), [])
|
||||
)
|
||||
|
||||
def target_of(self, message: ChatMessage) -> tuple[str | None, str, int] | None:
|
||||
"""The author, the body, and the timestamp with which a reaction names ``message``.
|
||||
|
||||
``None`` when ``message`` is not a target of this fold (a reaction, or a message
|
||||
that the fold never took).
|
||||
"""
|
||||
target = self._by_id.get(id(message))
|
||||
if target is None or target.message is not message:
|
||||
return None
|
||||
return target.author, target.body, target.timestamp
|
||||
|
||||
def _author_and_body(self, message: ChatMessage) -> tuple[str | None, str]:
|
||||
"""The author and the body of a message, as the hash of a reaction sees them.
|
||||
|
||||
On a channel, the body of a received message is the text after the ``Name: ``
|
||||
prefix, without the spaces at its two ends. Our channel messages have no prefix in
|
||||
the history, and their author is our node.
|
||||
"""
|
||||
if not self._channel:
|
||||
return None, message.text
|
||||
if message.outbound:
|
||||
return self._self_name, message.text.strip(" \t")
|
||||
name, body = split_channel_sender(message.text)
|
||||
return name, body.strip(" \t")
|
||||
|
||||
def _index(self, message: ChatMessage, author: str | None, body: str) -> None:
|
||||
"""Make ``message`` a target, and attach the reactions that waited for it."""
|
||||
target = _Target(message, author, body, wire_timestamp(message.created_at))
|
||||
self._targets.append(target)
|
||||
self._by_id[id(message)] = target
|
||||
if self._channel and not author:
|
||||
return # a channel reaction of MeshCore One must name the author
|
||||
key = (author if self._channel else None, message_hash(body, target.timestamp))
|
||||
self._by_hash[key] = target
|
||||
waiting = [entry for entry in self._pending if (entry[0].author, entry[0].hash) == key]
|
||||
if waiting:
|
||||
self._pending = [entry for entry in self._pending if entry not in waiting]
|
||||
for reaction, reactor in waiting:
|
||||
self._attach(message, reaction.emoji, reactor)
|
||||
|
||||
def _find(self, reaction: Reaction, at: int) -> _Target | None:
|
||||
"""The target that ``reaction`` names, or ``None``. The newest target wins a tie."""
|
||||
if reaction.form == MC1:
|
||||
return self._by_hash.get((reaction.author, reaction.hash))
|
||||
if reaction.form == MCO:
|
||||
anchor = at
|
||||
else:
|
||||
anchor = reaction.timestamp or 0
|
||||
for target in reversed(self._targets):
|
||||
if abs(target.timestamp - anchor) > WINDOW_S:
|
||||
continue
|
||||
if reaction.form == MCO:
|
||||
author = target.author if self._channel else None
|
||||
if mco_hash(target.timestamp, author, target.body) == reaction.hash:
|
||||
return target
|
||||
elif dart_hash(_units(target.body)) == reaction.body_hash:
|
||||
if self._channel and target.author is not None:
|
||||
if dart_hash(_units(target.author)) != reaction.author_hash:
|
||||
continue
|
||||
return target
|
||||
return None
|
||||
|
||||
def _attach(self, message: ChatMessage, emoji: str, reactor: Reactor) -> None:
|
||||
"""Add one reaction to ``message``, if that node did not already send that emoji."""
|
||||
badges = self._badges.setdefault(id(message), [])
|
||||
badge = next((b for b in badges if b.emoji == emoji), None)
|
||||
if badge is None:
|
||||
badge = Badge(emoji)
|
||||
badges.append(badge)
|
||||
if reactor not in badge.reactors:
|
||||
badge.reactors.append(reactor)
|
||||
@@ -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.
|
||||
|
||||
@@ -19,11 +19,14 @@ MeshTerm always stores them while a device listens.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..core.channels import channel_identity
|
||||
from ..core import reactions
|
||||
from ..core.channels import channel_identity, split_channel_sender
|
||||
from ..core.connection import Unsubscribe
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import (
|
||||
@@ -49,6 +52,29 @@ _AWAITING_CAP = 16
|
||||
#: Called with a message when its ack arrives after the send stopped its wait for the ack.
|
||||
LateAck = Callable[[ChatMessage], None]
|
||||
|
||||
#: The highest try number that a direct message can carry. The protocol keeps it in two bits.
|
||||
_LAST_ATTEMPT = 3
|
||||
|
||||
|
||||
def _stamp() -> tuple[int, datetime]:
|
||||
"""The timestamp for a message that MeshTerm is about to send, and the same instant as a date.
|
||||
|
||||
MeshTerm gives the timestamp to the device, and does not let the library read the clock
|
||||
a second time. Thus the stored time of the message is exactly the time that the
|
||||
recipients see. A reaction names its target by that time (refer to
|
||||
:mod:`~meshterm.core.reactions`), so the two must agree to the second.
|
||||
"""
|
||||
seconds = int(time.time())
|
||||
return seconds, datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
|
||||
|
||||
def _is_reaction(message: Message) -> bool:
|
||||
"""Whether an inbound message is a reaction. A room post never is one (rooms have none)."""
|
||||
if message.is_post:
|
||||
return False
|
||||
body = split_channel_sender(message.text)[1] if message.is_channel else message.text
|
||||
return reactions.parse(body.strip(" \t"), channel=message.is_channel) is not None
|
||||
|
||||
|
||||
def _fallback_channel_id(idx: int | None) -> str:
|
||||
"""The identity of a channel that MeshTerm cannot read.
|
||||
@@ -103,6 +129,9 @@ class ChatService:
|
||||
# that wants to know when its ack arrives (refer to :meth:`_settle_ack`).
|
||||
self._awaiting: OrderedDict[str, tuple[ChatMessage, LateAck | None]] = OrderedDict()
|
||||
self._ack_unsubscribe: Unsubscribe | None = None
|
||||
# The next try number of each direct message that was sent (by its row id). A
|
||||
# retry with ^R sends the same message again: the same timestamp, the next number.
|
||||
self._next_attempt: dict[int, int] = {}
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
@@ -408,6 +437,9 @@ class ChatService:
|
||||
that comes from the slot.
|
||||
* A muted channel (refer to :class:`~meshterm.core.mute_store.MuteStore`). This is
|
||||
the one suppression that the user asks for, not one that the device implies.
|
||||
* A reaction (refer to :mod:`~meshterm.core.reactions`). The transcript shows it on
|
||||
the message that it reacts to, not as a new message. Thus a badge for it would
|
||||
point at nothing new.
|
||||
|
||||
The method asks the device through the session cache. Thus this rule costs nothing
|
||||
for each message, more than the channel read that the identity already made.
|
||||
@@ -422,6 +454,8 @@ class ChatService:
|
||||
Returns:
|
||||
``True`` to increase the unread count of the conversation.
|
||||
"""
|
||||
if _is_reaction(message):
|
||||
return False
|
||||
if message.is_channel:
|
||||
store = getattr(self._ctx, "mute_store", None)
|
||||
if store is not None and store.is_muted(channel_id):
|
||||
@@ -481,7 +515,9 @@ class ChatService:
|
||||
return is_direct_messageable(contact.node_type)
|
||||
return False
|
||||
|
||||
async def _deliver_direct(self, contact: Contact, text: str) -> tuple[bool, list[str]]:
|
||||
async def _deliver_direct(
|
||||
self, contact: Contact, text: str, *, timestamp: int, first_attempt: int = 0
|
||||
) -> tuple[bool, list[str], int]:
|
||||
"""Transmit a direct message, and try again softly until it is acknowledged.
|
||||
|
||||
One logical send makes one first transmission, plus up to
|
||||
@@ -496,13 +532,22 @@ class ChatService:
|
||||
stores the message as unacknowledged. Then Ctrl-R in the chat screen is the retry
|
||||
that the user starts, in addition to these automatic retries.
|
||||
|
||||
Each try carries the same timestamp and the next try number, as the protocol
|
||||
specifies. Thus the recipient sees one message with one timestamp, whichever try
|
||||
arrives. A reaction to it names that timestamp. The try number makes each packet
|
||||
different, so a repeater does not discard a retry as a copy of the first packet. The
|
||||
number stops at 3 (the protocol has two bits for it).
|
||||
|
||||
Args:
|
||||
contact: The recipient.
|
||||
text: The message body.
|
||||
timestamp: The timestamp of the message, the same for each try.
|
||||
first_attempt: The try number of the first transmission.
|
||||
|
||||
Returns:
|
||||
Whether an attempt was acknowledged in its wait, and the ack code of each
|
||||
attempt. A late ack can still carry any of these codes (the device keeps them).
|
||||
Whether an attempt was acknowledged in its wait, the ack code of each attempt (a
|
||||
late ack can still carry any of these codes, because the device keeps them),
|
||||
and the try number for the next retry.
|
||||
|
||||
Raises:
|
||||
Exception: A hard send failure (the companion rejects the send) goes to the
|
||||
@@ -512,12 +557,16 @@ class ChatService:
|
||||
device = await self._ctx.device()
|
||||
attempts = max(0, self._ctx.preferences.direct_message_soft_retries) + 1
|
||||
codes: list[str] = []
|
||||
number = first_attempt
|
||||
for attempt in range(1, attempts + 1):
|
||||
delivery: Delivery = await device.send_direct_message(contact, text)
|
||||
delivery: Delivery = await device.send_direct_message(
|
||||
contact, text, timestamp=timestamp, attempt=min(number, _LAST_ATTEMPT)
|
||||
)
|
||||
number += 1
|
||||
if delivery.code:
|
||||
codes.append(delivery.code)
|
||||
if delivery.acked:
|
||||
return True, codes
|
||||
return True, codes, number
|
||||
if attempt < attempts:
|
||||
self._ctx.log.debug(
|
||||
"chat: DM to %s unacked on try %d/%d; soft-retrying",
|
||||
@@ -525,7 +574,7 @@ class ChatService:
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
return False, codes
|
||||
return False, codes, number
|
||||
|
||||
async def send_direct(
|
||||
self, contact: Contact, text: str, *, on_late_ack: LateAck | None = None
|
||||
@@ -549,7 +598,8 @@ class ChatService:
|
||||
The stored outbound :class:`ChatMessage`. Its ``acked`` shows whether a
|
||||
delivery acknowledgement arrived in the wait, until now.
|
||||
"""
|
||||
acked, codes = await self._deliver_direct(contact, text)
|
||||
seconds, sent_at = _stamp()
|
||||
acked, codes, number = await self._deliver_direct(contact, text, timestamp=seconds)
|
||||
chat = ChatMessage(
|
||||
text=text,
|
||||
outbound=True,
|
||||
@@ -557,10 +607,11 @@ class ChatService:
|
||||
peer=contact.key_prefix or contact.public_key[:12] or None,
|
||||
peer_name=contact.name,
|
||||
acked=acked,
|
||||
created_at=utcnow(),
|
||||
created_at=sent_at,
|
||||
)
|
||||
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
|
||||
if not acked:
|
||||
self._next_attempt[chat.row_id] = number
|
||||
self._await_ack(chat, codes, on_late_ack)
|
||||
return chat
|
||||
|
||||
@@ -583,7 +634,13 @@ class ChatService:
|
||||
Returns:
|
||||
The same ``message``, with :attr:`~ChatMessage.acked` updated.
|
||||
"""
|
||||
acked, codes = await self._deliver_direct(contact, message.text)
|
||||
seconds = int(message.created_at.timestamp())
|
||||
first = self._next_attempt.get(message.row_id, 1) if message.row_id is not None else 1
|
||||
acked, codes, number = await self._deliver_direct(
|
||||
contact, message.text, timestamp=seconds, first_attempt=first
|
||||
)
|
||||
if message.row_id is not None:
|
||||
self._next_attempt[message.row_id] = number
|
||||
if message.acked:
|
||||
return message # a late ack for an earlier attempt settled it during this send
|
||||
message.acked = acked
|
||||
@@ -756,7 +813,8 @@ class ChatService:
|
||||
device = await self._ctx.device()
|
||||
channel_id = await self.channel_id_for(index)
|
||||
asked = scope if scope is not None else self.channel_scope(channel_id)
|
||||
await device.send_channel_in_scope(index, text, asked)
|
||||
seconds, sent_at = _stamp()
|
||||
await device.send_channel_in_scope(index, text, asked, timestamp=seconds)
|
||||
chat = ChatMessage(
|
||||
text=text,
|
||||
outbound=True,
|
||||
@@ -764,7 +822,7 @@ class ChatService:
|
||||
channel_id=channel_id,
|
||||
channel_idx=index,
|
||||
peer_name=label,
|
||||
created_at=utcnow(),
|
||||
created_at=sent_at,
|
||||
scope=await self._sent_under(asked),
|
||||
)
|
||||
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -27,7 +27,7 @@ from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from ..context import AppContext
|
||||
from ..core import exitcodes
|
||||
from ..core import exitcodes, reactions
|
||||
from ..core.channels import (
|
||||
CHANNEL_SLOT_PROBE_CAP,
|
||||
DEFAULT_PUBLIC_SECRET,
|
||||
@@ -58,9 +58,20 @@ from ..services.trace_runner import (
|
||||
from ..ui import renderers
|
||||
from ..ui.fields import ChannelRef, NodeRef
|
||||
from ..ui.menus import Lane, column_header, fit_cells, section_heading
|
||||
from ..ui.theme import name_style
|
||||
from ..ui.pathline import SELF_GLYPH, PathHop
|
||||
from ..ui.termfont import powerline_enabled
|
||||
from ..ui.theme import emoticon, name_style
|
||||
from ..ui.tui import Choice, DeleteRequest, Separator
|
||||
from ..ui.widgets import NODE_GLYPHS, age_seconds, channel_glyph, format_age
|
||||
from ..ui.widgets import (
|
||||
DESTINATION,
|
||||
NODE_GLYPHS,
|
||||
ORIGIN,
|
||||
age_seconds,
|
||||
channel_glyph,
|
||||
format_age,
|
||||
hop_chip,
|
||||
name_chip,
|
||||
)
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -216,8 +227,8 @@ class ChatTool(Tool):
|
||||
# app-wide colour rule). A name that no contact and no stored advert has stays
|
||||
# muted. A room post names its author by key, which resolves in the other
|
||||
# direction.
|
||||
stored_names = ctx.repo.node_names()
|
||||
key_of = make_name_key_resolver(contacts, stored_names)
|
||||
stored_names, named_at = ctx.repo.node_names_with_times()
|
||||
key_of = make_name_key_resolver(contacts, stored_names, heard=named_at)
|
||||
name_of = make_node_resolver(contacts, stored_names)
|
||||
|
||||
# List the contacts by recency: first the contacts with messages, with the newest
|
||||
@@ -1020,30 +1031,32 @@ def _preview_text(
|
||||
|
||||
It is the same as the live transcript:
|
||||
|
||||
- Our own messages get a ``you:`` prefix.
|
||||
- In a received channel message, the inline ``Name:`` sender gets the hue that comes from
|
||||
its key (muted when no known node has the name).
|
||||
- Our own messages start with our author (refer to :func:`_append_author`).
|
||||
- In a received channel message, the inline ``Name:`` sender is the author, in the hue
|
||||
that comes from its key (the node grey when no known node has the name).
|
||||
- A room post starts with its author, named by key in the same way as the room screen
|
||||
names authors (:func:`~meshterm.ui.room.author_label`).
|
||||
- Each ``@[Name]`` mention shows as a bare ``@Name``, in the same way.
|
||||
- Each ``@[Name]`` mention shows as ``@Name``, in the same way (refer to
|
||||
:func:`_append_body`).
|
||||
|
||||
Thus the list and the chat use the same colour language.
|
||||
Thus the list and the chat use the same colour language, and where the terminal draws
|
||||
chips, the same chips: the author closes on the point and a mention opens on the notch.
|
||||
|
||||
The preview is made *in full*, however long the message is. Before, it was cut to a
|
||||
fixed 40 cells, and nothing could undo that cut. Now ←→ scroll the row. A preview that
|
||||
was cut before to approximately the visible lane has nothing more to show. The list
|
||||
cuts it at the right edge, the same as any other row, and the scroll goes past that.
|
||||
"""
|
||||
body_raw = last.text.replace("\n", " ")
|
||||
body_raw = _reaction_line(last) or last.text.replace("\n", " ")
|
||||
text = Text()
|
||||
if last.outbound:
|
||||
text.append("you: ", style="accent")
|
||||
_append_author(text, PathHop(SELF_GLYPH, you=True), Text("you: ", style="accent"))
|
||||
_append_body(text, body_raw, key_of)
|
||||
elif last.is_channel:
|
||||
name, body = split_channel_sender(body_raw)
|
||||
if name is not None:
|
||||
text.append(name, style=name_style(name, key_of(name)))
|
||||
text.append(": ", style="muted")
|
||||
key = key_of(name)
|
||||
_append_author(text, PathHop(name, key=key), _byline(name, key))
|
||||
_append_body(text, body, key_of)
|
||||
else:
|
||||
_append_body(text, body_raw, key_of)
|
||||
@@ -1051,26 +1064,70 @@ def _preview_text(
|
||||
from ..ui.room import author_label
|
||||
|
||||
name, key = author_label(last.author, name_of)
|
||||
text.append(name, style=name_style(name, key))
|
||||
text.append(": ", style="muted")
|
||||
_append_author(text, PathHop(name, key=key), _byline(name, key))
|
||||
_append_body(text, body_raw, key_of)
|
||||
else:
|
||||
_append_body(text, body_raw, key_of)
|
||||
return text
|
||||
|
||||
|
||||
def _reaction_line(last: ChatMessage) -> str | None:
|
||||
"""The preview body of a reaction, ``reacted 👍``, or ``None`` for a usual message.
|
||||
|
||||
A reaction is a message on the wire, and it can be the latest message of a
|
||||
conversation. Its raw body (a mention, a newline, and a hash) means nothing to a person.
|
||||
The name of the node keeps its place in front of the body, as for each message.
|
||||
"""
|
||||
if last.author is not None:
|
||||
return None # a room post: rooms have no reactions
|
||||
body = (
|
||||
split_channel_sender(last.text)[1] if last.is_channel and not last.outbound else last.text
|
||||
)
|
||||
reaction = reactions.parse(body.strip(" \t"), channel=last.is_channel)
|
||||
if reaction is None:
|
||||
return None
|
||||
line = f"reacted {emoticon(reaction.emoji)}"
|
||||
if last.is_channel and not last.outbound:
|
||||
name = split_channel_sender(last.text)[0]
|
||||
return f"{name}: {line}" if name else line
|
||||
return line
|
||||
|
||||
|
||||
def _append_author(text: Text, hop: PathHop, byline: Text) -> None:
|
||||
"""Append the author of a preview: an origin chip where chips are drawn, else ``byline``.
|
||||
|
||||
The chip closes on the point, the same as the sender chip of the transcript, and the
|
||||
body follows it after one space. The point says what the colon of ``Name:`` says, so
|
||||
the chip does not carry a colon. Where the terminal draws no chips, the author is the
|
||||
``byline`` (``Name: ``, or ``you: `` for us), as it always was (JP, 2026-10-10).
|
||||
"""
|
||||
if powerline_enabled():
|
||||
text.append_text(hop_chip(hop, role=ORIGIN))
|
||||
text.append(" ")
|
||||
else:
|
||||
text.append_text(byline)
|
||||
|
||||
|
||||
def _byline(name: str, key: str | None) -> Text:
|
||||
"""The author of a preview as text: the name in its hue, then a muted colon."""
|
||||
text = Text(name, style=name_style(name, key))
|
||||
text.append(": ", style="muted")
|
||||
return text
|
||||
|
||||
|
||||
def _append_body(text: Text, body: str, key_of: NameKeyResolver) -> None:
|
||||
"""Append ``body`` to ``text``, muted, and light the names that it mentions.
|
||||
|
||||
Each ``@[Name]`` is drawn in the hue that comes from the key of that node. It stays
|
||||
muted when the name resolves to no node that we know.
|
||||
Each ``@[Name]`` is drawn in the hue that comes from the key of that node, as a chip
|
||||
that opens on the notch where the terminal draws chips (the same as in the transcript).
|
||||
It takes the node grey when the name resolves to no node that we know.
|
||||
"""
|
||||
pos = 0
|
||||
for match in MENTION.finditer(body):
|
||||
if match.start() > pos:
|
||||
text.append(body[pos : match.start()], style="muted")
|
||||
name = match.group(1)
|
||||
text.append(f"@{name}", style=name_style(name, key_of(name)))
|
||||
text.append_text(name_chip(f"@{name}", key_of(name), role=DESTINATION))
|
||||
pos = match.end()
|
||||
if pos < len(body):
|
||||
text.append(body[pos:], style="muted")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,21 +18,25 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from ..core import links
|
||||
from ..core import links, reactions
|
||||
from ..core.channels import MENTION, split_channel_sender
|
||||
from ..core.connection import ContactNotOnDeviceError
|
||||
from ..core.events import EventKind, MeshEvent
|
||||
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
|
||||
from ..core.regions import WILDCARD
|
||||
from ..platforms import Platform, on_platform
|
||||
from .pathline import PATH_INK
|
||||
from .qr import QrScreen, qr_strip
|
||||
from .theme import name_style, snr_style
|
||||
from .theme import emoticon, name_style, snr_style
|
||||
from .tui.prompt import (
|
||||
CHANNEL_BYTE_LIMIT,
|
||||
DM_BYTE_LIMIT,
|
||||
@@ -42,10 +46,13 @@ from .tui.prompt import (
|
||||
from .tui.render import render_hanging, render_lines, right_aligned_tail
|
||||
from .tui.screen import CANCEL, Screen
|
||||
from .tui.spinner import Spinner, spinner_interval
|
||||
from .widgets import name_chip
|
||||
from .widgets import DESTINATION, ORIGIN, name_chip
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from .map_render import MapMarker
|
||||
from .map_screen import Locator
|
||||
from .node_detail_screen import ContactPages
|
||||
|
||||
|
||||
#: The delivery marks for a *resolved* outbound direct message, at the end of its line:
|
||||
@@ -69,12 +76,37 @@ _URL_GUTTER = 2
|
||||
#: only on the PicoCalc). Bound for each platform, never asked at each frame.
|
||||
_URL_CODES = False
|
||||
|
||||
#: The mark in front of the count of a reaction. Where the font has no emoji, an emoticon
|
||||
#: such as ``+1`` or ``<3`` stands in for the emoji, and a bare count after it reads as part
|
||||
#: of it (``+1 3``). There, the count is ``x3``. Bound for each platform.
|
||||
_COUNT_MARK = ""
|
||||
|
||||
|
||||
@on_platform
|
||||
def _bind(platform: Platform) -> None:
|
||||
"""Hang codes under URLs where the platform draws them (now and at each switch)."""
|
||||
global _URL_CODES
|
||||
"""Bind the choices of the transcript that depend on the platform (now and at each switch)."""
|
||||
global _URL_CODES, _COUNT_MARK
|
||||
_URL_CODES = platform.url_codes
|
||||
_COUNT_MARK = "" if platform.emoji else "x"
|
||||
|
||||
|
||||
#: The maximum width of a footer hint, in cells (refer to the UX standards).
|
||||
_HINT_CELLS = 72
|
||||
|
||||
|
||||
def _fit_hint(atoms: list[str], spare: tuple[str, ...] = ()) -> str:
|
||||
"""Join the hint atoms that are not empty, and remove ``spare`` atoms until the hint fits.
|
||||
|
||||
The ``spare`` atoms go in the given order, and only while the hint is wider than
|
||||
:data:`_HINT_CELLS`. Thus a key that the line can hold stays on it.
|
||||
"""
|
||||
kept = [atom for atom in atoms if atom]
|
||||
for atom in spare:
|
||||
if cell_len(" · ".join(kept)) <= _HINT_CELLS:
|
||||
break
|
||||
if atom in kept:
|
||||
kept.remove(atom)
|
||||
return " · ".join(kept)
|
||||
|
||||
|
||||
class ChatScreen(Screen):
|
||||
@@ -88,7 +120,15 @@ class ChatScreen(Screen):
|
||||
opens the delivery paths of the message. In the two types of chat, ^P opens the paths
|
||||
of the selected message. A path belongs to one message, so when no message is
|
||||
selected, there is nothing to show. ^U shows the links of the selected message as QR
|
||||
codes, on the share screen. When no message is selected, Esc leaves the chat.
|
||||
codes, on the share screen. ^L opens the map on the node of the author of the selected
|
||||
message, when that node has a location. ^E reacts to the selected message with an emoji
|
||||
(refer to :mod:`~meshterm.core.reactions`). When no message is selected, Esc leaves the
|
||||
chat.
|
||||
|
||||
A reaction is a message on the wire, but the transcript does not show it as one. It shows
|
||||
each reaction under the message that it reacts to: the emoji and a count, or the names of
|
||||
the nodes that reacted while that message is selected. A room has no reactions, so a
|
||||
room shows each message as it arrived.
|
||||
|
||||
A channel with a send scope shows its region in the title (``#ops · yul``). There, ^R
|
||||
sends a message again *unscoped*: the selected message, or the newest when no message is
|
||||
@@ -104,80 +144,93 @@ class ChatScreen(Screen):
|
||||
|
||||
@property
|
||||
def picocalc_lyra_lane(self):
|
||||
"""The PicoCalc lane in the words of the transcript: Latest/Oldest, Paths, Retry.
|
||||
"""The PicoCalc lane of a conversation: the same lane in a channel, a DM, and a room.
|
||||
|
||||
The Shift bank of the shared lane sends the plain ``end``/``home`` actions. But
|
||||
here the cursor of the compose line already uses them (refer to :meth:`handle`).
|
||||
Thus, without a change, the two keyboard keys go to the same fallback ("clear the
|
||||
selection, stay at the tail"), and each one seems to scroll to the bottom. Instead,
|
||||
the two Shift slots send ``ctrl_end``/``ctrl_home``, and their labels say what
|
||||
these actions do to a conversation: go back to the *latest* message and the
|
||||
compose line, or go back to the *oldest*. This is the shared pair with new labels
|
||||
in the same slots. Thus it keeps the shared sides: each jump is still behind the
|
||||
page key that goes in the same direction (refer to
|
||||
:data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). F4/F5 keep the shared paging pair.
|
||||
From the outside, the transcript moves by one screen, also when it is the selection
|
||||
that moves.
|
||||
The plain bank of F1 to F3 has the verbs that all three screens share, in the order
|
||||
of the hint: ``Paths`` (^P), ``URL→QR`` (^U), and ``Map`` (^L). The Shift bank of F1
|
||||
and F2 has the verbs of one screen only. ``Retry`` (^R) is on the Shift half of F1 in
|
||||
a DM and in a room, and ``Resend`` (the same ^R, unscoped) is there in a channel. A
|
||||
room adds ``Log in`` (^G) on the Shift half of F2 (refer to
|
||||
:class:`~meshterm.ui.room.RoomScreen`). Thus a key does the same thing on each of
|
||||
the three screens, and the user finds a verb in the same place (JP, 2026-10-10).
|
||||
|
||||
The F3 pair follows the two rules of the lane. *Retry* is not there in a channel,
|
||||
because a channel message is never acknowledged, so there is nothing to retry. In
|
||||
a direct chat with nothing outstanding, it is only dim. The Shift slot of a channel
|
||||
is *Resend* instead: the selected message (or the newest) again, unscoped (refer
|
||||
to :meth:`_retry_target`). It is there when a resend is connected, and it is dim
|
||||
until that message is one of ours that went out under a region. *Paths* must have
|
||||
a selected message to show the paths of, and the two navigation slots must have a
|
||||
transcript to move through.
|
||||
The shared verbs act *on* the selected message. ``Paths`` is lit while a message is
|
||||
selected. ``URL→QR`` shows the codes of the links of the selection on a share
|
||||
screen, one code at a time with its full URL, and is lit while the selection has a
|
||||
URL (refer to :meth:`_picked_urls`). On the Cardputer it is the only way to a code.
|
||||
On the PicoCalc, the transcript already draws each code under its message in the
|
||||
same size, so there the chip only shows one code alone. ``Map`` is lit while the
|
||||
author of the selection has a location (refer to :meth:`_locate_target`).
|
||||
|
||||
F1/F2 have the **day** jump: the section step of the transcript (its dividers are
|
||||
days). A grouped select list does the same with ``Sect ↑``/``Sect ↓``, on the same
|
||||
keyboard keys, which send the same ``ctrl_pageup``/``ctrl_pagedown``. The label
|
||||
names what the sections *are* here, because of the lane rule that a chip names an
|
||||
action. A left-hand pair rises toward F1. Thus ``Day ↑`` (back through the
|
||||
transcript) is on the outer side of ``Day ↓``, the same as the pager on the right
|
||||
and the list that it copies. These chips are lit only when there is more than one
|
||||
day to step between. A conversation in one afternoon has no sections, the same as
|
||||
a list with one section.
|
||||
``React`` (^E) is on the Shift half of F2, behind ``URL→QR``. A channel and a DM have
|
||||
it, and a room does not. Thus it goes on the Shift bank, the bank for a verb that not
|
||||
all three screens share. A room puts ``Log in`` there instead. ``React`` is lit while
|
||||
the selection is a message that a reaction can name (refer to :meth:`_can_react`).
|
||||
|
||||
``QR`` (^U) is on the Shift half of F2, next to ``Retry`` on the Shift half of F3.
|
||||
The two are actions *on* the selected message: the codes of its links on a share
|
||||
screen, larger than the codes under it in the transcript. ``QR`` is lit while the
|
||||
selection has a URL.
|
||||
``Author`` (^O, the node page of the author of the selection) is the fourth shared
|
||||
verb, and the plain bank has only three slots. Thus it is on the Shift half of F3,
|
||||
behind ``Map``, the other verb about the author. It is lit while the author has a
|
||||
page (refer to :meth:`_author_page`). Each of the two halves of F3 is empty when
|
||||
the screen has nothing to find that node with.
|
||||
|
||||
The Shift half of F1 follows the two rules of the lane. In a channel with no
|
||||
resend connected, it is empty, because a channel message is never acknowledged and
|
||||
there is nothing to retry. In a DM with nothing outstanding, it is only dim.
|
||||
``Resend`` sends the selected message (or the newest) again, unscoped (refer to
|
||||
:meth:`_retry_target`), and it is dim until that message is one of ours that went
|
||||
out under a region.
|
||||
|
||||
The day jump (``ctrl_pageup``/``ctrl_pagedown``) has no chip. ``Day ↑`` and
|
||||
``Day ↓`` were on F1 and F2, and JP removed them (2026-10-10). The desktop keeps
|
||||
the jump on ^PgUp and ^PgDn.
|
||||
|
||||
F4/F5 keep the shared paging pair. The Shift bank of the shared lane sends the plain
|
||||
``end``/``home`` actions. But here the cursor of the compose line already uses them
|
||||
(refer to :meth:`handle`). Thus, without a change, the two keyboard keys go to the
|
||||
same fallback ("clear the selection, stay at the tail"), and each one seems to
|
||||
scroll to the bottom. Instead, the two Shift slots send ``ctrl_end``/``ctrl_home``,
|
||||
and their labels say what these actions do to a conversation: go back to the
|
||||
*latest* message and the compose line, or go back to the *oldest*. Each jump is
|
||||
still behind the page key that goes in the same direction (refer to
|
||||
:data:`~meshterm.ui.tui.fkeys.DEFAULT_LANE`). From the outside, the transcript moves
|
||||
by one screen, also when it is the selection that moves.
|
||||
"""
|
||||
from .tui.fkeys import FPair, default_lane
|
||||
|
||||
lane = list(default_lane(nav=bool(self._messages)))
|
||||
live = bool(self._messages)
|
||||
# The messages are in time order. Thus there are two days exactly when the two ends
|
||||
# have different dates. This costs O(1). A walk through the dividers dates the full
|
||||
# transcript again at each paint.
|
||||
days = live and (
|
||||
self._messages[0].created_at.astimezone().date()
|
||||
!= self._messages[-1].created_at.astimezone().date()
|
||||
)
|
||||
lane[0] = FPair("Day ↑", "ctrl_pageup", enabled=days)
|
||||
lane[1] = FPair(
|
||||
"Day ↓",
|
||||
"ctrl_pagedown",
|
||||
"QR",
|
||||
"url_code",
|
||||
enabled=days,
|
||||
opp_enabled=bool(self._picked_urls()),
|
||||
)
|
||||
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
|
||||
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
|
||||
if not self._is_channel:
|
||||
retry = ("Retry", "retry")
|
||||
again = ("Retry", "retry")
|
||||
elif self._resend_unscoped is not None:
|
||||
retry = ("Resend", "retry")
|
||||
again = ("Resend", "retry")
|
||||
else:
|
||||
retry = ("", "")
|
||||
lane[2] = FPair(
|
||||
again = ("", "")
|
||||
lane[0] = FPair(
|
||||
"Paths",
|
||||
"paths",
|
||||
*retry,
|
||||
*again,
|
||||
enabled=self._selected is not None and self._paths is not None,
|
||||
opp_enabled=self._retry_target() is not None,
|
||||
)
|
||||
react = ("React", "react") if self._fold is not None else ("", "")
|
||||
lane[1] = FPair(
|
||||
"URL→QR",
|
||||
"url_code",
|
||||
*react,
|
||||
enabled=bool(self._picked_urls()),
|
||||
opp_enabled=self._can_react(),
|
||||
)
|
||||
if self._locator is not None or self._pages is not None:
|
||||
where = ("Map", "locate") if self._locator is not None else ("", "")
|
||||
who = ("Author", "author_page") if self._pages is not None else ("", "")
|
||||
lane[2] = FPair(
|
||||
*where,
|
||||
*who,
|
||||
enabled=self._locate_target() is not None,
|
||||
opp_enabled=self._author_page() is not None,
|
||||
)
|
||||
lane[3] = FPair("Page ↓", "pagedown", "Latest", "ctrl_end", enabled=live, opp_enabled=live)
|
||||
lane[4] = FPair("Page ↑", "pageup", "Oldest", "ctrl_home", enabled=live, opp_enabled=live)
|
||||
return lane
|
||||
|
||||
def __init__(
|
||||
@@ -193,6 +246,10 @@ class ChatScreen(Screen):
|
||||
key_of: Callable[[str], str | None] | None = None,
|
||||
scope: str | None = None,
|
||||
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
|
||||
locator: Locator | None = None,
|
||||
pages: ContactPages | None = None,
|
||||
self_name: str | None = None,
|
||||
reaction_set: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Build the chat screen.
|
||||
|
||||
@@ -224,6 +281,17 @@ class ChatScreen(Screen):
|
||||
resend_unscoped: An async callable that sends the text of a channel message
|
||||
again, unscoped, and returns the stored message (channels only. With
|
||||
``None``, ^R does nothing there).
|
||||
locator: The located nodes, for ^L: the map on the author of the selected
|
||||
message (refer to :class:`~meshterm.ui.map_screen.Locator`). With ``None``,
|
||||
^L does nothing.
|
||||
pages: The contacts, for ^O: the node page of the author of the selected
|
||||
message (refer to :class:`~meshterm.ui.node_detail_screen.ContactPages`).
|
||||
With ``None``, ^O does nothing.
|
||||
self_name: The name of our node. A channel reaction names the author of its
|
||||
target, and our node is the author of each message that we sent. With
|
||||
``None``, our channel messages get no reactions.
|
||||
reaction_set: The emoji that ^E offers, in order. ``None`` offers
|
||||
:data:`~meshterm.core.reactions.DEFAULT_REACTIONS`.
|
||||
"""
|
||||
super().__init__()
|
||||
self._scope = scope if conversation.is_channel else None
|
||||
@@ -246,11 +314,21 @@ class ChatScreen(Screen):
|
||||
if conversation.is_channel or contact is None
|
||||
else (contact.public_key or contact.key_prefix or "")
|
||||
)
|
||||
self._messages = list(messages)
|
||||
# The reactions of the conversation, folded onto their targets. A room has none.
|
||||
self._fold = (
|
||||
None
|
||||
if conversation.is_room
|
||||
else reactions.ReactionFold(channel=conversation.is_channel, self_name=self_name)
|
||||
)
|
||||
self._reaction_set = reaction_set or reactions.DEFAULT_REACTIONS.split()
|
||||
self._peer_label = conversation.label
|
||||
self._messages = [m for m in messages if not self._folds(m)]
|
||||
self._send = send
|
||||
self._resend = resend
|
||||
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
|
||||
self._paths = paths
|
||||
self._locator = locator
|
||||
self._pages = pages
|
||||
self._names = names
|
||||
self._session = session
|
||||
self._editor = LineEditor()
|
||||
@@ -263,9 +341,12 @@ class ChatScreen(Screen):
|
||||
self._status = ""
|
||||
self._stick = True # keep the newest message visible until the user scrolls up
|
||||
self._paths_open = False # one paths dialog at a time
|
||||
self._map_open = False # one map at a time
|
||||
self._page_open = False # one node page at a time
|
||||
self._codes_open = False # one share screen of links at a time
|
||||
self._paste_open = False # one paste-confirm dialog at a time
|
||||
self._resend_open = False # one resend-unscoped confirm at a time
|
||||
self._react_open = False # one reaction dialog at a time
|
||||
# The selection: the index of the highlighted message (or None when the compose
|
||||
# line has the focus), and the body line where it rendered, so that the frame keeps
|
||||
# it visible.
|
||||
@@ -299,23 +380,66 @@ class ChatScreen(Screen):
|
||||
|
||||
Only the keys that do something show. ``^P paths`` must have a selected message to
|
||||
show the paths *of*. ``^U QR`` must have a selected message with a URL in it
|
||||
(refer to :meth:`_picked_urls`). ``^R retry failed`` must have something to retry
|
||||
(refer to :meth:`_retry_target`). The F-key lane uses the same rules to dim its
|
||||
slots.
|
||||
(refer to :meth:`_picked_urls`). ``^L map`` must have a selected message whose
|
||||
author has a location (refer to :meth:`_locate_target`). ``^O author`` must have a
|
||||
selected message whose author has a node page (refer to :meth:`_author_page`).
|
||||
``^R retry failed`` must have something to retry (refer to :meth:`_retry_target`).
|
||||
The F-key lane uses the same rules to dim its slots.
|
||||
"""
|
||||
if self._selected is not None:
|
||||
code = " · ^U QR" if self._picked_urls() else ""
|
||||
code = "^U QR" if self._picked_urls() else ""
|
||||
where = "^L map" if self._locate_target() is not None else ""
|
||||
who = "^O author" if self._author_page() is not None else ""
|
||||
react = "^E react" if self._can_react() else ""
|
||||
if self._is_channel and self._retry_target() is not None:
|
||||
# A selected scoped message of ours can be sent again unscoped, and the hint
|
||||
# must name that key. The "(@mention)" of the reply and ^End are removed to
|
||||
# keep the line inside 72 cells (Esc still cancels the selection, and Enter
|
||||
# still says what it does). ↑↓ is also removed when the hint must also name
|
||||
# the code of a link.
|
||||
pick = "" if code else " · ↑↓ pick"
|
||||
return f"Enter reply · ^P paths{code} · ^R resend unscoped{pick} · Esc cancel"
|
||||
if self._is_channel:
|
||||
return f"Enter reply (@mention) · ^P paths{code} · ↑↓ pick · ^End/Esc cancel"
|
||||
return f"Enter paths{code} · ↑↓ pick · ^End/Esc cancel"
|
||||
# still says what it does). If the line is still too long, ↑↓ goes first,
|
||||
# then ^P, which the hint of each other selected message names, then ^O.
|
||||
# The message is ours here, so ^O only opens our own page.
|
||||
return _fit_hint(
|
||||
[
|
||||
"Enter reply",
|
||||
"^P paths",
|
||||
code,
|
||||
where,
|
||||
who,
|
||||
react,
|
||||
"^R resend unscoped",
|
||||
"↑↓ pick",
|
||||
"Esc cancel",
|
||||
],
|
||||
spare=("↑↓ pick", "^P paths", "^O author", "^E react"),
|
||||
)
|
||||
if self._replies:
|
||||
# With all the keys of a selection, the long words do not fit in 72 cells.
|
||||
# Then the hint uses the short words of the resend hint above.
|
||||
hint = _fit_hint(
|
||||
[
|
||||
"Enter reply (@mention)",
|
||||
"^P paths",
|
||||
code,
|
||||
where,
|
||||
who,
|
||||
react,
|
||||
"↑↓ pick",
|
||||
"^End/Esc cancel",
|
||||
],
|
||||
spare=("↑↓ pick",),
|
||||
)
|
||||
if cell_len(hint) <= _HINT_CELLS:
|
||||
return hint
|
||||
# ^E goes after ↑↓ when even the short words do not fit. The keys of the
|
||||
# selection that were here before ^E keep their places.
|
||||
return _fit_hint(
|
||||
["Enter reply", "^P paths", code, where, who, react, "↑↓ pick", "Esc cancel"],
|
||||
spare=("↑↓ pick", "^E react"),
|
||||
)
|
||||
return _fit_hint(
|
||||
["Enter paths", code, where, who, react, "↑↓ pick", "^End/Esc cancel"],
|
||||
spare=("↑↓ pick", "^E react"),
|
||||
)
|
||||
if self._retry_target() is not None:
|
||||
if self._is_channel:
|
||||
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
|
||||
@@ -325,7 +449,14 @@ class ChatScreen(Screen):
|
||||
# --- live updates --------------------------------------------------------
|
||||
|
||||
def append(self, message: ChatMessage) -> None:
|
||||
"""Append an inbound message to the transcript and paint."""
|
||||
"""Append an inbound message to the transcript and paint.
|
||||
|
||||
A reaction goes onto the message that it reacts to, and the transcript does not
|
||||
move for it.
|
||||
"""
|
||||
if self._folds(message):
|
||||
self._session.invalidate()
|
||||
return
|
||||
self._messages.append(message)
|
||||
# Do not pull the viewport to the tail while the user selects a message to reply to.
|
||||
if self._selected is None:
|
||||
@@ -472,7 +603,8 @@ class ChatScreen(Screen):
|
||||
tick),
|
||||
* it is the selected message (the selection sets its style, not the message), or
|
||||
* its identity or ``acked`` is different from the cache (it was appended, it was
|
||||
replaced in place when a pending bubble resolved, or it was removed).
|
||||
replaced in place when a pending bubble resolved, or it was removed), or
|
||||
* its number of reactions is different from the cache (a reaction arrived).
|
||||
|
||||
Most importantly, this works for each message. It is not a cached prefix that
|
||||
stops at the first such row. Thus a message stays cheap to draw again, however far
|
||||
@@ -488,11 +620,12 @@ class ChatScreen(Screen):
|
||||
cached_ids = cache["ids"]
|
||||
cached_acked = cache["acked"]
|
||||
cached_selected = cache["selected"]
|
||||
cached_reacts = cache["reacts"]
|
||||
cached_boundaries = cache["boundaries"]
|
||||
cached_lines = cache["lines"]
|
||||
cache_count = len(cached_ids)
|
||||
else:
|
||||
cached_ids = cached_acked = cached_selected = cached_boundaries = ()
|
||||
cached_ids = cached_acked = cached_selected = cached_reacts = cached_boundaries = ()
|
||||
cached_lines = []
|
||||
cache_count = 0
|
||||
|
||||
@@ -506,6 +639,7 @@ class ChatScreen(Screen):
|
||||
ids: list[int] = []
|
||||
ackeds: list[bool | None] = []
|
||||
selecteds: list[bool] = []
|
||||
reacts: list[int] = []
|
||||
boundaries: list[int] = []
|
||||
prev_group: tuple[bool, str] | None = None
|
||||
prev_day = None
|
||||
@@ -521,6 +655,7 @@ class ChatScreen(Screen):
|
||||
new_day = day != prev_day
|
||||
selected = idx == self._selected
|
||||
pending = message.acked is None and message.outbound and not message.is_channel
|
||||
react_count = self._fold.count(message) if self._fold is not None else 0
|
||||
reusable = (
|
||||
not selected
|
||||
and not pending
|
||||
@@ -528,6 +663,7 @@ class ChatScreen(Screen):
|
||||
and not cached_selected[idx]
|
||||
and cached_ids[idx] == id(message)
|
||||
and cached_acked[idx] == message.acked
|
||||
and cached_reacts[idx] == react_count
|
||||
)
|
||||
if reusable:
|
||||
start = cached_boundaries[idx - 1] if idx > 0 else 0
|
||||
@@ -569,6 +705,7 @@ class ChatScreen(Screen):
|
||||
ids.append(id(message))
|
||||
ackeds.append(message.acked)
|
||||
selecteds.append(selected)
|
||||
reacts.append(react_count)
|
||||
boundaries.append(len(lines))
|
||||
|
||||
self._render_cache = {
|
||||
@@ -576,6 +713,7 @@ class ChatScreen(Screen):
|
||||
"ids": ids,
|
||||
"acked": ackeds,
|
||||
"selected": selecteds,
|
||||
"reacts": reacts,
|
||||
"lines": lines,
|
||||
"boundaries": boundaries,
|
||||
}
|
||||
@@ -604,14 +742,19 @@ class ChatScreen(Screen):
|
||||
sender looks exactly like that node as a hop in a route, also for our messages:
|
||||
the ``★``, never our name.
|
||||
|
||||
Only this name is in a chip. An ``@mention`` in a body (refer to
|
||||
:meth:`_render_mentions`) is a part of what the sender said. A chip for it puts a
|
||||
chip in the middle of a sentence.
|
||||
Our star comes from the direction of the message (``outbound``), not from the
|
||||
``"you"`` label. Thus a remote sender with the name ``you`` still gets the hue of
|
||||
its key, and does not look like our node.
|
||||
|
||||
The sender is the origin of the route of each message under it. Thus the chip
|
||||
closes on the point, the mark of a route that goes on. An ``@mention`` in a body is
|
||||
the other end, and it opens on the notch (refer to :meth:`_render_mentions`).
|
||||
"""
|
||||
if message.outbound:
|
||||
return name_chip("", you=True)
|
||||
return name_chip("", you=True, role=ORIGIN)
|
||||
# ``·`` is a channel line that arrived with no sender prefix: there is no key to use.
|
||||
return name_chip(sender, None if sender == "·" else self._header_key(sender, message))
|
||||
key = None if sender == "·" else self._header_key(sender, message)
|
||||
return name_chip(sender, key, role=ORIGIN)
|
||||
|
||||
def _header_key(self, sender: str, message: ChatMessage) -> str | None:
|
||||
"""The key that gives the colour of the sender chip of a group: the key of the name.
|
||||
@@ -651,14 +794,59 @@ class ChatScreen(Screen):
|
||||
prefix.append(f"{stamp:%H:%M} ", style="cursor" if selected else "muted")
|
||||
body_text = self._body_text(body, message, selected=selected)
|
||||
whole = [m.span() for m in _WORD.finditer(body_text.plain) if links.urls(m.group())]
|
||||
# A mention is a chip, and a chip never breaks across two lines.
|
||||
whole += [(s.start, s.end) for s in body_text.spans if str(s.style) == PATH_INK]
|
||||
lines = render_hanging(
|
||||
prefix, body_text, width, indent=prefix.cell_len, whole=whole, gutter=_URL_GUTTER
|
||||
)
|
||||
urls = links.urls(body) if _URL_CODES else []
|
||||
if urls:
|
||||
lines += render_lines(qr_strip(urls, width, indent=prefix.cell_len), width)
|
||||
badges = self._fold.badges(message) if self._fold is not None else []
|
||||
if badges:
|
||||
row, runs = self._badge_text(badges, selected=selected)
|
||||
lines += render_hanging(
|
||||
Text(" " * prefix.cell_len), row, width, indent=prefix.cell_len, whole=runs
|
||||
)
|
||||
return lines
|
||||
|
||||
def _badge_text(
|
||||
self, badges: list[reactions.Badge], *, selected: bool
|
||||
) -> tuple[Text, list[tuple[int, int]]]:
|
||||
"""The reactions on one message, as one line under its body, and the runs to keep whole.
|
||||
|
||||
Each emoji has its count after it. The count is ``accent`` when our node is one of
|
||||
the nodes that reacted, else ``muted``. On the selected message, the names of the
|
||||
nodes take the place of the count, each in the hue of its node, and our node is the
|
||||
white ``you``. Where the font has no emoji, an emoticon stands in for each default
|
||||
reaction (refer to :func:`~meshterm.ui.theme.emoticon`).
|
||||
"""
|
||||
text = Text()
|
||||
runs: list[tuple[int, int]] = []
|
||||
for index, badge in enumerate(badges):
|
||||
if index:
|
||||
text.append(" ")
|
||||
start = len(text)
|
||||
text.append(emoticon(badge.emoji))
|
||||
text.append(" ")
|
||||
if not selected:
|
||||
count = f"{_COUNT_MARK}{len(badge.reactors)}"
|
||||
text.append(count, style="accent" if badge.ours else "muted")
|
||||
runs.append((start, len(text)))
|
||||
continue
|
||||
for number, reactor in enumerate(badge.reactors):
|
||||
if number:
|
||||
text.append(", ", style="muted")
|
||||
if reactor.ours:
|
||||
text.append("you", style="you")
|
||||
else:
|
||||
name = reactor.name or self._peer_label
|
||||
key = self._peer_key if reactor.name is None else self._sender_key(name)
|
||||
text.append(name, style=name_style(name, key or None))
|
||||
if not number:
|
||||
runs.append((start, len(text))) # the emoji never ends a line alone
|
||||
return text, runs
|
||||
|
||||
def _body_text(self, body: str, message: ChatMessage, *, selected: bool) -> Text:
|
||||
"""Build the styled body of a message: mentions in colour, then the glyphs at the end."""
|
||||
text = self._render_mentions(body, selected=selected)
|
||||
@@ -693,13 +881,19 @@ class ChatScreen(Screen):
|
||||
return Text(f" · scope {message.scope}", style="muted")
|
||||
|
||||
def _render_mentions(self, body: str, *, selected: bool) -> Text:
|
||||
"""Render body text, and change each ``@[Name]`` token to a ``@Name`` in its hue.
|
||||
"""Render body text, and change each ``@[Name]`` token to a ``@Name`` chip.
|
||||
|
||||
The reply flow puts an ``@[Name]`` token in the compose line (refer to
|
||||
:meth:`_begin_reply`). Here it shows as a bare ``@Name`` in the stable hue of that
|
||||
sender. Thus the user sees that a mention belongs to the person that it names. The
|
||||
text around the mentions keeps the base style of the line (``cursor`` when the
|
||||
message is the selected reply target, or else no style).
|
||||
:meth:`_begin_reply`). Here it shows as ``@Name`` in a chip of the hue of that
|
||||
node. Thus the user sees that a mention belongs to the node that it names. The text
|
||||
around the mentions keeps the base style of the line (``cursor`` when the message
|
||||
is the selected reply target, or else no style).
|
||||
|
||||
The chip opens on the notch, because the node that a mention names is the
|
||||
destination of a route that came from the sender (refer to :meth:`_group_header`,
|
||||
whose chip closes on the point). The ``@`` stays in the chip. A chip keeps the words
|
||||
of the arrow mode, where a mention is ``@Name`` with no frame, as it was before the
|
||||
chip (JP, 2026-10-10).
|
||||
"""
|
||||
base = "cursor" if selected else None
|
||||
text = Text()
|
||||
@@ -708,7 +902,8 @@ class ChatScreen(Screen):
|
||||
if match.start() > pos:
|
||||
text.append(body[pos : match.start()], style=base)
|
||||
name = match.group(1)
|
||||
text.append(f"@{name}", style=self._sender_style(name, mention=True))
|
||||
key = self._sender_key(name, mention=True)
|
||||
text.append_text(name_chip(f"@{name}", key, role=DESTINATION))
|
||||
pos = match.end()
|
||||
if pos < len(body):
|
||||
text.append(body[pos:], style=base)
|
||||
@@ -744,44 +939,24 @@ class ChatScreen(Screen):
|
||||
)
|
||||
return Text("Enter to see the paths this message took · End to cancel", style="accent")
|
||||
|
||||
def _sender_style(self, sender: str, *, is_self: bool = False, mention: bool = False) -> str:
|
||||
"""Get a stable colour for a sender name, from the node key of the sender.
|
||||
|
||||
Our messages are white. This comes from ``is_self`` (the message is outbound), not
|
||||
from the ``"you"`` label. Thus a remote sender with the name ``you`` still gets a
|
||||
hue from the palette, and does not look like our node. ``·`` (unknown) takes
|
||||
``node.unknown``. Each other sender name is resolved to a key (the contacts, then
|
||||
the names that the recorder stored, and in a direct thread the *sender label*
|
||||
falls back to the key of the peer), and takes the hue of that key. A name that no
|
||||
known node has also stays ``node.unknown``: the rule of the app is that colour
|
||||
marks an identity that has a key.
|
||||
|
||||
Each grey here is the *node* grey, never ``muted``. An unidentified sender is
|
||||
content that you can still do actions on (select the message, open its paths),
|
||||
not chrome. The two greys must not follow each other: on the console, ``muted`` is
|
||||
the dark slot and ``node.unknown`` is the light one (JP, 2026-08-12).
|
||||
|
||||
``mention=True`` gives the style of an ``@mention`` in the body, not of a sender
|
||||
label. A mention names any person, so the fallback to the key of the peer must
|
||||
not apply. Thus a mention that is not resolved stays grey in a direct chat,
|
||||
exactly as it does in a channel.
|
||||
"""
|
||||
if is_self:
|
||||
return "you" # white, outside the range of sender hues, so always easy to see
|
||||
if sender == "·":
|
||||
return "node.unknown"
|
||||
return name_style(sender, self._sender_key(sender, mention=mention))
|
||||
|
||||
def _sender_key(self, sender: str, *, mention: bool = False) -> str | None:
|
||||
"""The key of a sender name, or ``None`` when nothing gives a key for it.
|
||||
|
||||
The contacts first, then the names that the recorder stored. In a *direct* thread,
|
||||
a sender label with no key falls back to the key of the peer, because the only two
|
||||
persons in the conversation are we and the peer. A mention names any person, so
|
||||
that fallback must not apply to a mention.
|
||||
persons in the conversation are we and the peer. A mention names any node, so
|
||||
that fallback must not apply to a mention. Thus a mention that is not resolved
|
||||
stays grey in a direct chat, exactly as it does in a channel.
|
||||
|
||||
The colour (:meth:`_sender_style`) and the chip (:meth:`_group_header`) share this
|
||||
method. Thus the two cannot disagree about the identity of a name.
|
||||
A name with no key is drawn in the *node* grey, never in ``muted`` (the chip does
|
||||
this, refer to :func:`~meshterm.ui.widgets.name_chip`). An unidentified sender is
|
||||
content that you can still do actions on (select the message, open its paths),
|
||||
not chrome. On the console, ``muted`` is the dark slot and ``node.unknown`` is the
|
||||
light one (JP, 2026-08-12).
|
||||
|
||||
The sender chip (:meth:`_group_header`) and the mention chips
|
||||
(:meth:`_render_mentions`) share this method. Thus the two cannot disagree about
|
||||
the identity of a name.
|
||||
"""
|
||||
key = self._key_of(sender)
|
||||
if not key and not self._multiparty and not mention:
|
||||
@@ -801,10 +976,10 @@ class ChatScreen(Screen):
|
||||
Ctrl+PgUp/PgDn (day dividers), and the viewport moves with it. Enter on a selected
|
||||
message starts a reply with an ``@mention`` in a channel, and opens the delivery
|
||||
paths in a direct chat. In the two types of chat, ^P opens the paths of the
|
||||
selected message, and ^U the QR codes of its links. The two do nothing when no
|
||||
message is selected. ^End (or a move past the newest message) goes back to the
|
||||
compose line. The first Esc clears the selection, and the second Esc leaves the
|
||||
screen.
|
||||
selected message, ^U the QR codes of its links, ^L the map on its author, and ^E the
|
||||
reaction dialog. The four do nothing when no message is selected. ^End (or a move
|
||||
past the newest message) goes back to the compose line. The first Esc clears the
|
||||
selection, and the second Esc leaves the screen.
|
||||
"""
|
||||
if action == "enter":
|
||||
if self._selected is not None:
|
||||
@@ -820,6 +995,12 @@ class ChatScreen(Screen):
|
||||
self._open_paths(self._selected)
|
||||
elif action == "url_code":
|
||||
self._open_codes()
|
||||
elif action == "locate":
|
||||
self._open_map()
|
||||
elif action == "author_page":
|
||||
self._open_author_page()
|
||||
elif action == "react":
|
||||
self._begin_react()
|
||||
elif action == "retry":
|
||||
if self._is_channel:
|
||||
self._begin_resend_unscoped()
|
||||
@@ -879,6 +1060,85 @@ class ChatScreen(Screen):
|
||||
|
||||
self._session.run_detached(run())
|
||||
|
||||
def _locate_target(self) -> MapMarker | None:
|
||||
"""The marker of the author of the selected message, or ``None`` when ^L does nothing.
|
||||
|
||||
The author is the node whose key gives the colour of the chip of the message (refer
|
||||
to :meth:`_header_key`). Our own message has our node as its author. A message with
|
||||
no sender, or an author that has no location, has no marker.
|
||||
"""
|
||||
if self._locator is None or self._selected is None or not self._messages:
|
||||
return None
|
||||
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
|
||||
if message.outbound:
|
||||
return self._locator.you()
|
||||
sender, _body = self._sender_and_body(message)
|
||||
if sender == "·":
|
||||
return None
|
||||
return self._locator.find(self._header_key(sender, message))
|
||||
|
||||
def _open_map(self) -> None:
|
||||
"""Open the map on the author of the selected message (^L). One map at a time.
|
||||
|
||||
The map is a screen, and it goes on the stack above the chat. Esc on the map comes
|
||||
back to the chat, with the same message selected.
|
||||
"""
|
||||
target = self._locate_target()
|
||||
if target is None or self._locator is None or self._map_open:
|
||||
return
|
||||
locator = self._locator
|
||||
self._map_open = True
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await locator.show(target)
|
||||
finally:
|
||||
self._map_open = False
|
||||
self._session.invalidate()
|
||||
|
||||
self._session.run_detached(run())
|
||||
|
||||
def _author_page(self) -> Callable[[], Awaitable[None]] | None:
|
||||
"""The node page that ^O opens for the selected message, or ``None`` when there is none.
|
||||
|
||||
The page belongs to the author of the message: our node for a message of ours, or
|
||||
the contact that the key of the author finds (refer to :meth:`_header_key`, the
|
||||
same key that gives the colour of the sender chip). A message with no sender, or an
|
||||
author that is not a contact, has no page. The hint and the F-key lane ask this at
|
||||
each paint, so that they name ^O only when it acts.
|
||||
"""
|
||||
pages = self._pages
|
||||
if pages is None or self._selected is None or not self._messages:
|
||||
return None
|
||||
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
|
||||
if message.outbound:
|
||||
return lambda: pages.show(None)
|
||||
sender, _body = self._sender_and_body(message)
|
||||
if sender == "·":
|
||||
return None
|
||||
contact = pages.find(self._header_key(sender, message))
|
||||
return None if contact is None else (lambda: pages.show(contact))
|
||||
|
||||
def _open_author_page(self) -> None:
|
||||
"""Open the node page of the author of the selected message (^O). One page at a time.
|
||||
|
||||
The page is a screen, and it goes on the stack above the chat. Esc on the page comes
|
||||
back to the chat, with the same message selected.
|
||||
"""
|
||||
page = self._author_page()
|
||||
if page is None or self._page_open:
|
||||
return
|
||||
self._page_open = True
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await page()
|
||||
finally:
|
||||
self._page_open = False
|
||||
self._session.invalidate()
|
||||
|
||||
self._session.run_detached(run())
|
||||
|
||||
def _picked_urls(self) -> list[str]:
|
||||
"""The URLs in the body of the selected message, in order. None if nothing is selected."""
|
||||
if self._selected is None or not self._messages:
|
||||
@@ -1070,7 +1330,7 @@ class ChatScreen(Screen):
|
||||
try:
|
||||
message = await self._send(text)
|
||||
if message is not None:
|
||||
self._messages.append(message)
|
||||
self._admit(message)
|
||||
self._status = ""
|
||||
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
|
||||
self._status = f"send failed: {exc}"
|
||||
@@ -1228,7 +1488,7 @@ class ChatScreen(Screen):
|
||||
try:
|
||||
message = await self._resend_unscoped(target)
|
||||
if message is not None:
|
||||
self._messages.append(message)
|
||||
self._admit(message)
|
||||
self._status = ""
|
||||
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
|
||||
self._status = f"resend failed: {exc}"
|
||||
@@ -1255,12 +1515,126 @@ class ChatScreen(Screen):
|
||||
self._session.invalidate()
|
||||
|
||||
def _swap(self, old: ChatMessage, new: ChatMessage) -> None:
|
||||
"""Replace an optimistic bubble with its stored message, in place."""
|
||||
"""Replace an optimistic bubble with its stored message, in place.
|
||||
|
||||
The stored message is the one that a reaction can name, because it has the
|
||||
timestamp that went out. Thus the fold takes it here, and not the bubble.
|
||||
"""
|
||||
if self._folds(new):
|
||||
self._discard(old)
|
||||
return
|
||||
try:
|
||||
self._messages[self._messages.index(old)] = new
|
||||
except ValueError: # pragma: no cover - the bubble is always still there
|
||||
self._messages.append(new)
|
||||
|
||||
def _folds(self, message: ChatMessage) -> bool:
|
||||
"""Give ``message`` to the fold. ``True`` for a reaction, which the transcript omits."""
|
||||
return self._fold is not None and self._fold.take(message)
|
||||
|
||||
def _admit(self, message: ChatMessage) -> None:
|
||||
"""Append a message that we sent, unless it is a reaction (the fold shows that one)."""
|
||||
if not self._folds(message):
|
||||
self._messages.append(message)
|
||||
|
||||
# --- reactions -------------------------------------------------------------
|
||||
|
||||
def _react_target(self) -> tuple[ChatMessage, str | None, str, int] | None:
|
||||
"""The selected message, and the author, body, and timestamp that a reaction names it by.
|
||||
|
||||
``None`` when nothing is selected, or when a reaction cannot name the selection: a
|
||||
room, a message that is still in flight, or a channel message with no author (a
|
||||
line with no ``Name: `` prefix, or one of ours when our name is not known).
|
||||
"""
|
||||
if self._fold is None or self._selected is None or not self._messages:
|
||||
return None
|
||||
message = self._messages[min(self._selected, len(self._messages) - 1)]
|
||||
named = self._fold.target_of(message)
|
||||
if named is None:
|
||||
return None
|
||||
author, body, timestamp = named
|
||||
if self._is_channel and not author:
|
||||
return None
|
||||
return message, author, body, timestamp
|
||||
|
||||
def _can_react(self) -> bool:
|
||||
"""Whether ^E can start a reaction: a selection that a reaction can name, and no send."""
|
||||
return not self._sending and self._composing and self._react_target() is not None
|
||||
|
||||
def _begin_react(self) -> None:
|
||||
"""Ask which emoji, then send the reaction to the selected message (^E).
|
||||
|
||||
The dialog is a question, so it floats as a box over the chat. The emoji of the
|
||||
``reaction_emoji`` preference come first, then each other emoji (refer to
|
||||
:func:`emoji_catalog`). Each row has the name of its emoji, so a typed word such as
|
||||
``fire`` or ``heart`` filters the list to the emoji that it names. A row that our
|
||||
node already sent to this message says so. A second copy of it would only count one
|
||||
time, so the selection of that row sends nothing. The reaction goes out as a usual
|
||||
message in the conversation. When it is stored, the fold puts it under its target
|
||||
(refer to :meth:`_admit`).
|
||||
"""
|
||||
target = self._react_target()
|
||||
if target is None or self._sending or self._react_open or self._fold is None:
|
||||
return
|
||||
message, author, body, timestamp = target
|
||||
fold = self._fold
|
||||
self._react_open = True
|
||||
sender, _ = self._sender_and_body(message)
|
||||
who = "your message" if message.outbound else sender
|
||||
|
||||
async def run() -> None:
|
||||
from .menus import menu_rows, section_heading
|
||||
|
||||
def row(emoji: str) -> tuple[str, str, str]:
|
||||
name = reactions.emoji_name(emoji)
|
||||
sent = fold.reacted(message, emoji)
|
||||
return emoticon(emoji), f"{name} · sent" if sent else name, emoji
|
||||
|
||||
mine = list(self._reaction_set)
|
||||
others = [emoji for emoji in emoji_catalog() if emoji not in mine]
|
||||
rows = menu_rows([row(emoji) for emoji in mine + others])
|
||||
items = [
|
||||
section_heading("Your reactions"),
|
||||
*rows[: len(mine)],
|
||||
section_heading("All emoji"),
|
||||
*rows[len(mine) :],
|
||||
]
|
||||
try:
|
||||
emoji = await self._session.select(
|
||||
f"React — {who}", items, filterable=True, floating=True
|
||||
)
|
||||
finally:
|
||||
self._react_open = False
|
||||
if not emoji or self._sending:
|
||||
self._session.invalidate()
|
||||
return
|
||||
if fold.reacted(message, emoji):
|
||||
self._status = "you already reacted with that"
|
||||
self._session.invalidate()
|
||||
return
|
||||
if self._is_channel:
|
||||
text = reactions.channel_text(emoji, author or "", body, timestamp)
|
||||
else:
|
||||
text = reactions.direct_text(emoji, body, timestamp)
|
||||
self._sending = True
|
||||
self._status = "reacting…"
|
||||
self._session.invalidate()
|
||||
try:
|
||||
sent_message = await self._send(text)
|
||||
if sent_message is not None:
|
||||
self._admit(sent_message)
|
||||
no_ack = not self._is_channel and sent_message.acked is False
|
||||
self._status = "reaction sent · no ack" if no_ack else ""
|
||||
else:
|
||||
self._status = ""
|
||||
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
|
||||
self._status = f"reaction failed: {exc}"
|
||||
finally:
|
||||
self._sending = False
|
||||
self._session.invalidate()
|
||||
|
||||
self._spawn(run())
|
||||
|
||||
def _discard(self, message: ChatMessage) -> None:
|
||||
"""Remove an optimistic bubble that never became a real message."""
|
||||
try:
|
||||
@@ -1269,6 +1643,47 @@ class ChatScreen(Screen):
|
||||
pass
|
||||
|
||||
|
||||
#: The blocks of Unicode where an emoji with the emoji presentation can be. A wide symbol
|
||||
#: between them is a CJK symbol or radical, not an emoji.
|
||||
_EMOJI_BLOCKS = ((0x2000, 0x2E7F), (0x1F000, 0x1FAFF))
|
||||
|
||||
#: The I Ching symbols, which Unicode 16 draws wide. They are symbols, not emoji: the
|
||||
#: firmware and the other apps never offer them.
|
||||
_NOT_EMOJI = frozenset(range(0x268A, 0x2690)) | frozenset(range(0x2630, 0x2638))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def emoji_catalog() -> tuple[str, ...]:
|
||||
"""Each single emoji that the reaction dialog offers, in the order of Unicode.
|
||||
|
||||
The standard library has no list of emoji. Thus the catalog takes two sets. The first is
|
||||
each symbol in the emoji blocks that Unicode draws wide by default (``🔥``, ``⌚``). The
|
||||
second is the emoji that are text by default (``❤``), which the table of Rich lists.
|
||||
Each of these gets the variation selector that asks for the emoji form (``❤️``), as the
|
||||
apps send it. A flag, a skin tone, and a joined sequence are not in the catalog, but a
|
||||
reaction with one still shows when it arrives. The names come from the Unicode database
|
||||
of the Python that runs MeshTerm, so a newer Python offers newer emoji.
|
||||
"""
|
||||
from rich._unicode_data import load
|
||||
|
||||
found: dict[int, str] = {}
|
||||
for first, last in _EMOJI_BLOCKS:
|
||||
for cp in range(first, last + 1):
|
||||
char = chr(cp)
|
||||
if cp in _NOT_EMOJI:
|
||||
continue
|
||||
if unicodedata.category(char) == "So" and unicodedata.east_asian_width(char) == "W":
|
||||
found[cp] = char
|
||||
for char in load("auto").narrow_to_wide:
|
||||
if not char.isascii() and len(char) == 1:
|
||||
found.setdefault(ord(char), char + "\ufe0f")
|
||||
return tuple(
|
||||
emoji
|
||||
for _, emoji in sorted(found.items())
|
||||
if not reactions._is_regional(emoji[0]) and reactions.is_reaction_emoji(emoji)
|
||||
)
|
||||
|
||||
|
||||
async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||||
"""Open the live chat screen for ``conversation`` and run it until the user closes it.
|
||||
|
||||
@@ -1314,7 +1729,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||||
)
|
||||
contacts = await ctx.devstate.contacts()
|
||||
names = _contact_names(contacts)
|
||||
key_of = trace_runner.make_name_key_resolver(contacts, ctx.repo.node_names())
|
||||
stored_names, named_at = ctx.repo.node_names_with_times()
|
||||
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
|
||||
|
||||
async def send(text: str) -> ChatMessage | None:
|
||||
if conversation.is_channel:
|
||||
@@ -1345,7 +1761,15 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||||
ctx, lambda: ctx.chat.resend_direct(conversation.contact, message)
|
||||
)
|
||||
|
||||
from .map_screen import Locator
|
||||
from .node_detail_screen import ContactPages
|
||||
|
||||
paths = await _make_paths_presenter(ctx, conversation, device)
|
||||
self_name: str | None = None
|
||||
try:
|
||||
self_name = str((await ctx.devstate.self_info()).get("name") or "") or None
|
||||
except Exception: # noqa: BLE001 - with no name, our channel messages get no reactions
|
||||
self_name = None
|
||||
screen = ChatScreen(
|
||||
conversation,
|
||||
history,
|
||||
@@ -1357,6 +1781,10 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||||
key_of=key_of,
|
||||
scope=scope,
|
||||
resend_unscoped=resend_unscoped,
|
||||
locator=await Locator.gather(ctx),
|
||||
pages=await ContactPages.gather(ctx),
|
||||
self_name=self_name,
|
||||
reaction_set=reactions.reaction_set(ctx.preferences.reaction_emoji),
|
||||
)
|
||||
ctx.chat.set_active(conversation.key)
|
||||
|
||||
@@ -1568,10 +1996,10 @@ async def _make_paths_presenter(
|
||||
|
||||
session = ctx.ui.session
|
||||
contacts = await ctx.devstate.contacts()
|
||||
stored_names = ctx.repo.node_names()
|
||||
stored_names, named_at = ctx.repo.node_names_with_times()
|
||||
resolve = trace_runner.make_node_resolver(contacts, stored_names)
|
||||
type_of = trace_runner.make_node_type_resolver(contacts)
|
||||
key_of = trace_runner.make_name_key_resolver(contacts, stored_names)
|
||||
key_of = trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at)
|
||||
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
|
||||
self_name: str | None = None
|
||||
self_key: str | None = None
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -825,6 +825,36 @@ def _glyph_identity(icon: str) -> str:
|
||||
return icon
|
||||
|
||||
|
||||
#: The emoticons that stand in for the default reactions where the font has no emoji (the
|
||||
#: PicoCalc and the Cardputer). Each default reaction is two cells wide, and so is each
|
||||
#: emoticon. Thus a badge keeps its width. A reaction that is not here goes through the fold
|
||||
#: at the render boundary, as each other emoji does.
|
||||
# fmt: off
|
||||
_EMOTICONS: dict[str, str] = {
|
||||
"👍": "+1", "👎": "-1", "❤️": "<3", "❤": "<3", "😂": ":D", "😮": ":O", "😢": ":(",
|
||||
}
|
||||
# fmt: on
|
||||
|
||||
|
||||
def emoticon(emoji: str) -> str:
|
||||
"""The rendering of a reaction on the platform: the emoji itself, or its emoticon.
|
||||
|
||||
Args:
|
||||
emoji: The emoji of a reaction (one glyph, refer to
|
||||
:func:`~meshterm.core.reactions.is_reaction_emoji`).
|
||||
|
||||
Returns:
|
||||
The emoji on a platform that draws emoji. Else its emoticon from
|
||||
:data:`_EMOTICONS`, or the emoji unchanged when it has none.
|
||||
"""
|
||||
return _emoticon_impl(emoji)
|
||||
|
||||
|
||||
def _emoticon_compact(emoji: str) -> str:
|
||||
"""A platform with no emoji: the emoticon of a reaction, when it has one."""
|
||||
return _EMOTICONS.get(emoji, emoji)
|
||||
|
||||
|
||||
def _glyph_compact(icon: str) -> str:
|
||||
"""The PicoCalc: icons become their single-cell console-font glyph."""
|
||||
return _GLYPH_MAP.get(icon, icon)
|
||||
@@ -1099,6 +1129,7 @@ def snr_style(snr: float | None) -> str:
|
||||
_ACTIVE_THEME: Theme = MESH_THEME
|
||||
_node_impl: Callable[[str], str] = _node_style_spectrum
|
||||
_glyph_impl: Callable[[str], str] = _glyph_identity
|
||||
_emoticon_impl: Callable[[str], str] = _glyph_identity
|
||||
_fold_impl: Callable[[str], str] = _no_fold
|
||||
|
||||
#: The map from a marker colour to RGB. :func:`mark_rgb` fills it, and a platform switch
|
||||
@@ -1109,11 +1140,12 @@ _STYLE_RGB: dict[str, tuple[int, int, int]] = {}
|
||||
@on_platform
|
||||
def _bind(platform: Platform) -> None:
|
||||
"""Bind the choices of the theme that depend on the platform (runs now and at each switch)."""
|
||||
global _ACTIVE_THEME, _node_impl, _glyph_impl, _fold_impl, _FOLD_TABLE
|
||||
global _ACTIVE_THEME, _node_impl, _glyph_impl, _emoticon_impl, _fold_impl, _FOLD_TABLE
|
||||
global _FOLD_FONT, _FOLD_ALLOWED, _FOLD_QUANTIZE
|
||||
_ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16
|
||||
_node_impl = _node_style_spectrum if platform.truecolor else _node_style_quantized
|
||||
_glyph_impl = _glyph_identity if platform.emoji else _glyph_compact
|
||||
_emoticon_impl = _glyph_identity if platform.emoji else _emoticon_compact
|
||||
font = FONTS.get(platform.font)
|
||||
if font is not None:
|
||||
_FOLD_FONT = font
|
||||
|
||||
@@ -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,17 @@ from .spinner import spinner_interval
|
||||
#: ``IXON``/``IXOFF``, so ^Q is not XON here.
|
||||
_CTRL_LETTER_CHORDS: dict[str, str] = {
|
||||
"c": "quit",
|
||||
# ^L logs in to the room that a room screen shows.
|
||||
"l": "login",
|
||||
# ^E reacts to the selected message with an emoji. The raw mode of prompt_toolkit has no
|
||||
# line editing, so ^E is not "end of line" here (the End key moves the caret).
|
||||
"e": "react",
|
||||
# ^G logs in to the room that a room screen shows. In the input, ^G is not a special key:
|
||||
# it is the bell only in output, and no terminal or multiplexer takes it.
|
||||
"g": "login",
|
||||
"l": "locate",
|
||||
# ^O opens the node page of the author of the selected message. The raw mode of
|
||||
# prompt_toolkit clears IEXTEN, so a terminal on macOS or BSD does not take ^O to
|
||||
# discard output.
|
||||
"o": "author_page",
|
||||
"p": "paths",
|
||||
"q": "quit",
|
||||
"r": "retry",
|
||||
@@ -108,7 +123,6 @@ _CTRL_LETTER_CHORDS: dict[str, str] = {
|
||||
"u": "url_code",
|
||||
"v": "paste_clipboard",
|
||||
"w": "to_menu",
|
||||
"y": "locate",
|
||||
}
|
||||
|
||||
#: Maps the prompt_toolkit keys to the normalized action names that the screens understand.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1616,14 +1673,19 @@ class _ScriptedDevice:
|
||||
def __init__(self, acks: list) -> None:
|
||||
self._acks = list(acks)
|
||||
self.sent: list[str] = []
|
||||
# The timestamp and the try number of each send, in order.
|
||||
self.stamps: list[tuple[int | None, int]] = []
|
||||
|
||||
async def connect(self) -> None: # for _StubContext.device()
|
||||
return None
|
||||
|
||||
async def send_direct_message(self, contact: Contact, text: str):
|
||||
async def send_direct_message(
|
||||
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
|
||||
):
|
||||
from meshterm.core.models import Delivery
|
||||
|
||||
self.sent.append(text)
|
||||
self.stamps.append((timestamp, attempt))
|
||||
ack = self._acks.pop(0) if self._acks else None
|
||||
return Delivery(f"{len(self.sent):08x}", ack)
|
||||
|
||||
@@ -2298,29 +2360,16 @@ async def test_channel_slot_change_drops_the_cached_channel_list(repo: Repositor
|
||||
await device.disconnect()
|
||||
|
||||
|
||||
def test_chat_fkey_lane_steps_by_day_on_the_free_left_pair() -> None:
|
||||
"""The sections of the transcript are days, so F1 and F2 name the section step ``Day``.
|
||||
def test_chat_fkey_lane_has_no_day_jump() -> None:
|
||||
"""No chip sends the day jump: JP removed ``Day ↑`` and ``Day ↓`` (2026-10-10).
|
||||
|
||||
The keys and the actions are the same as ``Sect ↑`` and ``Sect ↓`` of a grouped select
|
||||
list. The lane names what the sections are here. The chips are lit only if there is
|
||||
more than one day to step between, as a list with one section has no section jump.
|
||||
The desktop keeps the jump on ^PgUp and ^PgDn.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from meshterm.core.models import utcnow
|
||||
|
||||
now = utcnow()
|
||||
one_day = [ChatMessage(text="hi", peer="d4e5f6a7", created_at=now)]
|
||||
single = _screen(_StubSession(), send=None, messages=one_day)
|
||||
assert [pair.label for pair in single.picocalc_lyra_lane[:2]] == ["Day ↑", "Day ↓"]
|
||||
assert [pair.action for pair in single.picocalc_lyra_lane[:2]] == [
|
||||
"ctrl_pageup",
|
||||
"ctrl_pagedown",
|
||||
]
|
||||
assert not any(
|
||||
pair.enabled for pair in single.picocalc_lyra_lane[:2]
|
||||
) # one day: no place to step to
|
||||
|
||||
spread = _screen(
|
||||
_StubSession(),
|
||||
send=None,
|
||||
@@ -2329,12 +2378,57 @@ def test_chat_fkey_lane_steps_by_day_on_the_free_left_pair() -> None:
|
||||
ChatMessage(text="now", peer="d4e5f6a7", created_at=now),
|
||||
],
|
||||
)
|
||||
assert all(pair.enabled for pair in spread.picocalc_lyra_lane[:2])
|
||||
# A left-hand pair rises toward F1, so a step back through the transcript is the outer
|
||||
# key. This is the same direction as the pager on the right.
|
||||
assert fkeys.PICOCALC_LYRA_DECK.action_for(spread.picocalc_lyra_lane, 1) == "ctrl_pageup"
|
||||
spread.handle(fkeys.PICOCALC_LYRA_DECK.action_for(spread.picocalc_lyra_lane, 1))
|
||||
assert spread._selected == 0 # moved to the first message of the older day
|
||||
lane = spread.picocalc_lyra_lane
|
||||
actions = {action for pair in lane if pair for action in (pair.action, pair.opp_action)}
|
||||
assert not actions & {"ctrl_pageup", "ctrl_pagedown"}
|
||||
deck = fkeys.PICOCALC_LYRA_DECK
|
||||
assert "Day" not in deck.lane_text(lane).plain + deck.lane_text(lane, shifted=True).plain
|
||||
|
||||
spread.handle("ctrl_pageup")
|
||||
assert spread._selected == 0 # the key still moves to the first message of the older day
|
||||
|
||||
|
||||
def test_channel_and_dm_lanes_share_f1_to_f3_and_keep_their_own_verbs_behind_shift() -> None:
|
||||
"""The plain bank of F1 to F3 is the same in each conversation (JP, 2026-10-10).
|
||||
|
||||
The verbs that the screens share are there, in the order of the hint. The Shift bank
|
||||
has the verbs of one screen: ``Retry`` in a DM and ``Resend`` in a channel, both ^R.
|
||||
"""
|
||||
from tests.conftest import recording_locator
|
||||
|
||||
messages = [ChatMessage(text="hi", peer="d4e5f6a7")]
|
||||
direct = ChatScreen(
|
||||
Conversation(
|
||||
label="Alice",
|
||||
is_channel=False,
|
||||
contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"),
|
||||
),
|
||||
messages,
|
||||
send=None,
|
||||
names={},
|
||||
session=_StubSession(),
|
||||
locator=recording_locator([]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async def resend_unscoped(message: ChatMessage) -> ChatMessage:
|
||||
return message
|
||||
|
||||
channel = ChatScreen(
|
||||
Conversation(label="#general", is_channel=True, channel_id="1"),
|
||||
[ChatMessage(text="Bob: hi", is_channel=True, channel_idx=0)],
|
||||
send=None,
|
||||
names={},
|
||||
session=_StubSession(),
|
||||
resend_unscoped=resend_unscoped,
|
||||
locator=recording_locator([]), # type: ignore[arg-type]
|
||||
)
|
||||
for screen in (direct, channel):
|
||||
lane = screen.picocalc_lyra_lane
|
||||
assert [pair.label for pair in lane[:3]] == ["Paths", "URL→QR", "Map"]
|
||||
assert [pair.action for pair in lane[:3]] == ["paths", "url_code", "locate"]
|
||||
assert direct.picocalc_lyra_lane[0].opp_label == "Retry"
|
||||
assert channel.picocalc_lyra_lane[0].opp_label == "Resend"
|
||||
assert channel.picocalc_lyra_lane[0].opp_action == direct.picocalc_lyra_lane[0].opp_action
|
||||
|
||||
|
||||
# -- URL codes (the PicoCalc) ---------------------------------------------------
|
||||
@@ -2522,10 +2616,8 @@ def test_ctrl_u_is_named_only_on_a_picked_message_with_a_link() -> None:
|
||||
linked.handle("up")
|
||||
plain.handle("up")
|
||||
assert "^U QR" in linked.footer_hint and "^U" not in plain.footer_hint
|
||||
assert (
|
||||
fkeys.PICOCALC_LYRA_DECK.action_for(linked.picocalc_lyra_lane, 7) == "url_code"
|
||||
) # Shift+F2
|
||||
assert linked.picocalc_lyra_lane[1].opp_enabled and not plain.picocalc_lyra_lane[1].opp_enabled
|
||||
assert fkeys.PICOCALC_LYRA_DECK.action_for(linked.picocalc_lyra_lane, 2) == "url_code" # F2
|
||||
assert linked.picocalc_lyra_lane[1].enabled and not plain.picocalc_lyra_lane[1].enabled
|
||||
|
||||
# The hint of each selection state, with a link in it, fits in the 72-cell budget.
|
||||
direct = _screen(
|
||||
@@ -2558,7 +2650,7 @@ def test_on_the_cardputer_a_link_takes_the_share_screen_as_on_the_desktop() -> N
|
||||
screen = _url_chat(f"the road list is at {url}")
|
||||
assert not _code_rows(_strip_ansi(screen._render_grouped(53)).splitlines())
|
||||
screen.handle("up")
|
||||
assert fkeys.CARDPUTER_ZERO_DECK.action_for(screen.cardputer_zero_lane, 17) == "url_code"
|
||||
assert fkeys.CARDPUTER_ZERO_DECK.action_for(screen.cardputer_zero_lane, 5) == "url_code"
|
||||
|
||||
share = QrScreen(url, title="Links")
|
||||
share.note_viewport(14)
|
||||
@@ -2623,7 +2715,7 @@ class _AckingMeshCore:
|
||||
if sub.event_type is EventType.ACK:
|
||||
sub.callback(event)
|
||||
|
||||
async def send_msg(self, pub, text): # noqa: ANN001, ANN201
|
||||
async def send_msg(self, pub, text, timestamp=None, attempt=0): # noqa: ANN001, ANN201
|
||||
from types import SimpleNamespace
|
||||
|
||||
if self._during:
|
||||
@@ -2744,3 +2836,244 @@ def test_only_the_acks_the_radio_could_still_push_are_awaited(repo: Repository)
|
||||
assert messages[0].acked is False
|
||||
chat._settle_ack(f"{39:08x}")
|
||||
assert messages[39].acked is True
|
||||
|
||||
|
||||
# --- ^L: the map on the author ------------------------------------------------------------
|
||||
|
||||
#: A node with a location, and our node.
|
||||
_ALICE_AT = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="a1" * 32)
|
||||
_US_AT = MapMarker(label="Base", lat=45.4, lon=-73.5, is_self=True, key="ee" * 32)
|
||||
|
||||
|
||||
async def test_chat_locate_opens_the_map_on_the_author_of_the_selected_message() -> None:
|
||||
"""^L opens the map on the author of the selected message, and is named only where it acts."""
|
||||
from tests.conftest import recording_locator
|
||||
|
||||
locator = recording_locator([_ALICE_AT, _US_AT])
|
||||
keys = {"alice": "a1" * 32, "bob": "b2" * 32}
|
||||
screen = ChatScreen(
|
||||
Conversation(label="#public", is_channel=True, channel_idx=0),
|
||||
[
|
||||
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0),
|
||||
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0),
|
||||
ChatMessage(text="hello", outbound=True, is_channel=True, channel_idx=0),
|
||||
],
|
||||
send=None,
|
||||
names={},
|
||||
session=_StubSession(),
|
||||
key_of=lambda name: keys.get(name.casefold()),
|
||||
locator=locator, # type: ignore[arg-type]
|
||||
)
|
||||
# With no selected message, ^L does nothing. The hint does not name it, and its chip on
|
||||
# F3 is dim.
|
||||
screen.handle("locate")
|
||||
await asyncio.sleep(0)
|
||||
assert locator.shown == []
|
||||
assert "^L" not in screen.footer_hint
|
||||
assert screen.picocalc_lyra_lane[2].label == "Map"
|
||||
assert screen.picocalc_lyra_lane[2].enabled is False
|
||||
|
||||
# Our own message: its author is our node.
|
||||
screen.handle("up")
|
||||
assert "^L map" in screen.footer_hint
|
||||
screen.handle("locate")
|
||||
await asyncio.sleep(0)
|
||||
assert locator.shown == [_US_AT]
|
||||
|
||||
# Bob has a key but no location, so there is nothing to show.
|
||||
screen.handle("up")
|
||||
assert "^L" not in screen.footer_hint
|
||||
assert screen.picocalc_lyra_lane[2].enabled is False
|
||||
screen.handle("locate")
|
||||
await asyncio.sleep(0)
|
||||
assert locator.shown == [_US_AT]
|
||||
|
||||
# Alice has a location. The chip is lit, and F3 sends the same action as ^L.
|
||||
screen.handle("up")
|
||||
assert "^L map" in screen.footer_hint
|
||||
assert screen.picocalc_lyra_lane[2].enabled is True
|
||||
assert fkeys.PICOCALC_LYRA_DECK.action_for(screen.picocalc_lyra_lane, 3) == "locate"
|
||||
screen.handle("locate")
|
||||
await asyncio.sleep(0)
|
||||
assert locator.shown == [_US_AT, _ALICE_AT]
|
||||
|
||||
|
||||
def test_chat_without_a_locator_has_no_map_chip() -> None:
|
||||
"""A chat that cannot locate leaves F3 empty, not dim."""
|
||||
screen = _screen(_StubSession(), send=None, messages=[ChatMessage(text="hi", peer="d4e5f6a7")])
|
||||
assert screen.picocalc_lyra_lane[2] is None
|
||||
|
||||
|
||||
async def test_chat_author_page_opens_the_node_page_of_the_author_of_the_selection() -> None:
|
||||
"""^O opens the node page of the author of the selected message, named only where it acts.
|
||||
|
||||
It is the twin of ^L: the same author, found by the same key. Its chip is ``Author``,
|
||||
on the Shift half of F3, behind ``Map``.
|
||||
"""
|
||||
from tests.conftest import recording_pages
|
||||
|
||||
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
|
||||
pages = recording_pages([alice])
|
||||
keys = {"alice": "a1" * 32, "bob": "b2" * 32}
|
||||
screen = ChatScreen(
|
||||
Conversation(label="#public", is_channel=True, channel_idx=0),
|
||||
[
|
||||
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0),
|
||||
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0),
|
||||
ChatMessage(text="hello", outbound=True, is_channel=True, channel_idx=0),
|
||||
],
|
||||
send=None,
|
||||
names={},
|
||||
session=_StubSession(),
|
||||
key_of=lambda name: keys.get(name.casefold()),
|
||||
pages=pages, # type: ignore[arg-type]
|
||||
)
|
||||
# With no selected message, ^O does nothing. The hint does not name it, and its chip is
|
||||
# dim. The screen has no locator, so the plain half of F3 is empty.
|
||||
screen.handle("author_page")
|
||||
await asyncio.sleep(0)
|
||||
assert pages.shown == []
|
||||
assert "^O" not in screen.footer_hint
|
||||
lane = screen.picocalc_lyra_lane
|
||||
assert (lane[2].label, lane[2].opp_label) == ("", "Author")
|
||||
assert lane[2].opp_enabled is False
|
||||
|
||||
# Our own message: its author is our node, whose page has no contact.
|
||||
screen.handle("up")
|
||||
assert "^O author" in screen.footer_hint
|
||||
screen.handle("author_page")
|
||||
await asyncio.sleep(0)
|
||||
assert pages.shown == [None]
|
||||
|
||||
# Bob has a key but he is not a contact, so there is no page to open.
|
||||
screen.handle("up")
|
||||
assert "^O" not in screen.footer_hint
|
||||
assert screen.picocalc_lyra_lane[2].opp_enabled is False
|
||||
screen.handle("author_page")
|
||||
await asyncio.sleep(0)
|
||||
assert pages.shown == [None]
|
||||
|
||||
# Alice is a contact. The chip is lit, and Shift+F3 sends the same action as ^O.
|
||||
screen.handle("up")
|
||||
assert "^O author" in screen.footer_hint
|
||||
assert screen.picocalc_lyra_lane[2].opp_enabled is True
|
||||
assert fkeys.PICOCALC_LYRA_DECK.action_for(screen.picocalc_lyra_lane, 8) == "author_page"
|
||||
screen.handle("author_page")
|
||||
await asyncio.sleep(0)
|
||||
assert pages.shown == [None, alice]
|
||||
|
||||
|
||||
def test_a_direct_chat_finds_the_page_of_its_peer() -> None:
|
||||
"""In a DM, the author of an inbound message is the peer, and the peer is a contact."""
|
||||
from tests.conftest import recording_pages
|
||||
|
||||
peer = Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7")
|
||||
screen = ChatScreen(
|
||||
Conversation(label="Alice", is_channel=False, contact=peer),
|
||||
[ChatMessage(text="hi", peer="d4e5f6a7")],
|
||||
send=None,
|
||||
names={"d4e5f6a7": "Alice"},
|
||||
session=_StubSession(),
|
||||
pages=recording_pages([peer]), # type: ignore[arg-type]
|
||||
)
|
||||
screen.handle("up")
|
||||
assert "^O author" in screen.footer_hint
|
||||
assert cell_len(screen.footer_hint) <= 72
|
||||
|
||||
|
||||
def test_chat_hint_with_all_its_keys_fits_in_72_cells() -> None:
|
||||
"""The longest hint (a scoped message of ours, with a link, on the map) fits in 72 cells.
|
||||
|
||||
The ↑↓ atom goes first, then ^P. The keys that only this message offers stay.
|
||||
"""
|
||||
|
||||
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
|
||||
return None
|
||||
|
||||
from tests.conftest import recording_locator
|
||||
|
||||
screen = ChatScreen(
|
||||
Conversation(label="#ops", is_channel=True, channel_idx=1),
|
||||
[
|
||||
ChatMessage(
|
||||
text="map at https://meshterm.net",
|
||||
outbound=True,
|
||||
is_channel=True,
|
||||
channel_idx=1,
|
||||
scope="yul",
|
||||
)
|
||||
],
|
||||
send=None,
|
||||
names={},
|
||||
session=_StubSession(),
|
||||
scope="yul",
|
||||
resend_unscoped=resend_unscoped,
|
||||
locator=recording_locator([_US_AT]), # type: ignore[arg-type]
|
||||
)
|
||||
screen.handle("up")
|
||||
hint = screen.footer_hint
|
||||
assert cell_len(hint) <= 72
|
||||
for atom in ("Enter reply", "^U QR", "^L map", "^R resend unscoped", "Esc cancel"):
|
||||
assert atom in hint
|
||||
assert "↑↓ pick" not in hint and "^P paths" not in hint
|
||||
|
||||
|
||||
def test_chat_resend_hint_drops_our_own_page_third() -> None:
|
||||
"""With ^O too, the resend hint drops ↑↓, then ^P, then ^O: the message is ours."""
|
||||
|
||||
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
|
||||
return None
|
||||
|
||||
from tests.conftest import recording_locator, recording_pages
|
||||
|
||||
screen = ChatScreen(
|
||||
Conversation(label="#ops", is_channel=True, channel_idx=1),
|
||||
[
|
||||
ChatMessage(
|
||||
text="map at https://meshterm.net",
|
||||
outbound=True,
|
||||
is_channel=True,
|
||||
channel_idx=1,
|
||||
scope="yul",
|
||||
)
|
||||
],
|
||||
send=None,
|
||||
names={},
|
||||
session=_StubSession(),
|
||||
scope="yul",
|
||||
resend_unscoped=resend_unscoped,
|
||||
locator=recording_locator([_US_AT]), # type: ignore[arg-type]
|
||||
pages=recording_pages([]), # type: ignore[arg-type]
|
||||
)
|
||||
screen.handle("up")
|
||||
hint = screen.footer_hint
|
||||
assert cell_len(hint) <= 72
|
||||
for atom in ("Enter reply", "^U QR", "^L map", "^R resend unscoped", "Esc cancel"):
|
||||
assert atom in hint
|
||||
assert "↑↓ pick" not in hint and "^P paths" not in hint and "^O" not in hint
|
||||
|
||||
|
||||
def test_chat_reply_hint_with_every_key_of_a_selection_fits_in_72_cells() -> None:
|
||||
"""A message of a located contact, with a link: the reply hint takes its short words.
|
||||
|
||||
With ^U, ^L, and ^O, the long words (``(@mention)``, ``^End/Esc``) do not fit. The
|
||||
short words of the resend hint do, and then each key of the selection stays.
|
||||
"""
|
||||
from tests.conftest import recording_locator, recording_pages
|
||||
|
||||
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
|
||||
screen = ChatScreen(
|
||||
Conversation(label="#public", is_channel=True, channel_idx=0),
|
||||
[ChatMessage(text="Alice: map at https://meshterm.net", is_channel=True, channel_idx=0)],
|
||||
send=None,
|
||||
names={},
|
||||
session=_StubSession(),
|
||||
key_of=lambda name: "a1" * 32 if name.casefold() == "alice" else None,
|
||||
locator=recording_locator([_ALICE_AT]), # type: ignore[arg-type]
|
||||
pages=recording_pages([alice]), # type: ignore[arg-type]
|
||||
)
|
||||
screen.handle("up")
|
||||
hint = screen.footer_hint
|
||||
assert cell_len(hint) <= 72
|
||||
for atom in ("Enter reply", "^P paths", "^U QR", "^L map", "^O author", "Esc cancel"):
|
||||
assert atom in hint
|
||||
@@ -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
|
||||
@@ -244,7 +244,8 @@ def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> N
|
||||
screen, and does not name in the list, fails here. Without the test, the mark would get
|
||||
a blank cell next to it with no warning. The test skips prose, because MeshTerm reads
|
||||
docstrings and bare strings and does not draw them. It also skips the module that defines
|
||||
the list.
|
||||
the list. It also skips the vocabulary of reactions, which is content that other apps
|
||||
send.
|
||||
"""
|
||||
from rich._unicode_data import load
|
||||
|
||||
@@ -254,13 +255,17 @@ def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> N
|
||||
if not char.isascii() and char not in cells._SINGLE_CELLS
|
||||
}
|
||||
# Inputs to the substitution table of the PicoCalc, which maps them to glyphs that its
|
||||
# font has. A terminal that shows emoji never draws them, so they are not marks.
|
||||
fold_inputs = {"↪", "✔", "✖"}
|
||||
# font has. A terminal that shows emoji never draws them, so they are not marks. The
|
||||
# heart is the key of an emoticon (theme._EMOTICONS), which only a handheld uses.
|
||||
fold_inputs = {"↪", "✔", "✖", "❤"}
|
||||
|
||||
package = pathlib.Path(ew.__file__).parents[2]
|
||||
drawn: set[str] = set()
|
||||
for path in package.rglob("*.py"):
|
||||
if path.name == "emoji_width.py":
|
||||
# The reactions module holds the emoji that other apps send (the defaults of
|
||||
# MeshCore One, the table of meshcore-open). These are content, not marks of the
|
||||
# app, and each one that is text-default carries its variation selector.
|
||||
if path.name in ("emoji_width.py", "reactions.py"):
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
prose = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -735,6 +735,45 @@ def _chat_urls(cols: int, rows: int) -> Screen:
|
||||
return ChatScreen(conv, messages, send=None, names={}, session=_GallerySession(cols, rows))
|
||||
|
||||
|
||||
def _chat_reactions(cols: int, rows: int) -> Screen:
|
||||
"""A selected channel message with reactions: the names of the nodes take the counts' place.
|
||||
|
||||
Five nodes react with three emoji, one of them ours. The selection draws the widest line
|
||||
that reactions make, and it wraps at the indent of the body. On the PicoCalc, emoticons
|
||||
stand in for the emoji that have one.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from meshterm.core import reactions
|
||||
|
||||
stamp = 1_760_000_000
|
||||
at = datetime.fromtimestamp(stamp, tz=timezone.utc)
|
||||
|
||||
def react(name: str, emoji: str) -> ChatMessage:
|
||||
body = reactions.channel_text(emoji, "Alice", "bridge on 3rd is open again", stamp)
|
||||
return ChatMessage(text=f"{name}: {body}", is_channel=True, created_at=at)
|
||||
|
||||
conv = Conversation(label="Lakeside emergency", is_channel=True, channel_idx=2)
|
||||
messages = [
|
||||
ChatMessage(text="Alice: bridge on 3rd is open again", is_channel=True, created_at=at),
|
||||
react("Hilltop-Repeater", "👍"),
|
||||
react("Bob", "👍"),
|
||||
react("Carol-Mobile", "❤️"),
|
||||
react("Dave", "🔥"),
|
||||
ChatMessage(
|
||||
text=reactions.channel_text("👍", "Alice", "bridge on 3rd is open again", stamp),
|
||||
outbound=True,
|
||||
is_channel=True,
|
||||
created_at=at,
|
||||
),
|
||||
]
|
||||
screen = ChatScreen(
|
||||
conv, messages, send=None, names={}, session=_GallerySession(cols, rows), self_name="Me"
|
||||
)
|
||||
screen.handle("up")
|
||||
return screen
|
||||
|
||||
|
||||
def _chat_long_url(cols: int, rows: int) -> Screen:
|
||||
"""A picked message with a link that is wider than the body lane.
|
||||
|
||||
@@ -795,6 +834,9 @@ class _PickerRepo:
|
||||
def node_names(self) -> dict:
|
||||
return {}
|
||||
|
||||
def node_names_with_times(self) -> tuple[dict, dict]:
|
||||
return {}, {}
|
||||
|
||||
|
||||
def _chat_picker(cols: int, rows: int) -> Screen:
|
||||
"""The conversation picker, built through the tool's own row funnel.
|
||||
@@ -1735,6 +1777,7 @@ _ENTRIES: list[_Entry] = [
|
||||
_Entry("chat", _chat),
|
||||
_Entry("chat_channel_scoped", _chat_channel_scoped),
|
||||
_Entry("chat_urls", _chat_urls),
|
||||
_Entry("chat_reactions", _chat_reactions),
|
||||
_Entry("chat_long_url", _chat_long_url),
|
||||
_Entry("chat_picker", _chat_picker),
|
||||
_Entry("room", _room),
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,436 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Emoji reactions: the wire format of MeshCore One, the fold, the send path, and the chat.
|
||||
|
||||
The parse cases come from the tests of MeshCore One (``ReactionParserTests.swift`` and
|
||||
``MeshCoreOpenReactionParserTests.swift``). Thus MeshTerm reads the same bodies as the app
|
||||
whose format it shares. The hash test calculates the expected value a second way, with the
|
||||
Base32 of the standard library, so that the test does not only repeat the code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from meshterm.core import reactions
|
||||
from meshterm.core.config import Settings
|
||||
from meshterm.core.models import ChatMessage, Contact, Conversation, Delivery, Message
|
||||
from meshterm.core.preferences import Preferences
|
||||
from meshterm.core.reactions import MC1, MCO, MCO_V1, ReactionFold, parse
|
||||
from meshterm.persistence.repository import Repository
|
||||
from meshterm.services.chat_service import ChatService
|
||||
from meshterm.ui.chat import ChatScreen
|
||||
from tests.conftest import plain
|
||||
|
||||
_T = 1_704_067_200 # 2024-01-01 00:00:00 UTC
|
||||
|
||||
|
||||
def _at(seconds: int) -> datetime:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
|
||||
|
||||
# -- the hash -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_hash_is_crockford_base32_of_the_first_five_bytes_of_sha256() -> None:
|
||||
"""SHA-256 of the UTF-8 body and the little-endian timestamp, five bytes, Crockford Base32."""
|
||||
digest = hashlib.sha256(b"Hello" + _T.to_bytes(4, "little")).digest()[:5]
|
||||
rfc = base64.b32encode(digest).decode() # five bytes make exactly eight characters
|
||||
expected = rfc.translate(
|
||||
str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "0123456789abcdefghjkmnpqrstvwxyz")
|
||||
)
|
||||
assert reactions.message_hash("Hello", _T) == expected
|
||||
assert reactions.message_hash("Hello", _T + 1) != expected
|
||||
assert reactions.message_hash("World", _T) != expected
|
||||
|
||||
|
||||
def test_the_texts_that_meshterm_sends_name_their_target() -> None:
|
||||
"""A channel reaction names the author first. A direct one has the emoji and the hash."""
|
||||
digest = reactions.message_hash("hi all", _T)
|
||||
assert reactions.channel_text("👍", "Alice", "hi all", _T) == f"@[Alice]👍\n{digest}"
|
||||
assert reactions.direct_text("👍", "hi all", _T) == f"👍\n{digest}"
|
||||
assert parse(reactions.channel_text("👍", "Alice", "hi all", _T), channel=True) == (
|
||||
reactions.Reaction("👍", MC1, digest, author="Alice")
|
||||
)
|
||||
|
||||
|
||||
# -- the parse ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "emoji", "author", "digest"),
|
||||
[
|
||||
("@[AlphaNode]👍\n7f3a9c12", "👍", "AlphaNode", "7f3a9c12"),
|
||||
("❤️@[BetaNode]\ne4d8b1a0", "❤️", "BetaNode", "e4d8b1a0"), # before 1.4.1
|
||||
("@[Node]👍\nOOOOOOOO", "👍", "Node", "00000000"),
|
||||
("@[Node]👍\niiiiiiii", "👍", "Node", "11111111"),
|
||||
("@[Node]👍\nLLLLLLLL", "👍", "Node", "11111111"),
|
||||
("@[Node:Alpha]👍\na1b2c3d4", "👍", "Node:Alpha", "a1b2c3d4"),
|
||||
("@[Alice]👍\nreceived", "👍", "Alice", "rece1ved"),
|
||||
("@[A]👍🏽\na1b2c3d4", "👍🏽", "A", "a1b2c3d4"),
|
||||
("@[A]👨👩👧\na1b2c3d4", "👨👩👧", "A", "a1b2c3d4"),
|
||||
("@[A]🇨🇦\na1b2c3d4", "🇨🇦", "A", "a1b2c3d4"),
|
||||
],
|
||||
)
|
||||
def test_a_channel_reaction_parses(body: str, emoji: str, author: str, digest: str) -> None:
|
||||
"""The two orders, the Crockford substitutions, a colon in a name, and each glyph shape."""
|
||||
assert parse(body, channel=True) == reactions.Reaction(emoji, MC1, digest, author=author)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"Just a normal message",
|
||||
"@[Node]👍",
|
||||
"👍 [Node]\na1b2c3d4",
|
||||
"👍@Node\na1b2c3d4",
|
||||
"@[Node]👍\nabc",
|
||||
"@[Node]👍\nuuuuuuuu",
|
||||
"@[]👍\na1b2c3d4",
|
||||
"@[Node]\na1b2c3d4",
|
||||
"@[Alice]🎉 on my way\npassword",
|
||||
"🎉 on my way@[Alice]\npassword",
|
||||
"A@[Node]\na1b2c3d4",
|
||||
],
|
||||
)
|
||||
def test_a_usual_channel_message_is_not_a_reaction(body: str) -> None:
|
||||
"""A body that only starts with an emoji, or is short of a part, stays a message."""
|
||||
assert parse(body, channel=True) is None
|
||||
|
||||
|
||||
def test_a_direct_reaction_has_no_author() -> None:
|
||||
"""A direct reaction is the emoji and the hash. The channel shape is not one."""
|
||||
assert parse("👍\nb45pc4ek", channel=False) == reactions.Reaction("👍", MC1, "b45pc4ek")
|
||||
assert parse("@[Node]👍\na1b2c3d4", channel=False) is None
|
||||
assert parse("🎉 on my way\npassword", channel=False) is None
|
||||
assert parse("1\na1b2c3d4", channel=False) is None
|
||||
|
||||
|
||||
def test_the_reactions_of_meshcore_open_parse() -> None:
|
||||
"""The v3 form indexes a table of 184 emoji. The v1 form carries the time and two hashes."""
|
||||
assert len(reactions.MCO_EMOJI) == 184
|
||||
assert parse("r:a1b2:00", channel=True) == reactions.Reaction("👍", MCO, "a1b2")
|
||||
assert parse("r:0000:45", channel=False).emoji == "😶" # the last smiley
|
||||
assert parse("r:0000:66", channel=False).emoji == "💪" # the last gesture
|
||||
assert parse("r:0000:67", channel=False).emoji == "❤️" # the first heart
|
||||
assert parse("r:0000:b7", channel=False).emoji == "🚀" # the last object
|
||||
for bad in ("r:a1b2:b8", "r:A1B2:00", "r:a1b2:0A", "r:a1b:00", "x:a1b2:00", ""):
|
||||
assert parse(bad, channel=True) is None
|
||||
old = parse("r:1704067200000_12345_67890:👍", channel=True)
|
||||
assert old == reactions.Reaction(
|
||||
"👍", MCO_V1, "1704067200000_12345_67890", timestamp=_T, author_hash=12345, body_hash=67890
|
||||
)
|
||||
|
||||
|
||||
def test_the_dart_hash_is_thirty_bits_and_never_zero() -> None:
|
||||
"""The ``String.hashCode`` of Dart: 1 for no input, and always under 2**30."""
|
||||
assert reactions.dart_hash([]) == 1
|
||||
value = reactions.dart_hash([ord(c) for c in "test string"])
|
||||
assert 0 < value < 1 << 30
|
||||
assert reactions.dart_hash([ord(c) for c in "hello"]) != reactions.dart_hash(
|
||||
[ord(c) for c in "world"]
|
||||
)
|
||||
|
||||
|
||||
def test_the_reaction_set_keeps_only_single_emoji() -> None:
|
||||
"""A word that is not one emoji is ignored. With nothing left, the default set is used."""
|
||||
assert reactions.reaction_set("🔥 ok 👍 🔥 👍👍") == ["🔥", "👍"]
|
||||
assert reactions.reaction_set("") == reactions.DEFAULT_REACTIONS.split()
|
||||
assert reactions.reaction_set("abc") == reactions.DEFAULT_REACTIONS.split()
|
||||
|
||||
|
||||
# -- the fold -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _channel(text: str, seconds: int = _T, *, outbound: bool = False) -> ChatMessage:
|
||||
return ChatMessage(text=text, outbound=outbound, is_channel=True, created_at=_at(seconds))
|
||||
|
||||
|
||||
def test_a_channel_reaction_folds_onto_its_target_once_for_each_node() -> None:
|
||||
"""The target stays. The reaction leaves the transcript. A copy from the same node is one."""
|
||||
fold = ReactionFold(channel=True, self_name="Me")
|
||||
target = _channel("Alice: hi all")
|
||||
assert fold.take(target) is False
|
||||
react = reactions.channel_text("👍", "Alice", "hi all", _T)
|
||||
assert fold.take(_channel(f"Bob: {react}", _T + 5)) is True
|
||||
assert fold.take(_channel(f"Bob: {react}", _T + 6)) is True # a relayed copy
|
||||
assert fold.take(_channel(f"Carol: {react}", _T + 7)) is True
|
||||
assert fold.take(_channel(reactions.channel_text("❤️", "Alice", "hi all", _T), outbound=True))
|
||||
badges = fold.badges(target)
|
||||
assert [(b.emoji, [r.name for r in b.reactors]) for b in badges] == [
|
||||
("👍", ["Bob", "Carol"]),
|
||||
("❤️", [None]),
|
||||
]
|
||||
assert badges[1].ours and fold.reacted(target, "❤️") and not fold.reacted(target, "👍")
|
||||
assert fold.count(target) == 3
|
||||
|
||||
|
||||
def test_a_reaction_to_our_channel_message_names_our_node() -> None:
|
||||
"""Our channel messages have no prefix in the history. Our name is their author."""
|
||||
fold = ReactionFold(channel=True, self_name="Me")
|
||||
ours = _channel("lunch?", outbound=True)
|
||||
fold.take(ours)
|
||||
fold.take(_channel("Bob: " + reactions.channel_text("😂", "Me", "lunch?", _T)))
|
||||
assert [b.emoji for b in fold.badges(ours)] == ["😂"]
|
||||
assert fold.target_of(ours) == ("Me", "lunch?", _T)
|
||||
|
||||
|
||||
def test_a_reaction_that_arrives_first_waits_for_its_target() -> None:
|
||||
"""On a mesh, a reaction can arrive before its target. It attaches when the target comes."""
|
||||
fold = ReactionFold(channel=True)
|
||||
assert fold.take(_channel("Bob: " + reactions.channel_text("👍", "Alice", "late", _T)))
|
||||
target = _channel("Alice: late")
|
||||
assert fold.take(target) is False
|
||||
assert [b.emoji for b in fold.badges(target)] == ["👍"]
|
||||
|
||||
|
||||
def test_the_author_tells_two_identical_messages_apart() -> None:
|
||||
"""Two nodes that send the same body in the same second get their own reactions."""
|
||||
fold = ReactionFold(channel=True)
|
||||
alice, carol = _channel("Alice: ok"), _channel("Carol: ok")
|
||||
fold.take(alice)
|
||||
fold.take(carol)
|
||||
fold.take(_channel("Bob: " + reactions.channel_text("👍", "Carol", "ok", _T)))
|
||||
assert fold.badges(alice) == [] and len(fold.badges(carol)) == 1
|
||||
|
||||
|
||||
def test_a_direct_reaction_folds_by_hash_alone() -> None:
|
||||
"""In a direct exchange, the peer is the only other node. Its reactor has no name."""
|
||||
fold = ReactionFold(channel=False)
|
||||
ours = ChatMessage(text="see you", outbound=True, created_at=_at(_T))
|
||||
fold.take(ours)
|
||||
assert fold.take(ChatMessage(text=reactions.direct_text("👍", "see you", _T), peer="d4e5"))
|
||||
(badge,) = fold.badges(ours)
|
||||
assert badge.emoji == "👍" and badge.reactors == [reactions.Reactor(None)]
|
||||
|
||||
|
||||
def test_a_reaction_of_meshcore_open_finds_its_target_near_in_time() -> None:
|
||||
"""The short hash of v3 is compared with the messages within the window of the reaction."""
|
||||
fold = ReactionFold(channel=True)
|
||||
target = _channel("Alice: hello there")
|
||||
fold.take(target)
|
||||
digest = reactions.mco_hash(_T, "Alice", "hello there")
|
||||
assert fold.take(_channel(f"Bob: r:{digest}:05", _T + 30))
|
||||
assert [b.emoji for b in fold.badges(target)] == ["🔥"]
|
||||
far = _channel("Carol: hello there", _T + 10_000)
|
||||
fold.take(far)
|
||||
late = reactions.mco_hash(_T + 10_000, "Carol", "hello there")
|
||||
assert fold.take(_channel(f"Bob: r:{late}:05", _T)) # out of the window: no target
|
||||
assert fold.badges(far) == []
|
||||
|
||||
|
||||
# -- the send path ------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Recorder:
|
||||
"""A device that records the timestamp and the try number of each send."""
|
||||
|
||||
def __init__(self, acks: list[bool]) -> None:
|
||||
self._acks = list(acks)
|
||||
self.direct: list[tuple[str, int | None, int]] = []
|
||||
self.channel: list[tuple[str, int | None]] = []
|
||||
|
||||
async def send_direct_message(self, contact, text, *, timestamp=None, attempt=0): # noqa: ANN001, ANN201
|
||||
self.direct.append((text, timestamp, attempt))
|
||||
acked = self._acks.pop(0) if self._acks else False
|
||||
code = f"{len(self.direct):08x}"
|
||||
return Delivery(code, object() if acked else None)
|
||||
|
||||
async def send_channel_in_scope(self, index, text, scope, *, timestamp=None): # noqa: ANN001, ANN201
|
||||
self.channel.append((text, timestamp))
|
||||
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self, device: _Recorder, repo: Repository) -> None:
|
||||
self._device = device
|
||||
self.repo = repo
|
||||
self.log = logging.getLogger("test.reactions")
|
||||
self.settings = Settings()
|
||||
self.preferences = Preferences()
|
||||
|
||||
async def device(self) -> _Recorder:
|
||||
return self._device
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(tmp_path: Path) -> Repository:
|
||||
"""A Repository on a scratch database. It closes when the test ends."""
|
||||
r = Repository(tmp_path / "reactions.db")
|
||||
yield r
|
||||
r.close()
|
||||
|
||||
|
||||
_ALICE = Contact(name="Alice", public_key="d4e5" + "0" * 60, key_prefix="d4e5f6a7")
|
||||
|
||||
|
||||
async def test_a_sent_message_stores_the_timestamp_that_went_out(repo: Repository) -> None:
|
||||
"""The stored time is the wire time to the second. A reaction to it then names it right."""
|
||||
device = _Recorder([True])
|
||||
chat = ChatService(_Ctx(device, repo))
|
||||
sent = await chat.send_channel(0, "hello")
|
||||
assert device.channel[0][1] == int(sent.created_at.timestamp())
|
||||
assert sent.created_at.microsecond == 0
|
||||
dm = await chat.send_direct(_ALICE, "hey")
|
||||
assert device.direct[0][1] == int(dm.created_at.timestamp())
|
||||
stored = repo.recent_chat_messages(is_channel=False, peer=_ALICE.key_prefix)
|
||||
assert stored[-1].created_at == dm.created_at
|
||||
|
||||
|
||||
async def test_retries_keep_the_timestamp_and_count_the_try(repo: Repository) -> None:
|
||||
"""Soft retries and ^R send one message: the same timestamp, the next try number, up to 3."""
|
||||
device = _Recorder([False, False, False, False, False])
|
||||
ctx = _Ctx(device, repo)
|
||||
ctx.preferences.direct_message_soft_retries = 1
|
||||
chat = ChatService(ctx)
|
||||
sent = await chat.send_direct(_ALICE, "hey")
|
||||
await chat.resend_direct(_ALICE, sent)
|
||||
await chat.resend_direct(_ALICE, sent)
|
||||
stamps = {timestamp for _, timestamp, _ in device.direct}
|
||||
assert stamps == {int(sent.created_at.timestamp())}
|
||||
assert [attempt for *_, attempt in device.direct] == [0, 1, 2, 3, 3, 3]
|
||||
|
||||
|
||||
async def test_a_reaction_does_not_raise_the_unread_badge(repo: Repository) -> None:
|
||||
"""A reaction shows on its target, so a badge for it would point at nothing new."""
|
||||
chat = ChatService(_Ctx(_Recorder([]), repo))
|
||||
react = Message(text=reactions.direct_text("👍", "hey", _T), sender="d4e5f6a7")
|
||||
assert await chat._notifies(react, None) is False
|
||||
channel = Message(
|
||||
text="Bob: " + reactions.channel_text("👍", "Alice", "hi", _T), channel=0, is_channel=True
|
||||
)
|
||||
assert await chat._notifies(channel, "abc") is False
|
||||
|
||||
|
||||
# -- the chat screen ----------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Session:
|
||||
"""A session stand-in that answers the reaction dialog with ``answer``."""
|
||||
|
||||
def __init__(self, answer: str | None) -> None:
|
||||
self.answer = answer
|
||||
self.asked: list[str] = []
|
||||
self.items: list = []
|
||||
self.filterable: bool | None = None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
pass
|
||||
|
||||
async def select(self, title, items, *, filterable=True, **_kw): # noqa: ANN001, ANN003, ANN201
|
||||
self.asked.append(title)
|
||||
self.items, self.filterable = items, filterable
|
||||
return self.answer
|
||||
|
||||
|
||||
def _screen(messages: list[ChatMessage], session: _Session, sent: list[str]) -> ChatScreen:
|
||||
async def send(text: str) -> ChatMessage:
|
||||
sent.append(text)
|
||||
return ChatMessage(text=text, outbound=True, is_channel=True, created_at=_at(_T + 60))
|
||||
|
||||
return ChatScreen(
|
||||
Conversation(label="#public", is_channel=True, channel_idx=0),
|
||||
messages,
|
||||
send=send,
|
||||
names={},
|
||||
session=session,
|
||||
self_name="Me",
|
||||
)
|
||||
|
||||
|
||||
def test_the_transcript_shows_reactions_under_their_target() -> None:
|
||||
"""The reaction leaves the transcript. Its emoji and count show under the target."""
|
||||
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi all", _T)
|
||||
screen = _screen([_channel("Alice: hi all"), _channel(react, _T + 5)], _Session(None), [])
|
||||
assert len(screen._messages) == 1
|
||||
body = plain(screen.render_body(60))
|
||||
assert "👍 1" in body
|
||||
assert reactions.message_hash("hi all", _T) not in body and "@[Alice]" not in body
|
||||
screen.handle("up") # the selection names the nodes instead of the count
|
||||
assert "👍 Bob" in plain(screen.render_body(60))
|
||||
|
||||
|
||||
async def test_ctrl_e_sends_the_reaction_and_folds_our_copy() -> None:
|
||||
"""^E asks which emoji, sends the text of MeshCore One, and shows our reaction on the target."""
|
||||
session, sent = _Session("😂"), []
|
||||
target = _channel("Alice: hi all")
|
||||
screen = _screen([target], session, sent)
|
||||
screen.handle("up")
|
||||
assert "^E react" in screen.footer_hint
|
||||
screen.handle("react")
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.gather(*screen._tasks)
|
||||
assert session.asked == ["React — Alice"]
|
||||
assert sent == [reactions.channel_text("😂", "Alice", "hi all", _T)]
|
||||
assert screen._messages == [target]
|
||||
assert screen._fold.reacted(target, "😂")
|
||||
|
||||
|
||||
def test_a_room_shows_reaction_text_as_it_arrived() -> None:
|
||||
"""A room has no reactions. Its messages stay messages, and ^E offers nothing."""
|
||||
body = reactions.direct_text("👍", "x", _T)
|
||||
room = Contact(name="Board", public_key="ab" * 32, key_prefix="ab" * 6, node_type=3)
|
||||
screen = ChatScreen(
|
||||
Conversation(label="Board", is_channel=False, contact=room),
|
||||
[ChatMessage(text=body, peer="abababababab", created_at=_at(_T))],
|
||||
send=None,
|
||||
names={},
|
||||
session=_Session(None),
|
||||
)
|
||||
assert len(screen._messages) == 1
|
||||
screen.handle("up")
|
||||
assert not screen._can_react()
|
||||
|
||||
|
||||
def test_the_messages_list_previews_a_reaction_in_words() -> None:
|
||||
"""The latest message of a conversation can be a reaction. Its preview says what it is."""
|
||||
from meshterm.tools.chat import _preview_text
|
||||
|
||||
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi", _T)
|
||||
preview = _preview_text(_channel(react), lambda name: None).plain
|
||||
assert "Bob" in preview and "reacted 👍" in preview and "@[" not in preview
|
||||
ours = ChatMessage(text=reactions.direct_text("😂", "hi", _T), outbound=True)
|
||||
assert "reacted 😂" in _preview_text(ours, lambda name: None).plain
|
||||
|
||||
|
||||
def test_a_reaction_from_a_node_with_a_long_name_folds() -> None:
|
||||
"""A node name can have 31 characters. A sender of 22 is still split off its reaction.
|
||||
|
||||
The two messages are from the hardware log that found the fault: the name limit was 20,
|
||||
so the reaction of ``Hazelwood r0b3.Anduril`` showed as a new message.
|
||||
"""
|
||||
fold = ReactionFold(channel=True)
|
||||
target = _channel("Hazelwood: ⚽ Union 2-1 at Chicago Fire (59')", 1_792_029_645)
|
||||
fold.take(target)
|
||||
react = _channel("Hazelwood r0b3.Anduril: @[Hazelwood]🔥\nmzjsjjpk", 1_792_029_654)
|
||||
assert fold.take(react) is True
|
||||
(badge,) = fold.badges(target)
|
||||
assert badge.emoji == "🔥" and badge.reactors == [reactions.Reactor("Hazelwood r0b3.Anduril")]
|
||||
|
||||
|
||||
async def test_the_reaction_dialog_searches_every_emoji_by_name() -> None:
|
||||
"""The preferred emoji come first, then each other emoji. A typed name filters them all."""
|
||||
from meshterm.ui.chat import emoji_catalog
|
||||
from meshterm.ui.tui import Choice, Separator
|
||||
|
||||
session = _Session(None)
|
||||
screen = _screen([_channel("Alice: hi all")], session, [])
|
||||
screen.handle("up")
|
||||
screen.handle("react")
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.gather(*screen._tasks)
|
||||
assert session.filterable is True
|
||||
values = [item.value for item in session.items if isinstance(item, Choice)]
|
||||
assert values[:6] == reactions.DEFAULT_REACTIONS.split()
|
||||
assert "🔥" in values and values.count("👍") == 1 # a preferred emoji is listed once
|
||||
assert isinstance(session.items[0], Separator) # "Your reactions", then "All emoji"
|
||||
assert sum(isinstance(item, Separator) for item in session.items) == 2
|
||||
fire = next(i for i in session.items if isinstance(i, Choice) and i.value == "🔥")
|
||||
assert "fire" in fire.label.plain
|
||||
assert len(emoji_catalog()) > 1000
|
||||
assert all(reactions.is_reaction_emoji(emoji) for emoji in emoji_catalog())
|
||||
@@ -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 ------------------------------------------------------
|
||||
|
||||
@@ -71,12 +71,14 @@ class _TracingDevice(MockDevice):
|
||||
await super().set_flood_scope(region)
|
||||
self.trail.append(("scope", key))
|
||||
|
||||
async def send_channel_message(self, index: int, text: str) -> None:
|
||||
await super().send_channel_message(index, text)
|
||||
async def send_channel_message(
|
||||
self, index: int, text: str, *, timestamp: int | None = None
|
||||
) -> None:
|
||||
await super().send_channel_message(index, text, timestamp=timestamp)
|
||||
self.trail.append(("channel", text, self._send_scope))
|
||||
|
||||
async def send_direct_message(self, contact, text): # noqa: ANN001, ANN201
|
||||
ack = await super().send_direct_message(contact, text)
|
||||
async def send_direct_message(self, contact, text, **sent): # noqa: ANN001, ANN003, ANN201
|
||||
ack = await super().send_direct_message(contact, text, **sent)
|
||||
self.trail.append(("dm", text, self._send_scope))
|
||||
return ack
|
||||
|
||||
@@ -148,10 +150,10 @@ async def test_a_dm_cannot_interleave_into_the_window() -> None:
|
||||
release = asyncio.Event()
|
||||
original = device.send_channel_message
|
||||
|
||||
async def slow_send(index: int, text: str) -> None:
|
||||
async def slow_send(index: int, text: str, **sent) -> None: # noqa: ANN003
|
||||
entered.set()
|
||||
await release.wait()
|
||||
await original(index, text)
|
||||
await original(index, text, **sent)
|
||||
|
||||
device.send_channel_message = slow_send # type: ignore[method-assign]
|
||||
scoped = asyncio.ensure_future(device.send_channel_in_scope(0, "scoped", "lakeside"))
|
||||
@@ -186,7 +188,7 @@ async def test_a_send_that_fails_still_restores_the_scope() -> None:
|
||||
"""If the channel send raises an error, the session scope must not stay on its region."""
|
||||
device = _TracingDevice()
|
||||
|
||||
async def boom(index: int, text: str) -> None:
|
||||
async def boom(index: int, text: str, **sent) -> None: # noqa: ANN003
|
||||
raise DeviceCommandError("rejected")
|
||||
|
||||
device.send_channel_message = boom # type: ignore[method-assign]
|
||||
@@ -426,7 +428,7 @@ async def test_resend_unscoped_targets_only_a_newest_scoped_message() -> None:
|
||||
assert screen._retry_target() is scoped
|
||||
assert "^R resend unscoped" in screen.footer_hint
|
||||
lane = screen.picocalc_lyra_lane
|
||||
assert lane[2].opp_label == "Resend" and lane[2].opp_enabled
|
||||
assert lane[0].opp_label == "Resend" and lane[0].opp_enabled # Shift+F1, the ^R of each chat
|
||||
|
||||
screen.handle("retry")
|
||||
await asyncio.sleep(0.05)
|
||||
@@ -498,7 +500,7 @@ def test_a_channel_with_nothing_to_resend_dims_the_chip() -> None:
|
||||
|
||||
screen = _channel_chat(_Session(), [unscoped], resend=resend)
|
||||
lane = screen.picocalc_lyra_lane
|
||||
assert lane[2].opp_label == "Resend" and not lane[2].opp_enabled
|
||||
assert lane[0].opp_label == "Resend" and not lane[0].opp_enabled
|
||||
assert fkeys.PICOCALC_LYRA_DECK.lane_text(lane, shifted=True).plain
|
||||
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||