16 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 056ac3f770 MeshTerm 0.2.3
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The build to diagnose a double-click with: a window that waits after a failure, a
log that keeps what happened, and a level you can turn up before reproducing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:57:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 3379ea2d44 A crash you can read, on a window that waits
Double-clicking the executable on Windows gives the process its own console, and
Windows destroys that console the moment the process ends. So a traceback was
drawn and erased in the same instant: the report was "it displays a trace but the
window closes almost instantly", which is not a rendering problem, it is the
window being owned by the thing that died.

Two changes, because one of them is not enough.

The window now waits. `GetConsoleProcessList` says how many processes share this
console; when the answer is one, nobody else is holding the window open and it
will close with us. On that path only, and only after a failure, MeshTerm asks for
a keypress before it goes. A run started from a shell leaves its output on the
screen anyway, and a run that worked should never make anyone press a key to be
allowed to leave.

And the interactive session logs what killed it. It was catching the missing
Windows console and re-raising everything else untouched, which is precisely
backwards for the case that needs a record most: the session whose console is
about to vanish is the one whose traceback nobody will get to read. It goes into
the file first, and the terminal is told where the file is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:49:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 bdc16fc448 The log is a text file that records what went wrong
Three things were true of the log at once: it was JSON Lines, it never rotated,
and it almost never recorded a failure. Of the last twenty thousand records in a
real one, nine were warnings and three were errors. It faithfully kept the boring
parts and missed the interesting ones, which is exactly backwards for the only
job it has.

It is plain text now, `~/.meshterm/meshterm.log`, one line per record with the
traceback indented under the line that raised it. JSON Lines was there in case
something replayed it; nothing ever did, and the actual reader is a person who has
hit a problem and is about to paste the last twenty lines into an issue. That
reader has a text editor.

It rotates, at 2MB across three files. The old handler grew without limit and a
real one reached 11MB over ten weeks — a lot of disk on a handheld, and an
unreasonable thing to ask someone to attach.

How much it keeps is a preference, `log_level` under a new Diagnostics group,
defaulting to WARNING. That is what makes the file useful rather than large: it
holds the problems, and turning it up to DEBUG for one run is how you chase
something. Loaded before logging is configured, since it decides how logging is
configured, and handed to the context afterwards so the file is not read twice.

And failures are written down at last. Every unhandled error reaches the log with
its traceback and the terminal says where to find it; expected errors — an
ambiguous device, a bad preference — go in as warnings, because a log that only
holds crashes cannot answer "what happened just before". The bug form now asks for
the file, which it had no reason to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:43:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 e2161fd191 Two MeshTerms can share a machine, but not a directory
Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a
downloaded build, a second one for a second radio — and nothing about how it was
installed changes that. Two at once will not corrupt anything, but each holds the
JSON stores in memory and rewrites them whole, so the second to save discards the
first's changes. A lost contact is much harder to notice than a crash.

The interactive session now takes an exclusive lock on the directory it is using,
and a second one is refused with the fix in the message: set MESHTERM_HOME. The
lock belongs to the operating system rather than to a process id written in a
file, because a pid file has to answer "is that still alive?", the answer differs
per platform, and it is wrong once a reboot recycles the number — so a crash
leaves a lock nobody can explain and everyone learns to delete it. One-shot
subcommands do not take it; blocking `meshterm contacts` because a menu is open in
another window would be an obstacle rather than a guard.

The scratch-file race is fixed at the same time, and it was ten places rather than
the three I first counted: every store under core/ carried the same three lines,
writing to a fixed `.tmp` name beside its file. Two processes saving together took
turns inside that one scratch file and then each renamed whatever was in it over
the real one — the rename is atomic, the thing being renamed was not. They now go
through one `write_atomically`, whose temporary carries the writing process's id,
and which cleans up after a failed write instead of leaving a partial file behind.

And the Windows console error is answered rather than raised. prompt_toolkit needs
a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives
as a traceback that reads like a broken application when the answer is "open a
different terminal". It now says that, and points out that every screen has a
subcommand which pipes perfectly well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:31:39 -04:00
Jean-Pierre MartineauandClaude Opus 5 9b2c5527f8 MeshTerm 0.2.2
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The first build that contains the whole app. 0.2.1's binaries start and then
offer you nothing, so this replaces them rather than adding to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:54:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 141698678e The downloadable builds actually have the app in them
The Windows build launched, printed help, drew the specimen card, and showed a
menu containing one item: Quit. Not a permissions problem and nothing to do with
Bluetooth — the tools were simply not in the bundle.

`meshterm/tools/__init__.py` finds every tool with `pkgutil.iter_modules` and
imports it so its `@register` decorator fires. Nothing static ever names a tool
module, so PyInstaller had no reason to include one, and the registry came up
empty: no menu entries, no CLI subcommands, an app that starts and does nothing.
`collect_submodules("meshterm.tools")` fixes it, which is the same treatment
`bleak.backends` already had for the same reason — the spec knew about other
people's dynamic imports and not about ours.

The worse half is that CI said this was fine. The smoke test ran `--help` and
`specimen`, and a build with no tools at all passes both. It now runs a
tool-provided subcommand, so an empty registry fails the pipeline instead of
reaching a release page. Green tests that cannot see the defect are worse than no
tests, because they are believed.

`MESHTERM_HOME` comes out of the same session. Every copy of MeshTerm reads and
writes `~/.meshterm`, so trying a downloaded build meant pointing it at the same
34MB history database as the checkout it was built from, with no way to keep them
apart. The variable moves the whole directory. An empty or whitespace value falls
back rather than resolving to the current directory, because a shell exporting it
blank is common and scattering someone's history into their downloads folder is a
poor way to find that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:49:37 -04:00
Jean-Pierre MartineauandClaude Opus 5 8cc1cae213 Say, in the changelog, what happens to it at 0.9.0
A note to whoever cuts the first public release, left where they will be looking
rather than in a plan they may not have open: at 0.9.0 everything above the fold
collapses into one entry saying "first public release" with the headline features
under it.

The reasoning, so it does not read as vandalism later: every version below 0.9.0
was released nowhere. Their entries are notes to ourselves about getting ready — a
formatter pin, a runner label, a workflow that fired on the wrong branches. None
of it is news to somebody arriving on launch day, and a changelog that opens with
a fortnight of housekeeping buries the thing they came for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:34:00 -04:00
Jean-Pierre MartineauandClaude Opus 5 2ea8f9fb83 MeshTerm 0.2.1
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Closes out the changelog's Unreleased section under a date, which is the whole
ceremony: everything since 0.2.0 was written down as it landed, so cutting a
release is renaming a heading rather than reading a week of commits back.

0.2.0's link points at its tag rather than a release page, because 0.2.0 was
never released anywhere — it was tagged locally before there was anything to
build or anywhere to put it. 0.2.1 is the first version that exists outside this
machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 13:30:11 -04:00
Jean-Pierre MartineauandClaude Opus 5 eb61c55f22 The Intel Mac leg asks for a runner that still exists
`macos-13` is retired. GitHub's image list starts at macOS 14 now, and the
current x86_64 labels are `macos-15-intel` and `macos-26-intel`.

The failure mode is worth writing down, because it is not the one you would
expect: a job asking for a retired label does not fail. It queues, forever,
and holds the entire run open behind it — so the four legs that had already
built sat there under a run that never finished, and anything waiting on that
workflow would have waited with them. That includes the release workflow, which
calls it, so a tag pushed today would have produced no release and no error to
explain why.

`macos-15-intel` rather than `-26-intel`, for the same reason the Python in this
matrix is 3.10: built against an older system, runs on more machines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:30:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 0c12bd4fcf Somewhere to download a build from
Pushing a version tag now builds the binaries and puts them on the Releases page,
with a combined SHA256SUMS and notes lifted out of the changelog section for that
version. It stays inside GitHub — while the repository is private the release is
private too — and it is emphatically not the PyPI publish, which is `release.yml`
and still needs a person to run it on purpose.

`installers.yml` gained a `workflow_call` trigger so the release calls it instead
of keeping a second copy of the matrix. Two matrices would agree for about a month
and then quietly stop, and the files people download should be built exactly the
way the ones CI checks on every push are.

The README now leads its install section with the download rather than with pip,
because most people who want MeshTerm have a radio and a terminal and not a Python
toolchain. It also says plainly that the first launch will be blocked: these builds
are unsigned, macOS will refuse them until the quarantine attribute is cleared, and
Windows will show its SmartScreen warning. Signing costs real money on both
platforms, so the honest move is to say so and give people the checksum file rather
than let them think something is wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:25:03 -04:00
Jean-Pierre MartineauandClaude Opus 5 ebb3c80f15 Pin the formatter, and stop it editing the record
The first CI run went red on `ruff format --check`, on a tree that was clean on
this machine. Three separate problems, all found by pushing.

`ruff>=0.4` is not a pin. CI installed 0.16.6 where the laptop had 0.15.18, and
0.16 formats Python code blocks inside Markdown while 0.15 leaves them alone — so
the two disagreed about whether the repository was formatted, which is the one
thing a formatter exists to settle. Pinned to the 0.16 line. Dependabot will
offer the next one, and CI will say whether it is safe.

The file it tripped over is `docs/survey-ui-consistency.md`, which quotes four
lines of a dialog call as it looked on 22 August. That excerpt is a fragment —
two arguments, no call around them — so the formatter parsed it as two statements
and rewrote them into a tuple and an assignment that never existed anywhere. The
surveys are dated records of a past state, so `docs/` is now excluded: a
formatter editing a quotation is not tidying, it is falsifying.

And the installer workflow was firing for branches it had no business building.
Dependabot opened three action bumps within a minute of the push; each one edits
`.github/workflows/installers.yml`, each matched its own path filter, and each
started a five-platform build. `branches: [main]` stops it.

Everything else was green on the first try, including the part that was actually
uncertain: all seven test legs passed, macOS among them, and the installers built
on Windows, macOS on Apple silicon, and Linux on both x64 and arm64 — so the
uConsole build works and the ARM runner was available after all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:01:35 -04:00
Jean-Pierre MartineauandClaude Opus 5 4f60f44512 A build you can download and run without Python
Most people who want MeshTerm have a radio and a terminal, not a Python
toolchain. This is the install for them: one file, download it, run it.

`packaging/meshterm.spec` freezes the app with PyInstaller in console mode —
which is not a detail, since a windowed build of a terminal program starts with
nowhere to draw and exits. The assets land at `meshterm/assets` inside the bundle
so that `Path(__file__).parent.parent / "assets"` keeps resolving exactly as it
does in a checkout, and bleak's per-platform backends are named as hidden imports
because nothing static ever sees the one that actually gets used. UPX is off: a
few megabytes are not worth being flagged by antivirus on a first release.

The build was run here rather than only written. The Windows executable comes out
at 14 MB, runs from a directory with no source tree in sight, and draws the
specimen card — which is the real test, because the specimen reads the asset
files.

`installers.yml` does the same on five machines, because frozen builds cannot be
cross-compiled. Windows, macOS on both Apple silicon and Intel (plenty of people
still have one, and an arm64 build will not run for them), and Linux on x64 and
ARM64 — the last for the uConsole. Each leg runs the binary from a temporary
directory before uploading it, so a build that only works beside its own source
fails on the runner instead of on somebody's laptop, and each one gets a checksum
written beside it. It publishes nothing; attaching these to a release stays a
separate decision.

Also here: `meshterm --help` now opens with the same sentence as the package
summary, rather than a fourth description of the project written in its own way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 05:00:22 -04:00
Jean-Pierre MartineauandClaude Opus 5 ab8ef2aa23 Say how the project is run, and ask better bug questions
The bug form now asks what JP's Discord post asks: how often it happens, whether
there's a workaround, what hardware you're on, and which companion device — with
the note that "a Xiao" doesn't narrow anything down. Three things his post didn't
cover got added: the MeshTerm version, whether it still happens under `--mock`
(which splits an app bug from a radio bug before anyone spends an evening on it),
and whether you're on the regular or PicoCalc look, since those draw differently
enough that a bug in one often doesn't exist in the other.

The README gains the part that was missing: what to expect from a maintainer
doing this on evenings and weekends. Issues welcome, ask before opening a pull
request, no promised timelines, and a nudge on a quiet issue is fine. Report on
GitHub or on Discord, whichever you have — the real ones get moved across either
way, and being told beats filing it tidily.

Install is rewritten for someone who wants to run MeshTerm rather than work on
it. It said `pip install -e ".[dev]"`, which is a contributor's command and puts
pytest and ruff on the machine of somebody who just wants to talk to a radio. Now
it's one pip line, a pipx alternative, `--mock` for people without a radio yet,
and an honest "coming" list for installers and PyPI.

Badges now include CI, the licence and Discord, so they say something checkable
rather than only decorating. The two survey documents get a line at the top
saying they're a snapshot of a date, not a to-do list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:56:51 -04:00
Jean-Pierre MartineauandClaude Opus 5 628b665420 The project can be joined, reported to, and argued with
The rest of the community files, which are the half of open-sourcing that is not
code: how someone tells you about a hole, what happens if a thread turns nasty,
and what you will be asked when they file a bug.

SECURITY.md points at GitHub's private vulnerability reporting rather than an
address that will be scraped, and promises only what one person in his spare time
can keep — an acknowledgement inside a week, which is not a fix inside a week, and
a decision inside ninety days. Its scope section is the MeshTerm-specific part: a
channel key or an admin password reaching a log, an export or the screen is a
vulnerability here, a crafted packet corrupting the observation database is one,
and a hole in the MeshCore firmware or in `bleak` is somebody else's to fix. It
also says plainly that the database is not encrypted, because a backup of it
carries a mesh's whole history.

The code of conduct is Contributor Covenant 2.1, taken verbatim from source rather
than paraphrased, with a real address in the enforcement section.

The bug form asks the seven things that otherwise get asked in every thread:
version, flavour, transport, OS and Python, terminal and its size, companion
firmware. Flavour is there because the PicoCalc draws itself in 53 columns and
sixteen colours, so a layout bug in one flavour frequently does not exist in the
other. It asks for the version from the splash screen rather than from
`meshterm --version`, which does not exist yet.

Dependabot is weekly and capped low, with minor and patch grouped into one pull
request: a solo maintainer facing five dependency PRs every Monday stops reading
any of them. Funding points at the three destinations the app's own support page
already offers, so the two cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:31:32 -04:00
Jean-Pierre MartineauandClaude Opus 5 117b958edf Everything PyPI needs, and no way to reach it by accident
The distribution is named `mesh-term`, because `meshterm` is on PyPI's prohibited
list and a request to free it is 319 deep in a queue. The name after
`pip install` is the only thing that changes: the import package is still
`meshterm`, the console script is still `meshterm`, and nothing in the code reads
the distribution name, so this is one line rather than a rename. Proven rather
than assumed — the wheel was built, installed into a clean interpreter, and the
command run from outside the source tree.

The rest is what a stranger sees on a package page and what the app never shows
them: project URLs, trove classifiers for the console, the three operating
systems and the four Python versions, and the licence moved to its PEP 639
expression form. No `License ::` classifier, which that form forbids, and no
`Typing :: Typed` either — there is no `py.typed` marker, and a classifier that
claims something untrue is worse than an absent one.

`release.yml` publishes to PyPI and is built to be hard to fire. It has no
`push: tags` trigger, and that absence is the design: a workflow that published
on a `v*` tag would make tagging v0.9.0 on launch day claim a name on a public
index, permanently, as a side effect of an unrelated action — and PyPI has no
rename and no real delete. So a human runs it from the Actions tab and types the
version, the job compares that against the version the tree actually built and
refuses on a mismatch, and a `pypi` environment can hold it for a reviewer.
Trusted Publishing means there is no API token in the repository to leak. It
publishes whatever version is committed; there is no version argument, so it
cannot publish something other than what is there.

CI gains a packaging job that does the same build and clean-install check on every
push, publishing nothing. Packaging breaks quietly, and the alternative is finding
out on the one day that is worst for finding out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 04:00:56 -04:00
Jean-Pierre MartineauandClaude Opus 5 365df236b9 The promises in CONTRIBUTING are checked by a machine
`.github/` starts here: the three commands the contributing guide asks a
contributor to run, run for them on every push and pull request.

Two jobs rather than one. Style is a property of the source, so `lint` runs once
on Linux and a failure means one thing. Behaviour is a property of the machine,
so `test` runs across the systems the description now promises — Windows, macOS
and Linux — which until this file existed had only ever been checked on one of
them, whatever the description said.

The matrix is shaped for a private repository, where minutes are metered and
billed by a multiplier: Linux 1x, Windows 2x, macOS 10x. Linux therefore carries
the whole 3.10 to 3.13 range, and the expensive runners cover its ends, since a
version-dependent break shows up at an end rather than in the middle. Seven legs,
about fifty billed minutes a run. The comment says to widen it to the full
product once the repo is public and standard runners stop costing anything.

Two small decisions worth their lines. `fail-fast` is off: the point of a run is
the whole picture of what broke, and a first red leg cancelling its siblings
hides the rest until the next push. And `concurrency` cancels a superseded run,
because a second push makes the first run's answer obsolete and those are metered
minutes spent on a stale commit.

One line of `.gitignore` came with it. The catch-all `.*/` that hides `.venv`
and the caches was hiding `.github/` too — the same rule `.claude/` already needed
an exception to. It fails silently in the worst way: a workflow that is never
committed looks exactly like a workflow that never runs.

The version risk this was expected to surface is already gone: the suite was run
locally on 3.13 as well as the usual 3.10, and both report 1888 passing. What
remains genuinely unchecked is macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 03:49:42 -04:00
39 changed files with 1909 additions and 108 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Kept in step with meshterm/assets/pages/support.md deliberately. If the app offers a
# way to chip in and this file doesn't, the two have drifted and one of them is lying.
buy_me_a_coffee: johnputer
custom:
- "https://www.patreon.com/c/MeshTerm"
- "https://tinyurl.com/SupportMeshTerm"
+183
View File
@@ -0,0 +1,183 @@
name: Bug report
description: Report a problem with MeshTerm
labels: [bug]
body:
- type: markdown
attributes:
value: |
Hopefully you never need this form.
If you do: how much of this you fill in really does decide what happens next. A
report I can act on gets worked on. A vague one usually doesn't, because I can't
do anything with it. If it isn't worth describing properly, it's probably not
worth fixing.
Most of these take ten seconds each.
- type: textarea
id: problem
attributes:
label: What's the problem
description: What happens, and what you expected to happen instead.
validations:
required: true
- type: dropdown
id: how-often
attributes:
label: How often does it happen
options:
- Every single time
- Often, but not always
- Now and then, no pattern I can see
- Once, and I haven't seen it since
validations:
required: true
- type: textarea
id: repro
attributes:
label: How do I make it happen
description: >
The steps, in order, starting from launching MeshTerm. Screens and keys beat
descriptions — "Contacts, Enter on a node, Routes tab, Enter on the second route"
is worth more than "open a trace".
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Is there a way around it
description: Anything that avoids it, or gets you unstuck. Say so even if it's ugly.
- type: input
id: version
attributes:
label: MeshTerm version
description: It's on the splash screen when you start, and on the About page.
placeholder: "0.2.0"
validations:
required: true
- type: input
id: hardware
attributes:
label: What are you running MeshTerm on
description: >
Desktop, laptop, Mac, or a handheld — and if it's a handheld, which one.
placeholder: "ClockworkPi uConsole (CM4), or: desktop PC"
validations:
required: true
- type: input
id: os
attributes:
label: Which OS, exact version
placeholder: "Windows 11 24H2 / macOS 15.3 / Debian 12"
validations:
required: true
- type: input
id: terminal
attributes:
label: Which terminal, and how big
description: >
PowerShell, Windows Terminal, iTerm2, the PicoCalc console, ssh from something.
Size in columns x rows if you know it — a lot of layout bugs are really terminal
bugs.
placeholder: "Windows Terminal, 120x40"
validations:
required: true
- type: input
id: companion
attributes:
label: Which companion device
description: >
Be precise. There are a lot of these and they don't behave the same. "A Xiao"
doesn't narrow it down — "Seeed XIAO nRF52840 + Wio-SX1262" does. Leave blank if
no radio was involved.
placeholder: "Heltec V3 / RAK4631 / XIAO nRF52840 + Wio-SX1262"
- type: input
id: firmware
attributes:
label: Which firmware version is on it
placeholder: "MeshCore 1.15.0"
- type: dropdown
id: transport
attributes:
label: How is it connected
options:
- USB / serial
- Bluetooth
- TCP
- SPI-to-TCP bridge
- Something else (say so below)
- No radio — simulator / --mock
validations:
required: true
- type: dropdown
id: flavour
attributes:
label: Regular or PicoCalc
description: >
MeshTerm draws itself differently on a PicoCalc — narrower, fewer colours, a
different font. A bug in one often doesn't exist in the other, so this saves a
round of guessing.
options:
- Regular (desktop, laptop, ssh)
- PicoCalc handheld
- Not sure
validations:
required: true
- type: dropdown
id: mock
attributes:
label: Does it still happen with `meshterm --mock`
description: >
`--mock` runs the whole app against a fake radio. If the bug survives that, it's
in MeshTerm and I can chase it without hardware. If it doesn't, it's in the link
or the radio, which is a different hunt. Skip it if the bug is about connecting
in the first place.
options:
- "Yes, still happens with --mock"
- "No, --mock is fine"
- "Haven't tried"
- "Doesn't apply"
- type: textarea
id: error
attributes:
label: The exact error, if there was one
description: >
Copy it as text rather than photographing the screen — text can be searched, and
a photo of a terminal is hard to read. Paste the whole traceback if it crashed.
render: text
- type: textarea
id: log
attributes:
label: The log file
description: >
MeshTerm keeps one at `~/.meshterm/meshterm.log` (or wherever `MESHTERM_HOME`
points). Attach it, or paste the last twenty lines. If the problem is hard to
catch, set **Log detail** to `DEBUG` on the Preferences page, do it once more,
and send that instead — it records a great deal more.
render: text
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Did it used to work? Did it start after an update? Screenshots of a wrong-looking
screen are genuinely useful here, even though text is better for errors.
+13
View File
@@ -0,0 +1,13 @@
blank_issues_enabled: false
contact_links:
- name: Not sure it's a bug? Ask on Discord
url: https://discord.gg/AZwe5Uvb3S
about: >
Setup trouble, "is it supposed to do this", or anything you want a second opinion
on. The support forum is the place. Someone running your radio has probably hit it.
- name: No GitHub account? Report it on Discord instead
url: https://discord.gg/AZwe5Uvb3S
about: >
Drop reproducible bugs in #bugs and they'll get moved here. Being told beats
filing it tidily.
+30
View File
@@ -0,0 +1,30 @@
name: Feature request
description: Suggest a feature or an improvement
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
MeshTerm is one person's spare time, so the honest answer to a good idea is often
"yes, eventually". Saying what you are actually trying to do — rather than the
feature you have in mind — often finds something that already works.
- type: textarea
id: problem
attributes:
label: What are you trying to do
description: The situation you are in, not the feature. What are you trying to find out about your mesh?
validations:
required: true
- type: textarea
id: idea
attributes:
label: What you had in mind
description: If you have a shape in mind — a screen, a key, a subcommand — describe it.
- type: textarea
id: today
attributes:
label: What you do today instead
description: The workaround, if there is one. This says more about how much the feature is worth than anything else.
+16
View File
@@ -0,0 +1,16 @@
## What this changes, and why
<!-- The why matters more than the what — the diff already says the what. -->
## Related issue
<!-- CONTRIBUTING asks for an issue first on anything non-trivial, so a large PR with no
issue behind it will get a conversation before it gets a review. Link it here. -->
## Before you mark it ready
- [ ] `python -m pytest -q` passes, including the dual-platform gallery gate
- [ ] `ruff check .` and `ruff format --check .` are clean
- [ ] New screens, rows and hints go through the helpers in `ui/menus.py`, and follow the
lexicon and UX standards in `CLAUDE.md`
- [ ] Anything user-visible has a line in `CHANGELOG.md` under `[Unreleased]`
+26
View File
@@ -0,0 +1,26 @@
# Weekly, and small on purpose. A solo maintainer reviewing five open dependency PRs
# every Monday stops reviewing them at all, so the caps are low enough to stay readable.
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
groups:
# One PR for the routine noise; anything major still arrives on its own so it
# gets read on its own.
minor-and-patch:
update-types: [minor, patch]
commit-message:
prefix: deps
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 3
commit-message:
prefix: ci
+119
View File
@@ -0,0 +1,119 @@
# The three commands CONTRIBUTING.md asks a contributor to run, run for them.
#
# Split in two on purpose. Style is a property of the source, not of the machine, so
# `lint` runs once and its failure is unambiguous. Behaviour is a property of the
# machine, so `test` runs everywhere we claim to work — the description promises
# Windows, macOS and Linux, and until this file existed that promise had only ever been
# checked on one of them.
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Nothing here writes to the repository; say so rather than inherit the default.
permissions:
contents: read
# A second push while the first is still running makes the first answer obsolete.
# Cancel it — on a private repo those are metered minutes spent on a stale commit.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: pip
cache-dependency-path: pyproject.toml
- run: pip install -e ".[dev]"
# Both gates, and the second runs even when the first fails: they report
# different things and seeing only the earlier one wastes a round trip.
- run: ruff check .
- if: ${{ !cancelled() }}
run: ruff format --check .
test:
name: test ${{ matrix.os }} py${{ matrix.python }}
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
# Let every leg finish. The point of a run is the whole picture of what broke,
# and one red leg cancelling its siblings hides the rest until the next push.
fail-fast: false
matrix:
# Shaped for a *private* repo, where minutes are metered and billed by a
# multiplier: Linux 1x, Windows 2x, macOS 10x. So Linux carries the full
# Python range and the costly runners cover the ends of it, which is where a
# version-dependent break actually shows up.
#
# Once the repo is public, standard runners are unmetered: widen this to every
# OS times every version and stop thinking about it.
include:
- { os: ubuntu-latest, python: "3.10" }
- { os: ubuntu-latest, python: "3.11" }
- { os: ubuntu-latest, python: "3.12" }
- { os: ubuntu-latest, python: "3.13" }
- { os: windows-latest, python: "3.10" }
- { os: windows-latest, python: "3.13" }
- { os: macos-latest, python: "3.13" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: pyproject.toml
- run: pip install -e ".[dev]"
# Includes tests/test_gallery.py, the dual-platform readability gate: every
# screen has to stay readable at 72 columns regular and 53 on the PicoCalc, and
# every PicoCalc case is a hard failure rather than a warning.
- run: python -m pytest -q
# Builds the distributions and checks them the way PyPI would, publishing nothing.
# Worth its minute: packaging breaks silently and is otherwise only discovered on
# the one day you least want to discover it. The wheel is installed into a clean
# environment and the command run from outside the source tree, because "it built"
# and "it works when installed" are different claims.
build:
name: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
- run: twine check dist/*
- name: Install the wheel clean and run the command
run: |
python -m venv /tmp/fresh
/tmp/fresh/bin/pip install --quiet dist/*.whl
cd /tmp
/tmp/fresh/bin/meshterm --help > /dev/null
# A tool-provided subcommand, not just --help: the tools are discovered at
# runtime, so a broken package still answers --help perfectly well.
/tmp/fresh/bin/meshterm contacts --help > /dev/null
echo "the wheel installs, the console script runs, and its tools registered"
+103
View File
@@ -0,0 +1,103 @@
# Puts the downloadable builds on the repository's Releases page.
#
# Nothing here leaves GitHub. While the repository is private the release is private too,
# visible and downloadable only by people who can see the repo. This is *not* the PyPI
# publish — that is `release.yml`, it is a separate act, and it does not run from here.
#
# Triggered by pushing a version tag, which is the moment a release exists in git anyway:
#
# git tag -a v0.3.0 -m "MeshTerm 0.3.0"
# git push origin v0.3.0
#
# The binaries are built by `installers.yml`, called rather than copied, so the files
# people download are made exactly the way the ones CI checks on every push are.
name: GitHub release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build a release from (e.g. v0.2.0)"
required: true
type: string
prerelease:
description: "Mark as a pre-release (keeps it out of 'Latest')"
type: boolean
default: false
permissions:
contents: read
jobs:
build:
uses: ./.github/workflows/installers.yml
publish:
name: create the release
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
# The one job that needs it: creating a release writes to the repository.
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
# Every platform's artifact, flattened into one directory of files to attach.
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Gather the binaries and their checksums
id: gather
shell: bash
run: |
mkdir -p release
find artifacts -type f -exec mv {} release/ \;
ls -lh release/
# One combined checksum file is easier to verify against than five loose ones.
(cd release && cat *.sha256 > SHA256SUMS && rm -f *.sha256)
tag="${{ inputs.tag || github.ref_name }}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
- name: Take the release notes from the changelog
id: notes
shell: bash
env:
VERSION: ${{ steps.gather.outputs.version }}
run: |
# The section for this version, between its own heading and the next one. If the
# changelog has nothing for it, say so plainly rather than inventing notes.
if awk -v v="$VERSION" '
$0 ~ "^## \\[" v "\\]" {found=1; next}
found && /^## \[/ {exit}
found {print}
' CHANGELOG.md | sed '/^$/d' | head -1 | grep -q .; then
awk -v v="$VERSION" '
$0 ~ "^## \\[" v "\\]" {found=1; next}
found && /^## \[/ {exit}
found {print}
' CHANGELOG.md > notes.md
else
echo "No changelog entry for $VERSION." > notes.md
fi
echo "--- notes ---"; cat notes.md
- name: Create it
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.gather.outputs.tag }}
PRERELEASE: ${{ inputs.prerelease && '--prerelease' || '' }}
run: |
gh release create "$TAG" release/* \
--title "MeshTerm ${TAG#v}" \
--notes-file notes.md \
--verify-tag \
$PRERELEASE
echo "https://github.com/${{ github.repository }}/releases/tag/$TAG"
+109
View File
@@ -0,0 +1,109 @@
# Standalone builds — a single file you download and run, with no Python installed.
#
# For most people who want MeshTerm, this is the install. They have a radio and a
# terminal, not a Python toolchain.
#
# Frozen builds cannot be cross-compiled: each one is made on the system it targets, so
# the matrix is the point of this file rather than an optimisation. Everything here
# uploads artifacts and nothing publishes — attaching them to a release stays a separate,
# deliberate act.
name: Installers
on:
workflow_dispatch:
# Callable, so the release workflow builds these binaries rather than keeping a second
# copy of the matrix that would drift from this one.
workflow_call:
# Only when the packaging itself changes. Three operating systems is not something to
# spend on every commit while the repository is private and minutes are metered.
push:
# `main` only. Without this, every Dependabot branch that touches this file — which
# is every action bump — starts a five-platform build, and three of those legs are
# billed at 2x and 10x.
branches: [main]
paths:
- "packaging/**"
- ".github/workflows/installers.yml"
permissions:
contents: read
concurrency:
group: installers-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: ${{ matrix.label }}
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
- { os: windows-latest, label: windows-x64, ext: ".exe" }
- { os: macos-latest, label: macos-arm64, ext: "" }
# Apple silicon builds do not run on Intel Macs, and plenty of people still
# have one. `macos-15-intel` rather than `-26-intel` for the same reason the
# Python here is 3.10: built against an older system, runs on more machines.
# (Not `macos-13` — that image is retired, and a job asking for it does not
# fail, it queues forever and holds the whole run open behind it.)
- { os: macos-15-intel, label: macos-x64, ext: "" }
- { os: ubuntu-22.04, label: linux-x64, ext: "" }
# For the uConsole and other 64-bit ARM handhelds. ARM runners are free on
# public repositories; on a private one this leg may sit unclaimed until the
# repo is public. Drop the line if it blocks you.
- { os: ubuntu-22.04-arm, label: linux-arm64, ext: "" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
# Oldest supported version on purpose: a binary built against an older glibc
# and an older Python runs on more machines than one built against the newest.
python-version: "3.10"
- run: pip install . pyinstaller
- name: Build
working-directory: packaging
run: pyinstaller --clean --noconfirm meshterm.spec
- name: Check it actually runs
shell: bash
run: |
bin="packaging/dist/meshterm${{ matrix.ext }}"
chmod +x "$bin" || true
# Run it from somewhere else entirely, so a build that only works next to the
# source tree fails here rather than on someone's laptop.
cd "$(mktemp -d)"
"$GITHUB_WORKSPACE/$bin" --help > /dev/null
# Reads the asset files, so a bundle missing its pages fails here.
"$GITHUB_WORKSPACE/$bin" specimen > /dev/null
# And a tool-provided subcommand, which is the check that matters. Every tool is
# found at runtime by `pkgutil.iter_modules`, so a bundle that omits them still
# starts, still prints help, still draws the specimen — and has an empty menu
# with nothing in it but Quit. `--help` passing proves almost nothing on its own.
"$GITHUB_WORKSPACE/$bin" contacts --help > /dev/null
echo "runs, finds its assets, and its tools registered"
- name: Name it after the version and the platform
shell: bash
run: |
version=$(python -c "import meshterm; print(meshterm.__version__)")
out="meshterm-${version}-${{ matrix.label }}${{ matrix.ext }}"
mv "packaging/dist/meshterm${{ matrix.ext }}" "$out"
# A checksum beside the binary, so anyone can tell they got what we built.
if command -v sha256sum > /dev/null; then
sha256sum "$out" > "$out.sha256"
else
shasum -a 256 "$out" > "$out.sha256"
fi
cat "$out.sha256"
- uses: actions/upload-artifact@v4
with:
name: meshterm-${{ matrix.label }}
path: |
meshterm-*
if-no-files-found: error
+99
View File
@@ -0,0 +1,99 @@
# Publishing to PyPI. Deliberately hard to fire.
#
# There is no `push: tags` trigger here, and that absence is the point. A release
# workflow that publishes on a `v*` tag would mean tagging v0.9.0 on launch day
# silently claims a distribution name on a public index, permanently, as a side
# effect of an unrelated action. PyPI has no rename and no true delete, so that
# side effect is not one you can take back.
#
# So: a human runs this, from the Actions tab, on purpose, and types the version
# to prove they meant this version. Three gates before anything leaves the runner:
#
# 1. no automatic trigger at all
# 2. the typed version must equal the version actually built
# 3. the `pypi` environment, which can require a reviewer in repo settings
#
# It publishes whatever version is in the tree. There is no version argument, so
# this cannot publish something other than what is committed.
name: Release
on:
workflow_dispatch:
inputs:
confirm_version:
description: "Version to publish, exactly (e.g. 0.9.0). A mismatch aborts."
required: true
type: string
permissions:
contents: read
jobs:
build:
name: build and check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
# Renders the README the way PyPI will, and fails on metadata PyPI would reject.
- run: twine check dist/*
# The version the build actually produced, read off the artifact rather than
# trusted from anywhere else.
- id: built
shell: bash
run: |
version=$(ls dist/*.tar.gz | sed -E 's/.*-([0-9][^-]*)\.tar\.gz/\1/')
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "built version: $version"
- name: Confirm the typed version matches what was built
shell: bash
env:
TYPED: ${{ inputs.confirm_version }}
BUILT: ${{ steps.built.outputs.version }}
run: |
if [ "$TYPED" != "$BUILT" ]; then
echo "::error::You typed '$TYPED' but the tree builds '$BUILT'. Nothing published."
exit 1
fi
echo "confirmed: $BUILT"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
name: publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
# Configure this environment in repo settings with a required reviewer, and it
# becomes a fourth gate: the job waits for an approval before it runs. The name
# must also match the environment set on the PyPI trusted publisher, or the OIDC
# exchange fails with an unhelpful error. If the publisher has no environment
# configured, delete this line.
environment:
name: pypi
url: https://pypi.org/p/mesh-term
permissions:
# Trusted Publishing mints a short-lived OIDC token instead of storing an API
# token in the repository. This is the only thing that needs write here.
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
+4
View File
@@ -14,6 +14,10 @@ dist/
.claude/settings.local.json
.claude/plans/
.claude/housekeeping.md
# ...and except the GitHub configuration, which only works where GitHub looks for
# it. The catch-all above hid it silently: a workflow that is never committed is
# indistinguishable from one that never runs.
!.github/
# Other virtual environments
venv/
+118 -2
View File
@@ -7,8 +7,119 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — wi
SemVer itself makes for a leading zero: while the major version is `0`, a **minor** bump is
allowed to change behaviour, not just add to it.
<!--
At 0.9.0 — the first public release — everything below collapses into a single entry
reading "first public release", with the headline features under it. Nobody arriving at
a project on its launch day wants a changelog of the fortnight before it: the versions
under 0.9.0 were never released anywhere, and their entries are notes to ourselves about
getting ready. Keep the dates and the tags; replace the prose.
-->
## [Unreleased]
## [0.2.3] — 2026-09-07
### Changed
- The log is a plain text file, `~/.meshterm/meshterm.log`, one readable line per record
with the traceback under the line that raised it. It was JSON Lines on the theory that
something would replay it; nothing ever did, and the job it actually has is being opened
by a person who has hit a problem.
- It rotates, at 2 MB across three files. It used to grow without limit — a real one
reached 11 MB in ten weeks, which is a lot of disk on a handheld and an unreasonable
thing to attach to an issue.
- **How much it keeps is a preference** — `log_level`, under a new Diagnostics group,
defaulting to `WARNING`. The file now holds the problems rather than a narration of a
working session: of the last twenty thousand records in a real log, twelve were a
warning or an error.
### Added
- One interactive MeshTerm per data directory. A second one is turned away with the way
out in the message, rather than quietly overwriting the first one's contacts and
settings. The lock is held by the operating system, so a crash releases it — a process
id in a file would leave a lock nobody could explain.
- Failures are written down. Every unhandled error reaches the log with its traceback and
the terminal says where to find it; expected errors go in as warnings, because a log
that only holds crashes cannot answer "what happened just before". The bug report form
asks for the file.
- The window stays open after a crash when MeshTerm owns it. Double-clicking the
executable on Windows gives the process its own console, which is destroyed the instant
the process ends — so an error was drawn and erased together and all anyone saw was a
flash. It now waits for a keypress, but only on that path: a run from a shell leaves its
output on screen already, and a successful run should not make anyone press a key.
- A readable answer when there is no Windows console. Git Bash, MSYS and Cygwin are not
Windows consoles, and prompt_toolkit's own error says so in the language of its
internals, arriving as a traceback that reads like a broken app rather than the
instruction it is.
### Fixed
- All ten stores write through one helper, and the file each writes to first is named
for the process writing it. They shared a fixed `.tmp` name, so two copies of MeshTerm
saving at the same moment took turns inside one scratch file and then each renamed
whatever was in it over the real one. The rename was always atomic; what was being
renamed was not.
## [0.2.2] — 2026-09-07
### Fixed
- **The downloadable builds were missing every tool.** They started, printed help and drew
the specimen card, and had a menu with nothing in it but Quit — no contacts, no map, no
trace, no chat. Tools are found at runtime with `pkgutil.iter_modules`, which finds
nothing inside a frozen bundle, so none of them registered. The packaging now collects
them, and the smoke tests run a tool-provided subcommand instead of only `--help`, which
a gutted build passes perfectly well.
### Added
- `MESHTERM_HOME` points config and data somewhere other than `~/.meshterm`. Every copy of
MeshTerm shared one directory, so trying a downloaded build meant letting it open the
same history database as the one you use — the valuable half of an install, with no way
to keep a second copy away from it. Also useful for a portable install, or two radios
kept apart.
## [0.2.1] — 2026-09-07
### Fixed
- The formatter is pinned. `ruff>=0.4` meant CI installed a newer ruff than a laptop had,
and the two disagreed about whether the tree was clean — 0.16 formats Python blocks
inside Markdown and 0.15 doesn't, so the first CI run went red on a file that was fine
locally. An unpinned formatter isn't a gate.
- `docs/` is out of ruff's reach. The survey documents quote code as it looked on a past
date, in fragments that were never whole programs; a formatter rewriting a quotation
edits the record.
- The installer builds only run for `main`. Every Dependabot branch that bumped an action
touched the workflow file, matched its path filter, and started a five-platform build.
### Changed
- The distribution is named `mesh-term`. PyPI prohibits `meshterm` (pypi/support#12162),
and the name you install is the only thing that changes: the import package and the
command are both still `meshterm`.
- `pyproject.toml` carries the metadata a stranger sees on a package page — project URLs,
trove classifiers, and the licence as a PEP 639 expression.
### Added
- Continuous integration. Lint once, tests across seven OS and Python combinations, and a
packaging job that installs the built wheel into a clean environment and runs the command
from outside the source tree.
- A release workflow with no automatic trigger, which publishes nothing until a human runs
it on purpose and types the version.
- A security policy, a code of conduct, issue and pull request templates, weekly
dependency updates, and the funding links the app's own support page already offers.
- A Releases page. Pushing a version tag builds the binaries and attaches them, with a
combined `SHA256SUMS` and release notes taken from this file. It stays inside GitHub
and is not the PyPI publish, which remains a separate and deliberate act.
- Standalone builds. A PyInstaller spec and a workflow that builds a single runnable
file for Windows, macOS (Intel and Apple silicon) and Linux (x64 and ARM64), checks
each one runs from outside the source tree, and writes a checksum beside it. Nothing
is published; the files are workflow artifacts until someone attaches them to a
release.
## [0.2.0] — 2026-09-07
The first version to carry a changelog. Everything before it is in the commit log and is
@@ -31,6 +142,8 @@ deliberately not reconstructed here.
which is the only handle a tile operator has on a client.
- `macOS` and `Bluetooth` are spelled one way across the project. `BLE` stays where it is
an identifier — the transport name, the config keys, the CLI.
- `meshterm --help` opens with the same description as the package summary and the
PyPI page, instead of a fourth wording of its own.
### Fixed
@@ -41,5 +154,8 @@ deliberately not reconstructed here.
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
happened never to run.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.0
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.3...HEAD
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
[0.2.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.1
[0.2.0]: https://github.com/jpmartineau/MeshTerm/tree/v0.2.0
+83
View File
@@ -0,0 +1,83 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at johnputer@meshterm.net. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+114 -7
View File
@@ -14,11 +14,18 @@ radio overhears is recorded to a local SQLite database, so the longer you run it
your mesh's history is worth.
<p>
<a href="https://github.com/jpmartineau/MeshTerm/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/jpmartineau/MeshTerm/actions/workflows/ci.yml/badge.svg"></a>
<img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-blue">
<a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-green"></a>
<img alt="For MeshCore" src="https://img.shields.io/badge/for-MeshCore-8A2BE2">
<img alt="Interface: TUI + CLI" src="https://img.shields.io/badge/interface-TUI%20%2B%20CLI-orange">
<a href="https://github.com/jpmartineau/MeshTerm/releases/latest"><img alt="Download" src="https://img.shields.io/badge/download-latest-brightgreen"></a>
<a href="https://discord.gg/AZwe5Uvb3S"><img alt="Discord" src="https://img.shields.io/badge/chat-Discord-5865F2"></a>
</p>
> MeshTerm is a side project, not a product. Bug reports are very welcome — but please
> open an issue before writing a pull request. [More on how it's run](#how-this-project-is-run).
<!--
Screenshots welcome here. Drop PNGs into docs/ and reference them, e.g.:
![Dashboard](docs/dashboard.png)
@@ -48,19 +55,84 @@ your mesh's history is worth.
## Install
```bash
git clone <your-fork-url> meshterm
cd meshterm
pip install -e ".[dev]"
### Download a build
**[⬇ Latest release](https://github.com/jpmartineau/MeshTerm/releases/latest)** — one file,
no Python needed. Windows, macOS (Intel and Apple silicon), and Linux (x64 and ARM64, so
the uConsole is covered).
MeshTerm is a terminal program, so **open a terminal and run it from there.** You can
double-click it and it will work, but you'll get whatever console your system picks, and
if anything goes wrong at startup the window closes before you can read why.
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
```powershell
.\meshterm-0.2.3-windows-x64.exe
```
Python **3.10+** is required (matching the `meshcore` library). The `[dev]` extra adds
pytest and ruff; drop it for a runtime-only install (`pip install -e .`).
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
```bash
python -m pytest -q # run the test suite
chmod +x meshterm-0.2.3-*
./meshterm-0.2.3-*
```
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
becomes just `meshterm` from anywhere.
> **Your system will complain the first time, and it's right to.** These builds aren't
> code-signed — signing costs real money on both platforms and this is a free side
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.2.3-*` and run it again.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
> **Trying a build without touching your real data.** MeshTerm keeps everything in
> `~/.meshterm` — your history database, contacts, channel keys — and *every* copy of
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.3-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.3-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
### Or install with pip
You need **Python 3.10 or newer**. That's the only requirement — MeshTerm pulls in
everything else itself.
```bash
pip install git+https://github.com/jpmartineau/MeshTerm
meshterm
```
`meshterm` opens the full-screen menu. That's the whole install.
**Rather not touch your system Python?** [pipx](https://pipx.pypa.io/) puts the app in its
own private environment and still gives you a plain `meshterm` command:
```bash
pipx install git+https://github.com/jpmartineau/MeshTerm
```
**No radio yet?** `meshterm --mock` runs the entire app against a simulated mesh, so you
can have a look around before you buy anything.
### Coming
**`pip install mesh-term`**, once the package is published. It isn't yet — the name
question is still being sorted out ([why](https://github.com/pypi/support/issues/12162)).
Setting up for development instead? That's in [CONTRIBUTING.md](CONTRIBUTING.md).
## Quick start
```bash
@@ -253,6 +325,41 @@ Adding a feature means subclassing `Tool`, decorating it with `@register`, and
implementing `run()`. The same registry builds both the CLI and the menu, and the base
class wraps every execution in a logged `runs` row automatically.
## How this project is run
MeshTerm is one person working evenings and weekends. I'd rather tell you that up front
than have you guess from how long things take.
**Issues are welcome — all of them.** Bugs, questions, "is this supposed to do that". The
[bug form](.github/ISSUE_TEMPLATE/bug.yml) asks for a fair bit, and that's on purpose: how
well a problem is described really does decide whether I can do anything with it. If I can
reproduce it, I'll usually chase it. If I can't, I'm mostly guessing.
**Ask before you write a pull request.** Open an issue first and wait for a yes. I'm not
being precious — MeshTerm has firm house rules about how screens get built (they're in
[CLAUDE.md](CLAUDE.md)), and I'd hate for you to spend a weekend on something I then ask
you to rewrite. A quick conversation first saves us both.
**I can't promise timelines.** Some things get fixed the same night. Some sit for a month
because life happened. If your issue goes quiet, a nudge is completely fine — it's not
rude, it's helpful.
**Where to report things.** Either works:
- **[GitHub issues](https://github.com/jpmartineau/MeshTerm/issues)** if you have an
account. This is where things get tracked and fixed, so it's the shortest path.
- **[Discord](https://discord.gg/AZwe5Uvb3S)** if you don't, or if you're not sure it's a
bug yet. Ask in the support forum, or drop reproducible defects in `#bugs`. I move the
real ones over to GitHub myself.
Don't worry about picking the wrong one. Getting told about a problem beats filing it
tidily.
Also here: [CONTRIBUTING.md](CONTRIBUTING.md) ·
[SECURITY.md](SECURITY.md) ·
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ·
[CHANGELOG.md](CHANGELOG.md)
## Development
```bash
+62
View File
@@ -0,0 +1,62 @@
# Security policy
## Reporting a vulnerability
Use GitHub's private vulnerability reporting: go to the **Security** tab of this
repository and choose **Report a vulnerability**. That opens a private thread visible only
to you and the maintainer, so nothing is disclosed while it is being fixed.
If that is unavailable to you for any reason, email <johnputer@meshterm.net> instead.
**Please do not open a public issue for a security problem.**
## What to expect
MeshTerm is maintained by one person in his spare time, so this promises only what can
actually be kept:
- **An acknowledgement within a week.** Not a fix within a week — an answer saying the
report was read and what happens next.
- **A fix or a decision within 90 days** for anything confirmed. If it will take longer,
you will be told why.
- **Credit in the release notes**, unless you would rather not be named.
A slow reply is not a dismissal. If a week passes with silence, a nudge on the same thread
is welcome.
## Supported versions
While the major version is `0`, only the **latest release** is supported. There are no
backports; a fix ships in the next version.
## Scope
In scope — anything in this repository:
- Code execution, path traversal, or file overwrite triggered by data MeshTerm reads:
packets overheard from the mesh, a companion's responses, a config or preferences file,
a downloaded map tile.
- Anything that discloses a **channel key**, a **repeater admin password**, or a node's
private key material — including into the local database, a log, an export, or the
screen when it should not be there.
- Anything that lets a crafted packet corrupt or delete the observation database.
- Weaknesses in how MeshTerm stores credentials on disk.
Out of scope, though still worth telling us about somewhere:
- Vulnerabilities in the **MeshCore firmware** or in the companion device itself. Those
belong upstream at the MeshCore project.
- Vulnerabilities in the `meshcore` Python library, `bleak`, or any other dependency —
report those to their maintainers. If a MeshTerm change can mitigate one, we want to
know.
- The fact that anyone within radio range can hear a public channel. That is how a mesh
works, not a defect.
- Anything requiring an attacker to already have write access to your filesystem or your
companion device.
## A note on what MeshTerm handles
MeshTerm decrypts channel traffic using keys **you** give it, and it records everything it
overhears to a local SQLite database. That database is not encrypted. Treat it as you would
any file holding your mesh's history, and remember that a backup of it carries the same
weight.
+5
View File
@@ -1,5 +1,10 @@
# Navigation survey — every exception to "Esc pops the stack"
> **A historical record.** This was a snapshot of the code on the date below, kept
> because a project that writes down its own problems is easier to trust than one
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
Written 2026-08-22 against `main` @ `4b29248`. A catalogue, not a change: nothing here had
been fixed at the time of writing. Each finding says where it is, what it does, and what
makes it a question.
+5
View File
@@ -1,5 +1,10 @@
# UI consistency survey — what is off, and what turned out to be fine
> **A historical record.** This was a snapshot of the code on the date below, kept
> because a project that writes down its own problems is easier to trust than one
> that doesn't. Some of what's here has since been fixed and some hasn't; nothing in
> it is a to-do list. Read it as "what this looked like then", not "what is wrong now".
Written 2026-08-22 against `main` @ `4b29248`. Companion to
[`survey-navigation.md`](survey-navigation.md), which covers navigation exceptions; this one
covers everything else in the CLAUDE.md UX standard. A catalogue, not a change.
+1 -1
View File
@@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.2.0"
__version__ = "0.2.3"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+148 -16
View File
@@ -10,9 +10,11 @@ from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
import typer
from rich.console import Console
from .context import AppContext
from .core.admin_store import AdminStore
@@ -20,9 +22,10 @@ from .core.config import Settings
from .core.connection import DeviceCommandError, is_connection_lost
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
from .core.preferences import PreferenceError
from .core.instancelock import InstanceBusy, hold_instance_lock
from .core.preferences import PreferenceError, Preferences
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
from .persistence.repository import Repository
from .platforms import Resolution, resolve, set_platform
from .tools import all_tools
@@ -68,7 +71,13 @@ app = typer.Typer(
# section panels flattened to plain headings by _unframe_help_panels() above — a
# splash of colour, none of the framing.
rich_markup_mode="rich",
help="MeshTerm — a modern toolkit for tuning and exploring your MeshCore mesh",
# The one description, same as the package summary and the PyPI page. See
# `meshterm.__doc__` — if this drifts from that, one of them is lying.
help=(
"MeshTerm is a full-featured TUI-based MeshCore client for your terminal. "
"Supports USB, Bluetooth and TCP companion connection. "
"For Windows, macOS and Linux."
),
)
# The context built by the callback and consumed by subcommands within one process.
@@ -135,9 +144,19 @@ def main_callback(
settings.db_path = db_path
console = make_console()
configure_logging(console, settings.config_dir, level=logging.INFO, quiet=quiet or json_output)
# Loaded before logging is configured, because how much goes in the file is one of
# them — and handed to the context afterwards so the file is not read twice.
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
configure_logging(
console,
settings.config_dir,
level=logging.INFO,
file_level=level_from_name(prefs.log_level),
quiet=quiet or json_output,
)
app_ctx = AppContext(
preferences=prefs,
console=console,
settings=settings,
repo=Repository(settings.db_path),
@@ -160,7 +179,60 @@ def main_callback(
if ctx.invoked_subcommand is None:
from .ui.menu import run_menu
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
# The interactive session is the one that holds the stores in memory and rewrites
# them whole, so it is the one that claims the directory. One-shot subcommands are
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
# another window would be an obstacle rather than a guard.
try:
with hold_instance_lock(settings.config_dir):
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
except InstanceBusy as exc:
console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(code=1) from None
except Exception as exc:
if type(exc).__name__ == "NoConsoleScreenBufferError":
# prompt_toolkit needs a real Win32 console and reports its absence in the
# language of its own internals. A traceback reads as "this app is broken"
# when the answer is "open a different terminal", so answer that instead.
get_logger().warning("no Windows console available: %s", exc)
_report_no_windows_console(console)
raise typer.Exit(code=1) from None
# Logged before it is re-raised, and this is the case the log matters most for:
# a session launched by double-clicking the executable owns its console window,
# so when it dies the window closes with it and takes the traceback along. The
# file is the only thing left to read afterwards.
get_logger().exception("the interactive session raised an unhandled error")
_report_log_location(console, settings.config_dir)
raise
def _report_log_location(console: Console, config_dir: Path) -> None:
"""Point at the log after a fault, since it is the copy that outlives the terminal."""
console.print()
console.print(f"[muted]The details are in {log_path(config_dir)}[/muted]")
console.print("[muted]Attach it to a bug report. For more detail next time, raise[/muted]")
console.print("[muted]'Log detail' on the Preferences page.[/muted]")
def _report_no_windows_console(console: Console) -> None:
"""Explain a console MeshTerm cannot draw in, and how to get one it can.
Reached when prompt_toolkit finds no Win32 console screen buffer: a Git Bash, MSYS or
Cygwin shell — which announce themselves as ``xterm`` and are not Windows consoles at
all — or a process whose output has been redirected away from one.
"""
console.print("[err]✗[/err] MeshTerm needs a Windows console, and could not find one.")
console.print()
console.print("That usually means one of two things:")
console.print()
console.print(" • You are in Git Bash, MSYS or Cygwin. Those are not Windows consoles.")
console.print(" Open [accent]Windows Terminal[/accent] or [accent]PowerShell[/accent]")
console.print(" and run it from there.")
console.print(" • The output is piped or redirected somewhere. The full-screen menu")
console.print(" needs the terminal itself.")
console.print()
console.print("Every screen also has a subcommand, and those pipe fine —")
console.print("try [accent]meshterm --help[/accent].")
@app.command(name="platform")
@@ -212,20 +284,30 @@ def run_tool_command(tool: Tool, params: dict) -> None:
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc:
# Expected user-facing error (ambiguous/absent device, a bad config or preference
# value, or a transient command failure): show the message, not a traceback.
# value, or a transient command failure): show the message, not a traceback. Logged
# at warning because it is a thing that went wrong, even though it is an ordinary
# one — a log that only holds crashes cannot answer "what happened before it".
get_logger().warning("%s failed: %s", tool.name, exc)
_state.console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(1) from exc
except Exception as exc:
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
# from in a one-shot scripted run the way the interactive menu offers — but it should
# still read as a clean message, not a traceback. Anything else propagates as before.
if not is_connection_lost(exc):
raise
_state.console.print(
"[err]✗[/err] the connection to your device was lost "
"(it may have been unplugged or powered off)."
)
raise typer.Exit(1) from exc
# still read as a clean message, not a traceback.
if is_connection_lost(exc):
get_logger().warning("%s lost the device connection: %s", tool.name, exc)
_state.console.print(
"[err]✗[/err] the connection to your device was lost "
"(it may have been unplugged or powered off)."
)
raise typer.Exit(1) from exc
# Anything else is a real fault. It still reaches the terminal as a traceback,
# because a person looking at one wants to see it — but it also lands in the log,
# which is the copy that survives the terminal being closed and is the one thing
# worth attaching to a bug report.
get_logger().exception("%s raised an unhandled error", tool.name)
_report_log_location(_state.console, _state.settings.config_dir)
raise
async def _drive(coro, ctx: AppContext) -> None:
@@ -274,9 +356,59 @@ def _register_all() -> None:
_register_all()
def _owns_its_console() -> bool:
"""Whether this process is the only one on its console — i.e. it was double-clicked.
Windows gives a console to a process launched from Explorer and destroys it the moment
that process ends, so an error message is displayed and erased in the same instant.
Launched from a shell instead, the console belongs to the shell and survives.
``GetConsoleProcessList`` tells the two apart: it reports how many processes are
attached to this console. One is us, alone, holding a window that dies with us.
Returns:
``True`` only on Windows, and only when nothing else shares the console.
"""
if sys.platform != "win32":
return False
try:
import ctypes
buffer = (ctypes.c_uint * 4)()
count = ctypes.windll.kernel32.GetConsoleProcessList(buffer, 4) # type: ignore[attr-defined]
except Exception: # pragma: no cover - no console at all, or a stubbed kernel32
return False
return count == 1
def main() -> None:
"""Console-script entry point."""
app()
"""Console-script entry point.
Holds the window open after a failure when MeshTerm owns it, because otherwise the
error is drawn and destroyed together and the person who needs to read it sees a
flash. Only on that path: a successful run should not make anyone press a key, and a
run from a shell leaves its output on the screen anyway.
"""
try:
app()
except SystemExit as exit_request:
if exit_request.code and _owns_its_console():
_wait_before_the_window_closes()
raise
except BaseException:
if _owns_its_console():
_wait_before_the_window_closes()
raise
def _wait_before_the_window_closes() -> None:
"""Ask for a keypress so a message stays readable, and never fail doing it."""
try:
print()
print("-- Press Enter to close this window --")
input()
except Exception: # pragma: no cover - stdin closed, redirected, or gone
pass
if __name__ == "__main__":
+2 -10
View File
@@ -14,11 +14,10 @@ a multi-user secret store; treat the file accordingly.
from __future__ import annotations
import json
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from .atomicwrite import write_atomically
from .models import Contact, LoginResult, utcnow
@@ -139,11 +138,4 @@ class AdminStore:
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically with owner-only permissions where supported."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
try: # best-effort: not all platforms/filesystems honor chmod
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
except OSError: # pragma: no cover - platform-dependent
pass
tmp.replace(self._path)
write_atomically(self._path, json.dumps(records, indent=2), owner_only=True)
+2 -4
View File
@@ -20,6 +20,7 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .atomicwrite import write_atomically
from .models import utcnow
#: The direct (zero-hop) cadence choices offered in the editor, in hours.
@@ -190,10 +191,7 @@ class AdvertStore:
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(records, indent=2))
def _as_hours(value: object, default: int) -> int:
+54
View File
@@ -0,0 +1,54 @@
"""THE way MeshTerm replaces a file it owns: all of the new contents, or none of them.
Every store under ``core/`` keeps its state in one file that it rewrites whole. Writing
in place means a crash — or a laptop lid closing — halfway through leaves a truncated
file where a contact list used to be, so each write goes to a neighbouring temporary file
first and lands with a single rename, which the filesystem either does or doesn't.
The neighbouring file's name carries the writing process's id. That is not fussiness:
MeshTerm's data directory is shared by every copy on the machine — a checkout, a
downloaded build, a second instance for a second radio — and a fixed ``.tmp`` name means
two of them writing at the same moment take turns clobbering one scratch file, then each
rename whatever is in it over the real one. The rename is atomic; the thing being renamed
was not. Per-process names make the two writes independent again, and the last rename
simply wins, which is a lost update rather than a corrupted file.
``MESHTERM_HOME`` is the way to avoid sharing the directory in the first place — see
:func:`meshterm.core.config.default_config_dir`.
"""
from __future__ import annotations
import os
import stat
from pathlib import Path
__all__ = ["write_atomically"]
def write_atomically(path: Path, text: str, *, owner_only: bool = False) -> None:
"""Replace ``path`` with ``text`` in one step, creating parent directories as needed.
Args:
path: The file to replace. Its directory is created if it does not exist.
text: The complete new contents, written as UTF-8.
owner_only: Restrict the file to the owner (``0600``) before it is put in place,
for anything holding a credential. Applied to the temporary file rather than
the destination, so there is no moment where the real file exists with wider
permissions. Best-effort: not every platform or filesystem honours it.
"""
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 owner_only:
try:
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
except OSError: # pragma: no cover - platform-dependent
pass
tmp.replace(path)
except BaseException:
# A failed write leaves no litter behind. `missing_ok` because the failure may
# well have been the write that would have created it.
tmp.unlink(missing_ok=True)
raise
+2 -4
View File
@@ -30,6 +30,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from .atomicwrite import write_atomically
from .channels import (
CHANNEL_SECRET_BYTES,
CHANNEL_SLOT_EMPTY_RUN,
@@ -156,10 +157,7 @@ class ChannelStore:
if channels
}
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
def _channel_from_json(entry: object) -> RememberedChannel | None:
+19 -2
View File
@@ -15,6 +15,7 @@ keys used to sit in this file with no screen behind them.
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
@@ -26,15 +27,31 @@ else: # pragma: no cover - exercised only on 3.10
import tomli as tomllib
#: Points MeshTerm's config and data somewhere other than the home directory. For trying a
#: build without letting it near a real history, for a portable install on a stick, and for
#: running two radios out of two directories. Read every time rather than cached, so a test
#: can move it between cases.
CONFIG_DIR_ENV = "MESHTERM_HOME"
def default_config_dir() -> Path:
"""Return the directory MeshTerm uses for config and data.
Resolves to ``.meshterm`` under the OS-defined home directory (``%USERPROFILE%``
on Windows, ``$HOME`` on Unix), as reported by :meth:`Path.home`.
``$MESHTERM_HOME`` wins if it is set and not empty. Otherwise this resolves to
``.meshterm`` under the OS-defined home directory (``%USERPROFILE%`` on Windows,
``$HOME`` on Unix), as reported by :meth:`Path.home`.
The override exists because the database is the valuable part of an install — a
running record of everything the radio has overheard — and there was no way to point a
second copy of MeshTerm at a different one. A downloaded build would happily open the
same 34MB file as the checkout it was built from.
Returns:
The resolved configuration directory path (not guaranteed to exist).
"""
override = os.environ.get(CONFIG_DIR_ENV, "").strip()
if override:
return Path(override).expanduser()
return Path.home() / ".meshterm"
+2 -4
View File
@@ -49,6 +49,7 @@ import json
from dataclasses import dataclass
from pathlib import Path
from .atomicwrite import write_atomically
from .models import Contact, advert_time
@@ -332,10 +333,7 @@ class ContactStore:
if contacts
}
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
def _contact_to_json(contact: RememberedContact) -> dict:
+2 -4
View File
@@ -19,6 +19,7 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .atomicwrite import write_atomically
from .models import utcnow
#: Message states: waiting in the outbox, landed, or abandoned after the retry budget.
@@ -253,10 +254,7 @@ class CourierStore:
for m in self._load()
],
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
def _as_int(value: object, default: int) -> int:
+2 -4
View File
@@ -19,6 +19,7 @@ import json
from dataclasses import dataclass
from pathlib import Path
from .atomicwrite import write_atomically
from .discovery import (
TRANSPORT_BLE,
TRANSPORT_SERIAL,
@@ -259,7 +260,4 @@ class DeviceStore:
},
"last": last,
}
# Atomic-ish replace so a crash mid-write can't truncate the existing registry.
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(payload, indent=2))
+94
View File
@@ -0,0 +1,94 @@
"""One interactive MeshTerm per data directory, and a clear answer when you want two.
Every copy of MeshTerm on a machine reads and writes the same ``~/.meshterm`` — a
checkout, a downloaded build, a second instance for a second radio. Nothing about how it
was installed changes that. Two running at once will not corrupt anything (the database
is in WAL mode and every file lands by rename) but each holds the JSON stores in memory
and rewrites them whole, so the second one to save quietly discards the first one's
changes. A lost contact is much harder to notice than a crash.
So the interactive session takes an exclusive lock on the directory it is using, and a
second one is turned away with the fix in the message: set ``MESHTERM_HOME``.
The lock is held by the operating system rather than written down as a process id in a
file. A pid file has to answer "is that process still alive?", the answer is different on
every platform, and it is wrong after a reboot recycles the number — so a crash leaves a
lock nobody can explain and everybody works around. An OS lock is released when the
process ends, however it ends.
One-shot CLI subcommands do not take it. They are brief, mostly read, and blocking
``meshterm contacts`` because a menu is open elsewhere would be an obstacle rather than a
guard.
"""
from __future__ import annotations
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
__all__ = ["InstanceBusy", "LOCK_NAME", "hold_instance_lock"]
#: The lock file, kept beside the data it guards. Empty; only the lock on it matters.
LOCK_NAME = ".lock"
class InstanceBusy(RuntimeError):
"""Raised when another interactive MeshTerm already holds this data directory."""
def __init__(self, config_dir: Path) -> None:
"""Build the message, which is mostly the way out of the situation."""
super().__init__(
f"Another MeshTerm is already using {config_dir}.\n\n"
"Two of them sharing one directory will quietly overwrite each other's\n"
"contacts and settings, so this one stopped instead.\n\n"
"To run a second one — a downloaded build beside your own, or a second\n"
"radio — give it a directory of its own:\n\n"
" MESHTERM_HOME=~/meshterm-other meshterm\n"
)
self.config_dir = config_dir
def _try_lock(handle) -> bool:
"""Take an exclusive, non-blocking lock on an open file; ``False`` if someone has it."""
if sys.platform == "win32":
import msvcrt
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
return False
return True
import fcntl
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return False
return True
@contextmanager
def hold_instance_lock(config_dir: Path) -> Iterator[None]:
"""Hold ``config_dir`` for this process, releasing it however the process ends.
Args:
config_dir: The data directory to claim.
Raises:
InstanceBusy: Another interactive MeshTerm holds it.
"""
config_dir.mkdir(parents=True, exist_ok=True)
path = config_dir / LOCK_NAME
handle = open(path, "a+b") # noqa: SIM115 - closed in the finally below
try:
if not _try_lock(handle):
raise InstanceBusy(config_dir)
yield
finally:
# Closing releases the lock on every platform. The file itself stays: deleting it
# would race a second process that has already opened it and is about to lock,
# which is how one directory ends up handed to two of them.
handle.close()
+3 -4
View File
@@ -23,6 +23,8 @@ from __future__ import annotations
import json
from pathlib import Path
from .atomicwrite import write_atomically
class MuteStore:
"""Reads and writes the set of channels whose notifications are muted, memory-first.
@@ -92,7 +94,4 @@ class MuteStore:
def _save(self) -> None:
"""Persist the whole muted set atomically (a crash mid-write keeps the old file)."""
data = {"muted": sorted(self._state)}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
+27 -4
View File
@@ -42,6 +42,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .atomicwrite import write_atomically
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
#: Display groups, in the order the page and the file present them. The order is the
@@ -56,6 +57,7 @@ GROUPS: tuple[str, ...] = (
"Map",
"History",
"Display",
"Diagnostics",
)
@@ -123,6 +125,16 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
#: What the log file keeps. Named rather than numeric, because those names are what every
#: other tool calls these and the reader is often being talked through it by someone else.
_LOG_LEVEL_CHOICES: dict[str, str] = {
"ERROR": "Errors only",
"WARNING": "Warnings and errors",
"INFO": "Also what the app is doing",
"DEBUG": "Everything, for chasing a bug",
}
PREFERENCES: tuple[PrefSpec, ...] = (
# --- Sending -----------------------------------------------------------------
PrefSpec(
@@ -282,6 +294,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
default="auto",
choices=_WIDTH_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
label="Log detail",
help="How much MeshTerm writes to its log file",
group="Diagnostics",
value_type="enum",
# Problems, not a narration of a working session. The file used to take
# everything, which buried the dozen interesting lines under twenty thousand
# dull ones and made it awkward to attach to a bug report.
default="WARNING",
choices=_LOG_LEVEL_CHOICES,
relaunch=True,
),
)
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
@@ -602,10 +628,7 @@ class Preferences:
"""
if self._path is None:
raise RuntimeError("these preferences have no file to save to")
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(self.as_yaml(), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, self.as_yaml())
#: The set installed for this process. Starts as a defaults-only, file-less instance so
+2 -4
View File
@@ -17,6 +17,7 @@ from datetime import datetime
from pathlib import Path
from .admin_store import admin_key
from .atomicwrite import write_atomically
from .models import Contact, utcnow
#: How many command-line entries are kept per node (newest last).
@@ -109,7 +110,4 @@ class RemoteStore:
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(records, indent=2))
+2 -4
View File
@@ -32,6 +32,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from .atomicwrite import write_atomically
from .device_config import DeviceConfigError, format_value, get_spec, parse_value
if TYPE_CHECKING:
@@ -147,10 +148,7 @@ class SettingsStore:
if values
}
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
@dataclass(frozen=True)
+71 -28
View File
@@ -1,13 +1,28 @@
"""Structured logging setup.
"""Logging setup: a plain text file you can open, and a console that stays out of the way.
Two sinks are configured: a Rich-formatted console handler for human-readable output,
and a JSON-lines file handler that captures every record for later replay/analysis.
Two sinks. The console handler is Rich-formatted and shows what a person watching the
terminal should see. The file handler writes ordinary log lines to
``<config_dir>/meshterm.log`` — one record per line, timestamp, level, logger, message,
with a traceback indented under the line that raised it.
Plain text on purpose. The file used to be JSON Lines, on the theory that something might
replay it; nothing ever did, and the one job it actually has is being opened by a person
who has hit a problem — often the person reporting it, who then pastes the last twenty
lines into an issue. That reader has a text editor, not a JSON parser.
The file rotates. It used to be a single :class:`~logging.FileHandler`, which grows
without limit: a real one reached 11MB over ten weeks, which is both a lot of disk on a
handheld and an unreasonable thing to attach to a bug report.
How much is written is a preference — ``log_level``, defaulting to ``WARNING``, so the
file holds the problems rather than a narration of a working session. Turn it down to
``INFO`` or ``DEBUG`` when chasing something.
"""
from __future__ import annotations
import json
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from rich.console import Console
@@ -15,28 +30,49 @@ from rich.logging import RichHandler
_LOGGER_NAME = "meshterm"
#: The log file, in the config directory beside the data it describes.
LOG_FILENAME = "meshterm.log"
class JsonlFormatter(logging.Formatter):
"""Format log records as one JSON object per line."""
#: Keep this much before rolling over, and this many rolled files. Roughly a fortnight of
#: a chatty session at DEBUG, and small enough that the whole set can be attached to an
#: issue without apology.
_MAX_BYTES = 2 * 1024 * 1024
_BACKUP_COUNT = 3
def format(self, record: logging.LogRecord) -> str:
"""Serialize a log record to a compact JSON string.
#: ``2026-09-07 14:23:11 WARNING meshterm.core.connection: link lost, retrying``
#: Level is padded so the messages line up; the logger name says which part spoke.
_LINE_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
Args:
record: The record to format.
Returns:
A single-line JSON document.
"""
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload)
#: The levels the ``log_level`` preference offers, by their own names. Spelled out rather
#: than taken from ``logging.getLevelName``, whose string-to-number direction is deprecated,
#: or ``getLevelNamesMapping``, which arrived in 3.11 and this package supports 3.10.
_LEVELS: dict[str, int] = {
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
def level_from_name(name: str, *, fallback: int = logging.WARNING) -> int:
"""Turn a ``log_level`` preference value into a level number.
Args:
name: A level name, in any case.
fallback: Used when the name is unrecognised — a hand-edited ``preferences.yaml``
with a typo in it should lose some log detail, not stop the app from starting.
Returns:
The matching level number.
"""
return _LEVELS.get(name.strip().upper(), fallback)
def log_path(log_dir: Path) -> Path:
"""Return the log file's path, for an error message that wants to name it."""
return log_dir / LOG_FILENAME
def configure_logging(
@@ -44,14 +80,16 @@ def configure_logging(
log_dir: Path,
*,
level: int = logging.INFO,
file_level: int = logging.WARNING,
quiet: bool = False,
) -> logging.Logger:
"""Configure and return the application logger.
Args:
console: The Rich console the handler should render to.
log_dir: Directory for the ``meshterm.log.jsonl`` event file.
log_dir: Directory for the ``meshterm.log`` file.
level: Console log level.
file_level: File log level, from the ``log_level`` preference.
quiet: When ``True``, suppress console output (file logging continues).
Returns:
@@ -70,9 +108,14 @@ def configure_logging(
logger.addHandler(rich_handler)
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_dir / "meshterm.log.jsonl", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(JsonlFormatter())
file_handler = RotatingFileHandler(
log_path(log_dir),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(file_level)
file_handler.setFormatter(logging.Formatter(_LINE_FORMAT, datefmt=_TIME_FORMAT))
logger.addHandler(file_handler)
_quiet_library_console(file_handler)
@@ -100,8 +143,8 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
1. Give the root logger a :class:`~logging.NullHandler` so ``basicConfig`` — which only
acts when the root has no handlers — becomes a no-op and never adds its stream handler.
2. Take each known library logger off propagation and attach only ``file_handler``, so
its records are still captured to the JSON-lines log but never reach the console, even
if some other code path installs a root stream handler anyway.
its records are still captured to the log but never reach the console, even if some
other code path installs a root stream handler anyway.
Args:
file_handler: The application's file handler to also capture library records to.
+22
View File
@@ -0,0 +1,22 @@
"""Launcher for the frozen builds.
PyInstaller freezes a *script*, not a console-script entry point, so this is the script:
it does what the ``meshterm`` command does and nothing else. Kept in ``packaging/``
rather than in the package, because it exists for the installers and has no business
being importable.
"""
from __future__ import annotations
import multiprocessing
import sys
if __name__ == "__main__":
# Windows and macOS spawn child processes by re-running this executable. Without
# this call a frozen app that ever starts one forks a second copy of the whole UI
# instead, which looks like the app launching itself over and over.
multiprocessing.freeze_support()
from meshterm.cli import main
sys.exit(main())
+72
View File
@@ -0,0 +1,72 @@
# PyInstaller spec for the downloadable builds. Run from the repository root:
#
# pyinstaller packaging/meshterm.spec
#
# One file, console mode. Console is not a detail: MeshTerm *is* a terminal program, and
# a windowed build would start with nowhere to draw and exit immediately.
#
# Built on the machine it targets. There is no cross-compiling here — the Windows build
# comes off a Windows runner, the macOS one off macOS. That is what the workflow is for.
from PyInstaller.utils.hooks import collect_submodules
# The assets have to land at `meshterm/assets`, because `ui/about.py` finds them with
# `Path(__file__).parent.parent / "assets"` and that path has to keep resolving inside
# the bundle exactly as it does in a checkout.
datas = [
("../meshterm/assets", "meshterm/assets"),
]
hiddenimports = [
# Every tool module. `meshterm.tools` finds these with `pkgutil.iter_modules` at
# import time, so nothing static ever names one, and a frozen build without this line
# starts with an empty registry: no menu entries but Quit, and no CLI subcommands.
# The app launches and looks like it works, which is the worst way for this to fail.
*collect_submodules("meshterm.tools"),
# bleak picks its backend at runtime by platform, so static analysis never sees the
# one that actually gets used. Collect all of them and let the unused ones sit.
*collect_submodules("bleak.backends"),
# Same story for the companion library's transports.
*collect_submodules("meshcore"),
# Rich and prompt_toolkit both reach for things dynamically.
"rich.console",
"prompt_toolkit.output.win32",
"prompt_toolkit.output.vt100",
]
a = Analysis(
["entry.py"],
pathex=[".."],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
# Nothing here is used, and each one drags in a GUI toolkit or a test framework.
excludes=["tkinter", "pytest", "IPython", "matplotlib", "numpy", "PIL"],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="meshterm",
debug=False,
bootloader_ignore_signals=False,
strip=False,
# UPX is off deliberately: it saves a few megabytes and gets the result flagged by
# antivirus often enough that it is not a trade worth making for a first release.
upx=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+38 -5
View File
@@ -3,14 +3,34 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "meshterm"
# The distribution name only. The import package and the console script both stay
# `meshterm` — PyPI prohibits that name (see pypi/support#12162), and nothing about the
# app changes because of it. `[tool.hatch.build.targets.wheel]` below is what keeps the
# wheel shipping the `meshterm/` package under this different name.
name = "mesh-term"
dynamic = ["version"]
description = "MeshTerm is a full-featured TUI-based MeshCore client for your terminal. Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux."
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Jean-Pierre Martineau" }]
license = { text = "Apache-2.0" }
license = "Apache-2.0"
license-files = ["LICENSE", "NOTICE"]
keywords = ["meshcore", "lora", "mesh", "radio", "cli"]
# No `License ::` entry: PEP 639 forbids one alongside the SPDX expression above.
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Communications :: Ham Radio",
"Topic :: System :: Networking",
]
dependencies = [
"meshcore>=2.3", # companion-device protocol (serial/BLE/TCP)
@@ -31,9 +51,18 @@ dependencies = [
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.4",
# Pinned to a minor line, not floated. A formatter that drifts between machines is
# not a gate: 0.16 formats Python inside Markdown and 0.15 does not, so an
# unpinned `ruff>=0.4` had CI and a laptop disagreeing about a clean tree.
"ruff>=0.16.6,<0.17",
]
[project.urls]
Homepage = "https://github.com/jpmartineau/MeshTerm"
Repository = "https://github.com/jpmartineau/MeshTerm"
Issues = "https://github.com/jpmartineau/MeshTerm/issues"
Changelog = "https://github.com/jpmartineau/MeshTerm/blob/main/CHANGELOG.md"
[project.scripts]
meshterm = "meshterm.cli:main"
@@ -48,8 +77,12 @@ packages = ["meshterm"]
[tool.ruff]
line-length = 100
target-version = "py310"
# Helper scripts for the Claude skills; they are not part of the package.
extend-exclude = [".claude"]
# Helper scripts for the Claude skills; they are not part of the package. And `docs/`,
# which holds dated survey documents: ruff formats Python blocks inside Markdown, and the
# code they quote is an excerpt of how a file looked on a past date. Reformatting a
# quotation edits the record — and the excerpts are call fragments, not whole programs, so
# the formatter's idea of "fixed" turns them into something that was never there.
extend-exclude = [".claude", "docs"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "D"]
+30 -1
View File
@@ -8,7 +8,9 @@ from __future__ import annotations
from pathlib import Path
from meshterm.core.config import Settings
import pytest
from meshterm.core.config import CONFIG_DIR_ENV, Settings, default_config_dir
def test_connect_on_start_defaults_true() -> None:
@@ -42,3 +44,30 @@ def test_tcp_profile_defaults_port(tmp_path: Path) -> None:
config.write_text('[profiles.wifi]\nhost = "meshcore.local"\n', encoding="utf-8")
profile = Settings.load(config).profiles["wifi"]
assert profile.is_tcp and profile.tcp_endpoint == "meshcore.local:5000"
def test_config_dir_defaults_to_the_home_directory(monkeypatch: pytest.MonkeyPatch) -> None:
"""With no override set, config and data live in ``.meshterm`` under the user's home."""
monkeypatch.delenv(CONFIG_DIR_ENV, raising=False)
assert default_config_dir() == Path.home() / ".meshterm"
def test_config_dir_follows_the_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""``$MESHTERM_HOME`` moves the whole directory, so a build can run beside a real one.
This is what keeps a downloaded binary from opening the same database as the checkout
it was built from — the history is the valuable half of an install, and there was no
way to point a second copy somewhere else.
"""
monkeypatch.setenv(CONFIG_DIR_ENV, str(tmp_path / "elsewhere"))
assert default_config_dir() == tmp_path / "elsewhere"
def test_config_dir_ignores_an_empty_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""An unset-but-present or whitespace value falls back rather than resolving to nowhere.
A shell that exports the variable empty is common enough that treating "" as "put the
data in the current directory" would be a nasty way to scatter someone's history.
"""
monkeypatch.setenv(CONFIG_DIR_ENV, " ")
assert default_config_dir() == Path.home() / ".meshterm"
+110
View File
@@ -0,0 +1,110 @@
"""Tests for the one-interactive-session-per-directory guard, and the atomic writer.
Both exist for the same reason: every copy of MeshTerm on a machine shares one data
directory unless told otherwise, so two of them running at once is a thing that happens
by accident rather than by choice.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
from meshterm.core.atomicwrite import write_atomically
from meshterm.core.instancelock import LOCK_NAME, InstanceBusy, hold_instance_lock
REPO_ROOT = Path(__file__).resolve().parent.parent
def test_the_lock_is_released_when_the_holder_leaves(tmp_path: Path) -> None:
"""Two sessions in sequence are fine; the guard is against overlap, not against reuse."""
with hold_instance_lock(tmp_path):
pass
with hold_instance_lock(tmp_path):
pass
def test_the_lock_file_lives_beside_the_data(tmp_path: Path) -> None:
"""The lock sits in the directory it guards, and the directory is made if missing."""
target = tmp_path / "not-yet"
with hold_instance_lock(target):
assert (target / LOCK_NAME).exists()
def test_a_second_process_is_turned_away(tmp_path: Path) -> None:
"""The real test of an OS lock: another *process* cannot take it.
Nothing in-process proves anything here — a lock that only excluded the thread that
already holds it would pass a single-process test and fail in the one situation it
exists for.
"""
program = (
"import pathlib, sys\n"
f"sys.path.insert(0, {str(REPO_ROOT)!r})\n"
"from meshterm.core.instancelock import hold_instance_lock, InstanceBusy\n"
"try:\n"
f" with hold_instance_lock(pathlib.Path({str(tmp_path)!r})):\n"
" print('TAKEN')\n"
"except InstanceBusy:\n"
" print('REFUSED')\n"
)
with hold_instance_lock(tmp_path):
done = subprocess.run(
[sys.executable, "-c", program], capture_output=True, text=True, timeout=60
)
assert done.stdout.strip() == "REFUSED", done.stderr[-500:]
def test_the_refusal_says_how_to_run_two(tmp_path: Path) -> None:
"""The message is mostly the way out, because that is what the reader needs next.
Someone who sees this is doing something reasonable — trying a downloaded build, or
running a second radio — so being told *no* without being told *how* would be the
unhelpful half of the answer.
"""
message = str(InstanceBusy(tmp_path))
assert "MESHTERM_HOME" in message
assert str(tmp_path) in message
def test_a_write_lands_whole_or_not_at_all(tmp_path: Path) -> None:
"""The written file holds exactly what was asked for, and no scratch file survives."""
target = tmp_path / "sub" / "contacts.json"
write_atomically(target, json.dumps({"a": 1}))
assert json.loads(target.read_text(encoding="utf-8")) == {"a": 1}
assert list(tmp_path.rglob("*.tmp")) == []
def test_the_scratch_file_is_named_for_its_writer(tmp_path: Path, monkeypatch) -> None:
"""Two processes writing the same file must not share one temporary name.
The rename is atomic; the thing being renamed is not. A fixed ``.tmp`` name lets two
writers interleave inside one scratch file and then each rename whatever ended up
there over the real one.
"""
seen: list[Path] = []
real = Path.write_text
def spy(self: Path, *args: object, **kwargs: object) -> int:
seen.append(self)
return real(self, *args, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(Path, "write_text", spy)
write_atomically(tmp_path / "x.json", "{}")
assert seen and str(__import__("os").getpid()) in seen[0].name
def test_a_failed_write_leaves_no_litter(tmp_path: Path, monkeypatch) -> None:
"""A write that raises cleans up after itself rather than leaving a partial file."""
def boom(self: Path, *args: object, **kwargs: object) -> int:
raise OSError("disk full")
monkeypatch.setattr(Path, "write_text", boom)
with pytest.raises(OSError):
write_atomically(tmp_path / "y.json", "{}")
assert list(tmp_path.rglob("*.tmp")) == []
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the log file: plain text, bounded, and holding the things that went wrong.
The log's one real job is being opened by a person who has hit a problem — often the
person reporting it. Every assertion here is about that reader.
"""
from __future__ import annotations
import logging
from pathlib import Path
from rich.console import Console
from meshterm.persistence.logging import (
LOG_FILENAME,
configure_logging,
get_logger,
level_from_name,
log_path,
)
def _read(log_dir: Path) -> str:
for handler in get_logger().handlers:
handler.flush()
return log_path(log_dir).read_text(encoding="utf-8")
def test_the_log_is_plain_text(tmp_path: Path) -> None:
"""One readable line per record: when, how bad, who said it, and what."""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
get_logger("core.connection").warning("link lost, retrying")
line = _read(tmp_path).splitlines()[0]
assert "WARNING" in line
assert "meshterm.core.connection" in line
assert "link lost, retrying" in line
# A date as well as a time. "it happened at 14:40" is useless a week later.
assert line.startswith("20")
def test_the_file_is_named_plainly(tmp_path: Path) -> None:
"""``meshterm.log`` — an extension every text editor already opens."""
assert log_path(tmp_path).name == LOG_FILENAME == "meshterm.log"
def test_the_level_keeps_the_quiet_records_out(tmp_path: Path) -> None:
"""At the default, the file holds problems rather than a narration of a working run.
This is the whole point of the default being WARNING: a log where the dozen useful
lines sit under twenty thousand dull ones is one nobody reads.
"""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
log = get_logger()
log.debug("opened a screen")
log.info("connected to a radio")
log.warning("something went wrong")
written = _read(tmp_path)
assert "something went wrong" in written
assert "opened a screen" not in written
assert "connected to a radio" not in written
def test_turning_it_up_lets_everything_through(tmp_path: Path) -> None:
"""DEBUG is there for chasing something, and keeps what WARNING drops."""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.DEBUG, quiet=True)
get_logger().debug("opened a screen")
assert "opened a screen" in _read(tmp_path)
def test_a_traceback_goes_in_with_the_record(tmp_path: Path) -> None:
"""An exception's traceback is written under the record that reports it.
This is most of why the file is worth attaching to a bug report: the terminal scrolls
away and gets closed, and this copy does not.
"""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
try:
raise ValueError("the device said something impossible")
except ValueError:
get_logger().exception("tool raised")
written = _read(tmp_path)
assert "tool raised" in written
assert "ValueError: the device said something impossible" in written
assert "Traceback (most recent call last)" in written
def test_the_file_is_bounded(tmp_path: Path) -> None:
"""It rotates. An unbounded log reached 11MB in ten weeks on a real install."""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
handler = next(h for h in get_logger().handlers if hasattr(h, "maxBytes"))
assert handler.maxBytes > 0
assert handler.backupCount > 0
def test_a_level_name_becomes_a_level(tmp_path: Path) -> None:
"""The preference stores a name; the handler needs a number."""
assert level_from_name("DEBUG") == logging.DEBUG
assert level_from_name("warning") == logging.WARNING
assert level_from_name(" Error ") == logging.ERROR
def test_a_nonsense_level_still_starts_the_app() -> None:
"""A typo in a hand-edited preferences file loses log detail, not the app.
Someone editing YAML by hand and writing ``WARN`` should get a slightly quieter log,
not a program that refuses to launch.
"""
assert level_from_name("WARN") == logging.WARNING
assert level_from_name("") == logging.WARNING