From 99b991707ed6225e509cc0189e27a8ba4f7e6252 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Tue, 6 Oct 2026 21:37:03 -0400 Subject: [PATCH] Comments of the scripts and the packaging files are in ASD-STE100 The Python scripts, the packaging code, and the full-line comments of the shell scripts. Heredocs, marker lines, licence lines, and code do not change. The help text of type-text.py now starts with a complete STE sentence, and uart1-mux.py now says three writes, as the code does. Co-Authored-By: Claude Opus 5.5 (1M context) --- packaging/entry.py | 13 +- packaging/meshterm.spec | 117 +++--- packaging/notices.py | 262 +++++++------- scripts/basemap-doctor.py | 186 +++++----- scripts/cardputer-zero/launcher-entry.sh | 22 +- scripts/cardputer-zero/type-text.py | 56 +-- .../calculinux-console-font-6x12.sh | 123 ++++--- .../calculinux-console-font-6x8.sh | 58 +-- .../picocalc-lyra/calculinux-radio-bridge.sh | 70 ++-- scripts/picocalc-lyra/calculinux-setup.sh | 121 ++++--- scripts/picocalc-lyra/calculinux-wifi-set.sh | 17 +- .../xiao-radio/build-firmware.sh | 26 +- scripts/picocalc-lyra/xiao-radio/flash.py | 54 +-- .../picocalc-lyra/xiao-radio/lyra-setup.sh | 17 +- scripts/picocalc-lyra/xiao-radio/uart1-mux.py | 33 +- scripts/uconsole/meshterm-spi-bridge | 341 +++++++++--------- 16 files changed, 797 insertions(+), 719 deletions(-) diff --git a/packaging/entry.py b/packaging/entry.py index 97f83ca..268143b 100644 --- a/packaging/entry.py +++ b/packaging/entry.py @@ -1,10 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 """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. +PyInstaller freezes a script, not an entry point for a console script. This file is that +script. It does what the ``meshterm`` command does and nothing more. It is in ``packaging/`` +and not in the package, because it exists for the installers and must not be importable. """ from __future__ import annotations @@ -13,9 +12,9 @@ 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. + # Windows and macOS start child processes when they run this executable again. Without + # this call, a frozen app that starts a child process starts a second copy of the whole + # UI instead. This looks like an app that starts itself again and again. multiprocessing.freeze_support() from meshterm.cli import main diff --git a/packaging/meshterm.spec b/packaging/meshterm.spec index f57b7c9..b688208 100644 --- a/packaging/meshterm.spec +++ b/packaging/meshterm.spec @@ -3,45 +3,48 @@ # # 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. +# The build is one file, in console mode. Console mode is necessary: MeshTerm is a terminal +# program. A windowed build has no place to draw, and it exits at once. # -# 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. +# Build on the machine that is the target. There is no cross-compiling here. The Windows +# build comes from a Windows runner, and the macOS build comes from macOS. This is the +# purpose of the workflow. import re import sys from PyInstaller.utils.hooks import collect_submodules -# `packaging/notices.py` builds THIRD-PARTY-NOTICES.txt at build time; it sits beside -# this spec rather than under `meshterm/` because — like `entry.py` above — it has no -# business being importable from the installed package, only from the freezer. `SPECPATH` -# is one of the names PyInstaller injects into a running .spec's namespace (the directory -# holding this file, resolved from the path given to `pyinstaller`, not the process's -# cwd), so this finds it however the spec was invoked. +# `packaging/notices.py` builds THIRD-PARTY-NOTICES.txt at build time. It is next to this +# spec and not under `meshterm/`, because it must not be importable from the installed +# package. Only the freezer imports it. The same is true of `entry.py` above. `SPECPATH` +# is one of the names that PyInstaller adds to the namespace of a .spec that runs. It is +# the directory that holds this file. PyInstaller resolves it from the path that was given +# to `pyinstaller`, not from the cwd of the process. Thus the spec finds `notices.py` +# with any way of starting it. sys.path.insert(0, SPECPATH) -import notices # noqa: E402 (import must follow the sys.path edit above) +import notices # noqa: E402 - the import must come after the sys.path change above -# Generated fresh on every build rather than checked into git: it is a function of -# whichever distributions are actually installed in *this* interpreter, and a stale copy -# would drift the moment a dependency's license text changed upstream without anyone -# noticing. `workpath` is PyInstaller's own scratch directory for exactly this kind of -# build-time artefact — cleaned by `--clean`, never mistaken for a source file. It lands -# at `//THIRD-PARTY-NOTICES.txt` (PyInstaller appends the spec's own -# name, minus its extension, to whatever workpath it was given — see `build()` in -# `PyInstaller/building/build_main.py`), which for this spec means `build/meshterm/`. +# The build makes this file new each time. It is not in git. It depends on the +# distributions that are installed in this interpreter. A copy that is not current can +# become wrong when the licence text of a dependency changes upstream and nobody notices. +# `workpath` is the temporary directory of PyInstaller for this type of build-time +# artefact. `--clean` cleans it, and nobody can mistake it for a source file. The file goes +# to `//THIRD-PARTY-NOTICES.txt`. PyInstaller adds the name of the spec, +# without its extension, to the workpath that it receives (refer to `build()` in +# `PyInstaller/building/build_main.py`). For this spec, this is `build/meshterm/`. third_party_notices = notices.write_third_party_notices( os.path.join(workpath, "THIRD-PARTY-NOTICES.txt") ) -# The Windows build's version resource: the Details tab of the file's Properties, and the -# name and version Windows shows for it. Without one the .exe is just "meshterm.exe" with -# no product behind it, and SignPath refuses to sign it — their signing configuration -# checks the product name and version written here, so a file that doesn't carry them -# can't be passed off as a MeshTerm release. Built from the package's own `__version__` -# rather than kept as a second copy that would drift. The other platforms have no such -# resource, and PyInstaller warns if handed one there. +# The version resource of the Windows build. It is the Details tab of the Properties of the +# file, and the name and version that Windows shows for it. Without it, the .exe is only +# "meshterm.exe" with no product, and SignPath refuses to sign it. The signing configuration +# of SignPath checks the product name and version that this resource has. Thus a file +# without them cannot pass as a MeshTerm release. The spec builds the resource from the +# `__version__` of the package. It does not keep a second copy, because a second copy can +# become wrong. The other platforms have no such resource, and PyInstaller gives a warning +# if the spec gives one there. version_info = None if sys.platform == "win32": from PyInstaller.utils.win32.versioninfo import ( @@ -54,14 +57,14 @@ if sys.platform == "win32": VSVersionInfo, ) - # The checkout being frozen, not whichever MeshTerm the interpreter happens to have - # installed: `Analysis` below reads its sources from here too. + # This is the checkout that PyInstaller freezes. It is not a MeshTerm that the + # interpreter has installed. `Analysis` below also reads its sources from here. sys.path.insert(0, os.path.dirname(SPECPATH)) - import meshterm # noqa: E402 (import must follow the sys.path edit above) + import meshterm # noqa: E402 - the import must come after the sys.path change above - # The resource's fixed half holds exactly four numbers: 0.10.2 → (0, 10, 2, 0). A - # suffix such as `rc1` has nowhere to go there and is dropped; the text fields below - # keep the version exactly as written. + # The fixed half of the resource has exactly four numbers: 0.10.2 becomes (0, 10, 2, 0). + # A suffix such as `rc1` has no place there, and the spec removes it. The text fields + # below keep the version exactly as written. numbers = [int(n) for n in re.match(r"\d+(?:\.\d+)*", meshterm.__version__)[0].split(".")] numbers = tuple((numbers + [0, 0, 0, 0])[:4]) version_info = VSVersionInfo( @@ -69,8 +72,8 @@ if sys.platform == "win32": kids=[ StringFileInfo( [ - # 0409 is US English and 04B0 says the strings are Unicode — the - # pairing nearly every Windows program declares. + # 0409 is US English, and 04B0 shows that the strings are Unicode. + # Nearly all Windows programs declare this pair. StringTable( "040904B0", [ @@ -90,37 +93,39 @@ if sys.platform == "win32": ], ) -# 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. +# The assets must go to `meshterm/assets`. `ui/about.py` finds them with +# `Path(__file__).parent.parent / "assets"`, and this path must resolve in the bundle in the +# same way as in a checkout. datas = [ ("../meshterm/assets", "meshterm/assets"), - # The SPI radio's node is run by path under *another* Python — the one with the radio - # library, which this build doesn't carry — so it has to exist as a source file, beside - # where `spiradio.node_script()` looks for it, not only as bytecode in the archive. + # The node of the SPI radio runs by path under another Python. This is the Python that + # has the radio library, and this build does not have it. Thus the node must exist as a + # source file, next to the place where `spiradio.node_script()` looks for it. Bytecode in + # the archive is not sufficient. ("../meshterm/core/radionode.py", "meshterm/core"), - # A one-file build is a *copy* of MeshTerm, and Apache-2.0 §4(a)/(d) want MeshTerm's - # own LICENSE and NOTICE distributed with every copy — not folded into the app's own - # assets, but sitting at the bundle root the way they sit at the repository root. + # A build of one file is a copy of MeshTerm. Apache-2.0 §4(a) and §4(d) require the + # LICENSE and the NOTICE of MeshTerm with each copy. They must not be inside the assets + # of the app. They must be at the root of the bundle, as they are at the root of the + # repository. ("../LICENSE", "."), ("../NOTICE", "."), - # Every MIT/BSD/PSF dependency's own notice wants to travel with copies the same way; - # see packaging/notices.py for how this is built. + # The notice of each MIT, BSD, and PSF dependency must go with the copies in the same + # way. Refer to packaging/notices.py for how the build makes it. (str(third_party_notices), "."), ] 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. + # Each tool module. `meshterm.tools` finds these with `pkgutil.iter_modules` at import + # time, so no static code names a tool module. A frozen build without this line starts + # with an empty registry: no menu entries except Quit, and no CLI subcommands. The app + # starts and looks correct, and this is the worst way for the failure to appear. *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. + # bleak chooses its backend at run time, by platform. Thus static analysis never sees + # the backend that the app uses. Collect all the backends, and keep the unused ones. *collect_submodules("bleak.backends"), - # Same story for the companion library's transports. + # The same is true for the transports of the companion library. *collect_submodules("meshcore"), - # Rich and prompt_toolkit both reach for things dynamically. + # Rich and prompt_toolkit both use some modules dynamically. "rich.console", "prompt_toolkit.output.win32", "prompt_toolkit.output.vt100", @@ -135,7 +140,7 @@ a = Analysis( hookspath=[], hooksconfig={}, runtime_hooks=[], - # Nothing here is used, and each one drags in a GUI toolkit or a test framework. + # The app does not use these modules, and each one adds a GUI toolkit or a test framework. excludes=["tkinter", "pytest", "IPython", "matplotlib", "numpy", "PIL"], noarchive=False, ) @@ -152,8 +157,8 @@ exe = EXE( 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 is off on purpose. It saves a few megabytes, but antivirus software often flags + # the result. For a first release, this is not a good trade. upx=False, console=True, disable_windowed_traceback=False, diff --git a/packaging/notices.py b/packaging/notices.py index d54cef7..ece346c 100644 --- a/packaging/notices.py +++ b/packaging/notices.py @@ -1,30 +1,33 @@ # SPDX-License-Identifier: Apache-2.0 -"""Builds ``THIRD-PARTY-NOTICES.txt``: every license the frozen build has to carry. +"""Build ``THIRD-PARTY-NOTICES.txt``: each licence that the frozen build must carry. -A one-file PyInstaller build is a *copy* of MeshTerm and of everything MeshTerm imports -at runtime — Apache-2.0 §4(a)/(d) want MeshTerm's own LICENSE and NOTICE distributed -with every such copy, and each MIT/BSD/PSF dependency wants its own notice kept with -copies too. Before this module existed, the spec (``packaging/meshterm.spec``) bundled -neither: the five installers carried the bundled font's license and nothing else. +A PyInstaller build of one file is a copy of MeshTerm and of all the code that MeshTerm +imports at run time. Apache-2.0 §4(a) and §4(d) require the LICENSE and NOTICE of MeshTerm +with each such copy. Each MIT, BSD, and PSF dependency also requires that its own notice +stays with the copies. Before this module existed, the spec (``packaging/meshterm.spec``) +bundled neither of them. The five installers carried the licence of the bundled font and +nothing more. -This is deliberately a *generator*, not a checked-in file: a checked-in copy invites -drift the moment a dependency changes hands or its license text, silently, the next time -someone bumps a version pin. It runs at build time (see the spec) and again in -:mod:`tests.test_notices`, walking the actual runtime dependency closure of the -``mesh-term`` distribution installed in *this* interpreter — so a build's notices always -match what that build actually bundled, on whichever platform built it. A dependency -only one platform needs (a bleak backend, ``tomli`` below 3.11) shows up only in that -platform's notices, because it was never in the other platforms' closures to begin with. +This module is a generator on purpose, and not a file in git. A copy in git becomes wrong, +with no message, when a dependency changes owner or its licence text, or when somebody +changes a version pin. The generator runs at build time (refer to the spec) and again in +:mod:`tests.test_notices`. It examines the real runtime dependency closure of the +``mesh-term`` distribution that is installed in this interpreter. Thus the notices of a +build always match the packages that the build bundled, on the platform that built it. A +dependency that only one platform needs (a bleak backend, ``tomli`` below 3.11) is only in +the notices of that platform, because it was never in the closures of the other platforms. -Two things get no exception: +Two rules have no exception: -* A dependency with no discoverable license file fails the whole build, loudly, rather - than shipping quietly without one — see :data:`_VENDORED_LICENSE_GAPS` for the one - currently-known exception, and why it is a documented fix rather than a silent one. -* Python's own PSF license is always the first entry, found the same way the ``license`` - builtin finds it (reusing its own candidate-file search, not re-deriving the layout it - already knows) — venv or not, Windows or POSIX, a future interpreter layout it wasn't - written against still resolves correctly, or fails loudly instead of guessing wrong. +* If a dependency has no licence file that the module can find, the whole build fails with + an error. The build must not ship with no message and without the file. Refer to + :data:`_VENDORED_LICENSE_GAPS` for the one exception that we know, and the reason that + it is a documented repair and not a silent one. +* The PSF licence of Python is always the first entry. The module finds it in the same way + as the ``license`` builtin. It uses the search of that builtin for candidate files, and it + does not work out the layout again. Thus it resolves correctly with a venv or without + one, on Windows or POSIX. A future interpreter layout that the code does not expect + also resolves correctly, or it fails with an error and does not guess wrongly. """ from __future__ import annotations @@ -40,36 +43,35 @@ from packaging.markers import default_environment from packaging.requirements import Requirement from packaging.utils import canonicalize_name -#: The distribution this build actually ships — see ``pyproject.toml``. Not a parameter -#: callers are expected to vary: a notices file for a *different* distribution's closure -#: would not describe what this build bundles. +#: The distribution that this build ships (refer to ``pyproject.toml``). Callers must not +#: change it as a parameter. A notices file for the closure of a different distribution +#: does not describe what this build bundles. ROOT_DISTRIBUTION = "mesh-term" -#: A handful of dependencies whose wheels on PyPI carry a license *classifier* or -#: *expression* but were built and uploaded without the license text itself — verified -#: by inspecting each wheel directly (``unzip -l``), not assumed from the metadata -#: alone. Their real text is vendored here, unmodified, from each project's own -#: repository — see each vendored file's own header for the exact source and the date -#: it was pulled. Nothing else gets this treatment: a dependency not listed here still -#: fails the build the moment its wheel has the same gap, which is the point — this is -#: a documented fix for known, verified problems, not a general escape hatch for an -#: unknown future one. +#: A small number of dependencies. Their wheels on PyPI have a licence *classifier* or +#: *expression*, but the authors built and uploaded them without the licence text. We +#: verified this by an examination of each wheel (``unzip -l``). We did not assume it from +#: the metadata alone. The real text is stored here, with no change, from the repository of +#: each project. The header of each stored file gives the exact source and the date of the +#: download. No other dependency gets this treatment. A dependency that is not in this list +#: still fails the build if its wheel has the same gap. This is the purpose of the list: it +#: is a documented repair for known problems that we verified. It is not a general way out +#: for an unknown problem in the future. #: -#: * ``pyserial`` — no newer release exists to bump to; 3.5 (2020) is still the latest -#: pyserial on PyPI, and its wheel has never shipped a LICENSE, COPYING, or NOTICE -#: file of any kind. -#: * The nine ``winrt-*`` packages — bleak's Windows BLE backend (``bleak.backends. -#: winrt``) pulls these in only on ``sys_platform == "win32"``, so they only appear -#: in a Windows build's closure. All nine are generated and published together by the -#: pywinrt project and share the one upstream license file, which none of their -#: wheels include. -#: * ``pyobjc-core`` and ``pyobjc-framework-libdispatch`` — two of the four pyobjc -#: packages bleak's macOS BLE backend (``bleak.backends.corebluetooth``) pulls in on -#: ``sys_platform == "darwin"``, so they only appear in a macOS build's closure, which -#: is where the 0.3.3 release build caught them. Their 12.2.2 wheels ship no license -#: file; the other two (Cocoa, CoreBluetooth) do, and are read from their dist-info -#: like everything else. All four come from the one pyobjc repository and its single -#: MIT text. +#: * ``pyserial``: no newer release exists. Version 3.5 (2020) is still the latest pyserial +#: on PyPI, and its wheel never included a LICENSE, COPYING, or NOTICE file. +#: * The nine ``winrt-*`` packages: the Windows BLE backend of bleak +#: (``bleak.backends.winrt``) adds these only on ``sys_platform == "win32"``, so they are +#: only in the closure of a Windows build. The pywinrt project generates and publishes all +#: nine together. They share one upstream licence file, which none of their wheels +#: include. +#: * ``pyobjc-core`` and ``pyobjc-framework-libdispatch``: two of the four pyobjc packages +#: that the macOS BLE backend of bleak (``bleak.backends.corebluetooth``) adds on +#: ``sys_platform == "darwin"``. Thus they are only in the closure of a macOS build, and +#: the release build of 0.3.3 found them there. Their 12.2.2 wheels have no licence file. +#: The other two (Cocoa, CoreBluetooth) have one, and the module reads it from their +#: dist-info as it does for all other packages. All four come from the one pyobjc +#: repository and its single MIT text. _VENDORED_LICENSE_GAPS: dict[str, tuple[str, ...]] = { "pyserial": ("vendored-licenses/pyserial-LICENSE.txt",), "pyobjc-core": ("vendored-licenses/pyobjc-LICENSE.txt",), @@ -87,18 +89,18 @@ _VENDORED_LICENSE_GAPS: dict[str, tuple[str, ...]] = { "winrt-windows-storage-streams": ("vendored-licenses/pywinrt-LICENSE.txt",), } -#: Conventional bare-directory license filenames a pre-PEP-639 wheel might bundle at its -#: dist-info root without ever declaring them in metadata. Matched case-insensitively by -#: prefix, so ``LICENSE``, ``LICENSE.txt``, ``LICENSE-MIT`` and ``License.rst`` all hit. +#: The usual licence file names that a wheel from before PEP 639 can bundle at its dist-info +#: root, with no declaration in the metadata. The match ignores case and uses the prefix. +#: Thus ``LICENSE``, ``LICENSE.txt``, ``LICENSE-MIT``, and ``License.rst`` all match. _CONVENTIONAL_LICENSE_PREFIXES = ("LICENSE", "LICENCE", "COPYING", "NOTICE") class NoticesError(RuntimeError): - """A license notice this build needs could not be found. + """A licence notice that this build needs is not available. - Raised instead of quietly omitting an entry — a bundle that silently drops a notice - is the exact failure mode this module exists to prevent, so a missing one stops the - build rather than shipping. + The module raises this error instead of a silent omission of an entry. A bundle that + drops a notice with no message is the exact failure that this module prevents. Thus a + missing notice stops the build, and the build does not ship. """ @@ -107,13 +109,14 @@ class Notice: """One entry in the generated notices file. Attributes: - name: The distribution's own name, as PyPI (or, for Python itself, the - ``platform`` module) spells it — not canonicalised, so the rendered file - reads the way a person searching for the package by its usual name expects. + name: The name of the distribution, as PyPI spells it. For Python itself, it is the + spelling of the ``platform`` module. The name is not canonicalised. Thus the + file shows the name that a person expects when they search for the package. version: The installed version. - summary: The license expression or classifier(s) this distribution declares, - for the header line. Never the license text itself — that is :attr:`text`. - text: The verbatim license text (one or more files, concatenated), unmodified. + summary: The licence expression or classifiers that this distribution declares, for + the header line. It is never the licence text. The licence text is :attr:`text`. + text: The licence text, word for word and with no change (one or more files, + joined). """ name: str @@ -123,25 +126,26 @@ class Notice: def _python_notice() -> Notice: - """Return the PSF license notice for the interpreter running this build. + """Return the PSF licence notice for the interpreter that runs this build. - Reuses the candidate-file search the ``license`` builtin itself does - (``site.setcopyright`` builds it from ``os.path.dirname(os.__file__)`` plus the - Windows install-root layout, the POSIX lib layout, and a same-directory fallback) - rather than re-deriving those paths and risking a layout it didn't anticipate. This - resolves correctly from inside a venv too, without any venv-specific casing here, - because ``os.__file__`` always points at the *base* interpreter's standard library. + The function uses the search for candidate files that the ``license`` builtin does. + ``site.setcopyright`` builds the search from ``os.path.dirname(os.__file__)``, the + install-root layout of Windows, the lib layout of POSIX, and a fallback in the same + directory. The function does not work out these paths again, because a layout that it + does not expect can occur. It also resolves correctly in a venv, with no special code + for a venv. This is because ``os.__file__`` always points to the standard library of + the base interpreter. Raises: - NoticesError: No candidate file exists, and the ``license`` builtin's own - fallback text (a pointer to a URL, not license text) is not good enough to - ship as a notice. + NoticesError: No candidate file exists. The fallback text of the ``license`` + builtin is a pointer to a URL and not licence text, so it is not good enough + to ship as a notice. """ printer = getattr(builtins, "license", None) if printer is None: - # `site` normally installs this at interpreter startup; it is only missing if - # the interpreter was started with `-S`. Install it ourselves rather than give - # up — a build environment that happens to do this shouldn't lose the notice. + # `site` usually installs this at the start of the interpreter. It is missing only + # if the interpreter started with `-S`. Install it here and do not give up. A build + # environment that starts the interpreter in this way must not lose the notice. import site site.setcopyright() @@ -168,14 +172,15 @@ def _python_notice() -> Notice: def _license_summary(dist: Distribution) -> str: - """The short "which license" line for a distribution's header. + """The short "which licence" line for the header of a distribution. - Prefers the PEP 639 ``License-Expression`` (the SPDX expression a modern build - backend writes), then the ``License ::`` classifiers older packages rely on - instead, then the legacy free-text ``License`` field only if it is short enough to - be a label rather than a pasted-in license itself. "unspecified" is a last resort, - not a failure: an unclear *label* is a readability problem, unlike a missing license - *file*, which is a compliance one and raises instead (see :func:`_license_text`). + The first choice is the PEP 639 ``License-Expression`` (the SPDX expression that a + modern build backend writes). The second choice is the ``License ::`` classifiers that + older packages use. The third choice is the old free-text ``License`` field, but only + if it is short enough to be a label and not a pasted licence. "unspecified" is the last + choice, and it is not a failure. An unclear label is a problem of readability. A missing + licence file is a problem of compliance, and it raises an error (refer to + :func:`_license_text`). """ metadata = dist.metadata expression = metadata.get("License-Expression") @@ -191,42 +196,42 @@ def _license_summary(dist: Distribution) -> str: def _dist_info_files(dist: Distribution) -> list[PackagePath]: - """Every file RECORD lists under this distribution's own ``.dist-info`` directory. + """Each file that RECORD lists under the own ``.dist-info`` directory of this distribution. - Built from :attr:`Distribution.files` rather than the private - ``Distribution._path`` some tools reach for, so this stays correct however a future - ``importlib.metadata`` lays a distribution's metadata out on disk — it only ever - asks the distribution for files it already told us it owns. + The function uses :attr:`Distribution.files`. It does not use the private + ``Distribution._path`` that some tools use. Thus the function stays correct if a future + ``importlib.metadata`` puts the metadata of a distribution in a different layout on the + disk. The function only asks the distribution for files that it already says it owns. """ files = dist.files or [] return [f for f in files if str(f).split("/", 1)[0].endswith(".dist-info")] def _license_files(dist: Distribution) -> list[PackagePath]: - """The dist-info files that make up this distribution's license text. + """The dist-info files that make the licence text of this distribution. - PEP 639's ``License-File`` metadata entries win when present, matched by basename - since a build backend is free to nest them (hatchling puts every one under a - ``licenses/`` subdirectory; other tools leave them at the dist-info root). Lacking - any declaration at all — true of most wheels built before 2024 — falls back to - whatever the wheel happened to bundle at the dist-info root under a conventional - name, which is the same thing a person skimming the directory would go looking for. + If the metadata has ``License-File`` entries (PEP 639), the function uses them. It + matches them by base name, because a build backend can put them in a subdirectory. + (hatchling puts each one in a ``licenses/`` subdirectory. Other tools leave them at the + dist-info root.) If there is no declaration, the function uses the files that the wheel + has at the dist-info root, with a usual name. Most wheels from before 2024 have no + declaration. A person who looks in the directory searches for the same files. """ dist_files = _dist_info_files(dist) declared = dist.metadata.get_all("License-File") or [] if declared: wanted = [Path(name).name for name in declared] by_name = {Path(str(f)).name: f for f in dist_files} - # Declared order, not RECORD's, so the concatenated text below reads in the - # order the package's own author declared it. + # Use the declared order, not the order of RECORD. Thus the joined text below has + # the order that the author of the package declared. return [by_name[name] for name in wanted if name in by_name] return sorted( ( f for f in dist_files - # dist-info root only — one path segment below the directory itself. A - # nested `licenses/` folder with no metadata declaration pointing at it - # isn't a convention to guess at. + # Only the dist-info root: one path segment below the directory itself. A + # nested `licenses/` folder that no metadata declaration names is not a + # convention that the function must guess. if str(f).count("/") == 1 and Path(str(f)).name.upper().startswith(_CONVENTIONAL_LICENSE_PREFIXES) ), @@ -235,18 +240,18 @@ def _license_files(dist: Distribution) -> list[PackagePath]: def _vendored_license_files(canonical_name: str) -> list[Path]: - """Local fallback license text for a dependency whose wheel ships none at all.""" + """The local fallback licence text for a dependency whose wheel has none.""" here = Path(__file__).parent return [here / rel for rel in _VENDORED_LICENSE_GAPS.get(canonical_name, ())] def _license_text(dist: Distribution, canonical_name: str) -> str: - """The concatenated, verbatim license text for one distribution. + """The licence text of one distribution, joined and word for word. Raises: - NoticesError: Nothing was found in the dist-info and nothing is vendored for - it either. Nothing downstream is in a position to notice a quietly-missing - notice, so this stops the build instead of shipping a gap. + NoticesError: The dist-info has no licence file, and no file is stored for the + distribution. No later step can notice a notice that is missing with no + message. Thus this error stops the build, and the build does not ship a gap. """ matches = _license_files(dist) if matches: @@ -275,23 +280,22 @@ def _license_text(dist: Distribution, canonical_name: str) -> str: def dependency_closure(root: str = ROOT_DISTRIBUTION) -> list[Distribution]: - """The installed distributions ``root`` actually pulls in on this interpreter. + """The installed distributions that ``root`` pulls in on this interpreter. - Walks ``Requires-Dist`` recursively (``importlib.metadata`` exposes it as - :attr:`Distribution.requires`), evaluating each requirement's environment marker - against *this* interpreter and platform — with ``extra`` forced to the empty - string, which is what "no extras requested" means to a marker (a bare - ``pip install mesh-term`` asks for none), so a requirement guarded by - ``extra == "dev"`` reads as false and the ``dev`` extra's own tooling (pytest, - pytest-asyncio, ruff) never enters the closure. A marker that instead names a - platform or Python version this interpreter doesn't match is excluded the same - way — the point of evaluating markers at all rather than reading every - ``Requires-Dist`` line flat. + The function walks ``Requires-Dist`` recursively (``importlib.metadata`` gives it as + :attr:`Distribution.requires`). It evaluates the environment marker of each requirement + against this interpreter and platform. It sets ``extra`` to the empty string. For a + marker, this means that no extras are requested (a bare ``pip install mesh-term`` asks + for none). Thus a requirement with ``extra == "dev"`` is false, and the tools of the + ``dev`` extra (pytest, pytest-asyncio, ruff) never enter the closure. A marker that + names a platform or a Python version that this interpreter does not match is excluded in + the same way. This is the reason to evaluate the markers, and not to read each + ``Requires-Dist`` line as plain text. Returns: - Distributions sorted by their canonical (PEP 503) name, deterministically, and - never including ``root`` itself — MeshTerm's own license is ``LICENSE`` and - ``NOTICE``, not a third-party notice. + The distributions, in the order of their canonical (PEP 503) name. The order is + deterministic. The list never includes ``root`` itself, because the licence of + MeshTerm is ``LICENSE`` and ``NOTICE``, and it is not a third-party notice. """ environment = default_environment() environment["extra"] = "" @@ -315,12 +319,12 @@ def dependency_closure(root: str = ROOT_DISTRIBUTION) -> list[Distribution]: def collect_notices(root: str = ROOT_DISTRIBUTION) -> list[Notice]: - """All notices this build's ``THIRD-PARTY-NOTICES.txt`` should carry. + """All the notices that ``THIRD-PARTY-NOTICES.txt`` of this build must carry. - Python's own PSF notice always comes first — Python is not a PyPI distribution - ``Requires-Dist`` can name, so it can never turn up in :func:`dependency_closure`. - Every dependency follows it, sorted by name, for a stable, reviewable diff between - one build's notices file and the next. + The PSF notice of Python is always first. Python is not a PyPI distribution that + ``Requires-Dist`` can name, so it can never appear in :func:`dependency_closure`. Each + dependency follows it, in the order of its name. Thus the difference between the notices + file of one build and the next is stable, and a person can examine it. """ notices = [_python_notice()] for dist in dependency_closure(root): @@ -352,7 +356,7 @@ _RULE = "=" * 78 def render(notices: list[Notice]) -> str: - """Render ``notices`` as one deterministic, newline-terminated UTF-8 document.""" + """Render ``notices`` as one UTF-8 document that is deterministic and ends with a newline.""" blocks = [_HEADER] for notice in notices: header = f"{notice.name} {notice.version} — {notice.summary}" @@ -361,14 +365,14 @@ def render(notices: list[Notice]) -> str: def write_third_party_notices(path: str | Path, root: str = ROOT_DISTRIBUTION) -> Path: - r"""Generate and write ``THIRD-PARTY-NOTICES.txt`` to ``path``. + r"""Generate ``THIRD-PARTY-NOTICES.txt`` and write it to ``path``. - Written as raw UTF-8 bytes rather than through text mode, so a Windows build - doesn't translate the ``\n`` line endings to ``\r\n`` — the file is meant to be - byte-identical in content across the platforms that each build it. + The function writes raw UTF-8 bytes. It does not use text mode. Thus a Windows build + does not change the ``\n`` line endings to ``\r\n``. The content of the file must be the + same, byte for byte, on each platform that builds it. Returns: - ``path``, resolved to a :class:`~pathlib.Path`, for the caller's convenience. + ``path``, as a :class:`~pathlib.Path`, for the convenience of the caller. """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/basemap-doctor.py b/scripts/basemap-doctor.py index 90e70eb..88b221a 100755 --- a/scripts/basemap-doctor.py +++ b/scripts/basemap-doctor.py @@ -1,27 +1,30 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 -"""Diagnose why MeshTerm's map has no basemap, on the machine that has the problem. +"""Find why the map of MeshTerm has no basemap, on the machine that has the problem. -The map is the only networked part of MeshTerm: it fetches OpenStreetMap vector tiles over -HTTPS from the source named by the ``basemap_tilejson_url`` preference. Every failure on -that path — a missing certificate store, DNS, a timeout, a middlebox — is swallowed and -logged at DEBUG (:mod:`meshterm.services.basemap`), and ``log_level`` defaults to WARNING, -so a reader whose tiles never arrive sees a blank ground and no explanation anywhere. +The map is the only part of MeshTerm that uses the network. It downloads OpenStreetMap +vector tiles over HTTPS from the source that the ``basemap_tilejson_url`` preference names. +MeshTerm catches each failure on that path and logs it at DEBUG +(:mod:`meshterm.services.basemap`). The failures include a missing certificate store, DNS, +a timeout, and a middlebox. The default of ``log_level`` is WARNING. Thus a user whose tiles +never arrive sees a blank ground and no explanation anywhere. -This script is that explanation. It is standalone and stdlib-only so it can be pasted onto -any machine running any version of MeshTerm, and it prints one verdict with the fix. +This script gives that explanation. It is standalone and uses only the standard library, so +the user can paste it onto any machine that runs any version of MeshTerm. It prints one +verdict with the fix. -It runs under MeshTerm's *own* interpreter, which is the one thing that matters here: a Mac -routinely has three Pythons, only one of them is the app's, and only that one's certificate -store is the one the map actually uses. Started under any other, it finds the installed -``meshterm`` command, reads the interpreter out of its shebang and hands itself over — so -the user needs no idea which Python they installed into. Save it to a file rather than -piping it into ``python3 -``: with no file on disk there is nothing to hand over. +The script must run under the own interpreter of MeshTerm. This is the important point. A +Mac usually has three Pythons, and only one of them is the Python of the app. Only the +certificate store of that Python is the store that the map uses. If another Python starts +the script, the script finds the installed ``meshterm`` command and reads the interpreter +from its shebang. Then it hands itself over to that interpreter. Thus the user does not +need to know the Python that they installed into. Save the script to a file. Do not pipe it +into ``python3 -``, because with no file on disk the script has nothing to hand over. -The decisive test is an A/B. ``curl`` verifies against the operating system's trust store -and Python verifies against its own, so curl succeeding where Python fails means the -certificates are at fault and nothing about the network is — the one comparison that -separates the two causes that look identical from inside the app. +The decisive test is an A/B. ``curl`` verifies against the trust store of the operating +system, and Python verifies against its own store. If curl succeeds where Python fails, the +certificates are the fault and the network is not. This is the only comparison that +separates two causes that look the same from inside the app. """ from __future__ import annotations @@ -40,21 +43,23 @@ import urllib.request from pathlib import Path from typing import NamedTuple -#: Where tiles come from unless the preference says otherwise. Kept in step with -#: ``basemap.DEFAULT_TILEJSON_URL`` by hand: this script may run against an install whose -#: ``meshterm`` it cannot import, so it cannot read the constant it is mirroring. +#: The place that tiles come from, unless the preference says otherwise. Change it by hand +#: when ``basemap.DEFAULT_TILEJSON_URL`` changes. This script can run for an install whose +#: ``meshterm`` it cannot import, so it cannot read the constant that it copies. FALLBACK_TILEJSON_URL = "https://tiles.openfreemap.org/planet" -#: Per-request timeout, matching ``BasemapSource``'s own so that a link which is merely -#: slow fails here for the same reason it fails in the app rather than for a stricter one. +#: The timeout of each request. It is the same as the timeout of ``BasemapSource``. Thus a +#: link that is only slow fails here for the same reason that it fails in the app, and not +#: for a stricter reason. TIMEOUT = 12.0 -#: What the app sends, so a source that filters by agent treats this run the same way. +#: The user agent that the app sends. Thus a source that filters by agent treats this run +#: in the same way. USER_AGENT = "MeshTerm-basemap-doctor (+https://github.com/jpmartineau/MeshTerm)" -#: A tile that exists: zoom 9 over central Poland. The TileJSON resolving is only half the -#: path — the tiles themselves are often a different host — so the check fetches real -#: geometry before it reports success. +#: A tile that exists: zoom 9 over central Poland. A TileJSON that resolves is only half +#: of the path, because the tiles are often on a different host. Thus the check downloads +#: real geometry before it reports success. SAMPLE_TILE = (9, 285, 167) @@ -69,39 +74,40 @@ def line(label: str, value: object) -> None: def find_meshterm() -> tuple[str | None, str | None, str | None]: - """Report MeshTerm's version, tile URL and config dir, if this interpreter has them. + """Report the MeshTerm version, tile URL, and config directory, if this interpreter has them. Returns: - ``(version, tilejson_url, config_dir)``, each ``None`` where it could not be read. + ``(version, tilejson_url, config_dir)``. Each value is ``None`` when it cannot be read. """ try: import meshterm from meshterm.core.config import default_config_dir from meshterm.core.preferences import Preferences - except Exception: # noqa: BLE001 - any import failure means "not this interpreter" + except Exception: # noqa: BLE001 - an import failure means that this is not the interpreter return None, None, None config_dir = default_config_dir() url = None try: url = Preferences.load(config_dir / "preferences.toml").basemap_tilejson_url - except Exception: # noqa: BLE001 - an unreadable preferences file is itself a finding + except Exception: # noqa: BLE001 - a preferences file that MeshTerm cannot read is a finding pass return getattr(meshterm, "__version__", "?"), url, str(config_dir) -#: Set on the re-executed child, so a second interpreter that still cannot import MeshTerm -#: gets on with the diagnosis instead of handing itself on forever. +#: The script sets this on the child that it runs again. Thus a second interpreter that +#: still cannot import MeshTerm continues with the diagnosis. It does not hand itself over +#: again and again. REEXEC_ENV = "MESHTERM_DOCTOR_REEXEC" def meshterm_interpreter() -> str | None: - """The interpreter the installed ``meshterm`` command runs under, if it can be read. + """The interpreter that runs the installed ``meshterm`` command, if the script can read it. - A console script on Unix is a text file whose shebang names the exact interpreter the - app was installed into — and that interpreter's certificate store is the one the map - actually uses, which no other Python on the machine can speak for. On Windows the - console script is a binary launcher with no shebang, so this finds nothing and the - diagnosis proceeds with a banner saying which interpreter it did use. + On Unix, a console script is a text file. Its shebang names the exact interpreter that + the app was installed into. The certificate store of that interpreter is the store that + the map uses, and no other Python on the machine can answer for it. On Windows, the + console script is a binary launcher with no shebang. Thus this function finds nothing, + and the diagnosis continues with a banner that names the interpreter that it used. """ script = shutil.which("meshterm") if script is None: @@ -113,24 +119,24 @@ def meshterm_interpreter() -> str | None: return None if not first.startswith(b"#!"): return None - # A shebang may carry arguments (``#!/usr/bin/env python3``); the interpreter is the - # first word, and an ``env`` line names no absolute path worth re-execing. + # A shebang can have arguments (``#!/usr/bin/env python3``). The interpreter is the + # first word. An ``env`` line does not name an absolute path that is worth a second run. interpreter = first[2:].decode("utf-8", "replace").strip().split(" ")[0] return interpreter if interpreter and Path(interpreter).is_file() else None def reexec_under_meshterm() -> None: - """Hand this script to MeshTerm's own interpreter when it is not the one running it. + """Hand this script to the interpreter of MeshTerm, if another interpreter runs it. - The user should not have to know which of their three Pythons the app was installed - into — that detail is exactly what the failure is made of. + The user does not need to know which of the three Pythons the app was installed into. + This detail is the cause of the failure. """ if os.environ.get(REEXEC_ENV): return try: if importlib.util.find_spec("meshterm") is not None: return - except (ImportError, ValueError): # a broken or shadowed package: keep looking + except (ImportError, ValueError): # a broken or hidden package: continue the search pass target = meshterm_interpreter() here = Path(globals().get("__file__", "")).resolve() if globals().get("__file__") else None @@ -139,33 +145,39 @@ def reexec_under_meshterm() -> None: if Path(target).resolve() == Path(sys.executable).resolve(): return print(f" (handing over to MeshTerm's own interpreter: {target})") - # The child inherits this stdout, so the parent's buffer has to go out first or the - # banner lands after the report it introduces. Block buffering makes that certain the - # moment the output is piped into a file or a paste, which is how it is meant to - # travel. (``os.execve`` would avoid the extra process, but on Windows it is not a - # real exec: it spawns and exits, and segfaults outright under a redirect.) + # The child uses the same stdout as this process. Thus the buffer of the parent must go + # out first. If it does not, the banner appears after the report that it introduces. + # Block buffering makes this certain when the output goes into a file or a paste, and + # this is the intended use. (``os.execve`` avoids the extra process, but on Windows it + # is not a real exec. It starts a new process and exits, and it crashes with a segfault + # under a redirect.) sys.stdout.flush() child = subprocess.run([target, str(here), *sys.argv[1:]], env={**os.environ, REEXEC_ENV: "1"}) sys.exit(child.returncode) def exists_marker(path: str) -> str: - """Return a marker saying whether ``path`` is actually on disk.""" + """Return a marker that shows whether ``path`` is on the disk.""" return "" if Path(path).exists() else " <- MISSING" def describe_env() -> tuple[str | None, str | None]: - """Print interpreter, platform and MeshTerm facts; return the tile URL and config dir.""" + """Print facts about the interpreter, the platform, and MeshTerm. + + Returns: + The tile URL and the config directory. + """ heading("This machine") line("Python", sys.version.split()[0]) line("Interpreter", sys.executable) line("OS", f"{platform.system()} {platform.release()} ({platform.machine()})") line("TERM", os.environ.get("TERM", "(unset)")) - # Not a tile question, but it is the other thing that makes a map look wrong and it - # costs nothing to answer in the same round trip: no COLORTERM means 256 colours, and - # the basemap's greens and blues are what collide when they are quantized. - # Windows is exempt: prompt_toolkit's Windows output reports 24-bit outright, so an - # unset COLORTERM there says nothing. Everywhere else it is the whole of the evidence. + # This is not a question about tiles. But it is the other cause that makes a map look + # wrong, and the answer costs nothing in the same round trip. No COLORTERM means 256 + # colours, and the greens and blues of the basemap collide when they are quantized. + # Windows is an exception. The Windows output of prompt_toolkit reports 24-bit colour + # directly, so an unset COLORTERM there shows nothing. On all other platforms, it is + # all of the evidence. unset = "(unset)" if os.name == "nt" else "(unset) <- only 256 colours" line("COLORTERM", os.environ.get("COLORTERM") or unset) @@ -179,7 +191,7 @@ def describe_env() -> tuple[str | None, str | None]: def describe_trust() -> None: - """Print the certificate store this Python would verify a tile server against.""" + """Print the certificate store that this Python uses to verify a tile server.""" heading("Certificate store") line("OpenSSL", ssl.OPENSSL_VERSION) paths = ssl.get_default_verify_paths() @@ -205,20 +217,20 @@ def describe_trust() -> None: line("Certs loaded", loaded if loaded else "0 <- nothing to verify against") -#: ``curl`` exit codes that mean TLS, as opposed to never getting that far. The whole -#: value of asking curl is that it verifies against the *operating system's* trust store, -#: so "curl got a certificate it disliked" and "curl could not reach the host" are -#: opposite answers and must not be collapsed into "curl failed". +#: The ``curl`` exit codes that mean a TLS failure, not a failure before TLS. The reason to +#: ask curl is that it verifies against the trust store of the operating system. Thus +#: "curl got a certificate that it rejected" and "curl could not reach the host" are +#: opposite answers. Do not combine them into "curl failed". CURL_TLS_CODES = frozenset({35, 51, 58, 59, 60, 66, 77, 83, 90, 91}) class Curl(NamedTuple): - """What the system ``curl`` made of the same URL. + """The result that the system ``curl`` gave for the same URL. Attributes: - verdict: ``ok`` (the server answered, whatever its status), ``tls`` (a certificate - or handshake failure), ``unreachable`` (never got that far), or ``absent`` - (no ``curl`` on this machine to ask). + verdict: ``ok`` (the server answered, with any status), ``tls`` (a certificate or + handshake failure), ``unreachable`` (curl did not reach the server), or + ``absent`` (this machine has no ``curl`` to ask). detail: The status or error, for the transcript. """ @@ -227,7 +239,7 @@ class Curl(NamedTuple): def curl_says(url: str) -> Curl: - """Fetch ``url`` with the system ``curl``, which verifies against the OS trust store.""" + """Download ``url`` with the system ``curl``, which uses the trust store of the OS.""" curl = shutil.which("curl") if curl is None: return Curl("absent", "no curl on this machine to compare against") @@ -247,26 +259,26 @@ def curl_says(url: str) -> Curl: except (subprocess.TimeoutExpired, OSError): return Curl("unreachable", "timed out") if proc.returncode == 0: - # Any HTTP status at all means the transport worked, which is the only thing this - # comparison is asking about - a 301 or a 404 still proves TLS and the route. + # Each HTTP status shows that the transport worked. This is the only thing that this + # comparison asks. A 301 or a 404 also proves TLS and the route. return Curl("ok", f"HTTP {proc.stdout.strip()}") kind = "tls" if proc.returncode in CURL_TLS_CODES else "unreachable" return Curl(kind, f"exit {proc.returncode}: {proc.stderr.strip() or 'no detail'}") def fetch(url: str) -> tuple[bool, object]: - """GET ``url`` exactly the way the app does. Returns ``(ok, body-or-exception)``.""" + """GET ``url`` in the same way as the app. Returns ``(ok, body-or-exception)``.""" req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: return True, resp.read() - except Exception as exc: # noqa: BLE001 - classifying the failure is the whole job + except Exception as exc: # noqa: BLE001 - this function must classify the failure return False, exc -#: What to do about a certificate store this Python cannot verify with. The macOS case is -#: first because it is the one that reaches a user who did nothing wrong: a python.org -#: install ships with an empty store until its own installer command is run. +#: What to do when this Python cannot verify with its certificate store. The macOS case is +#: first, because it affects a user who did nothing wrong. A python.org install has an +#: empty store until the user runs its own installer command. CERT_FIX = ( "Give this Python a certificate store. On macOS a python.org install ships\n" " without one until you run its own command:\n" @@ -276,7 +288,7 @@ CERT_FIX = ( ' export SSL_CERT_FILE="$(python3 -m certifi)" # then relaunch MeshTerm' ) -#: What to do when TLS is being terminated in the middle rather than at the tile server. +#: What to do when TLS ends in the middle of the path and not at the tile server. INTERCEPT_FIX = ( "Trust the interceptor's root certificate in this Python (SSL_CERT_FILE can\n" " point at a bundle that includes it), or exempt the tile host from HTTPS\n" @@ -285,11 +297,10 @@ INTERCEPT_FIX = ( def classify(exc: BaseException) -> tuple[str, str, str]: - """Turn a fetch exception into ``(kind, what happened, what to do about it)``. + """Change an exception from a download to ``(kind, what happened, what to do about it)``. - The *kind* is what the verdict reasons with: a certificate failure and a timeout point - at opposite halves of the world, and the ``curl`` comparison means something different - for each. + The verdict uses the kind in its reasoning. A certificate failure and a timeout point to + opposite causes, and the ``curl`` comparison has a different meaning for each. """ reason = getattr(exc, "reason", exc) if isinstance(exc, urllib.error.HTTPError): @@ -328,12 +339,12 @@ def classify(exc: BaseException) -> tuple[str, str, str]: def verdict(kind: str, cause: str, fix: str, curl: Curl) -> None: - """Print one coherent story from what Python saw and what ``curl`` saw. + """Print one coherent story from the result of Python and the result of ``curl``. - The two readings have to be reconciled rather than printed side by side: a certificate - Python rejects *and curl accepts* is MeshTerm's own trust store, while one they both - reject is the network re-signing traffic or a genuinely bad server - the same Python - exception, opposite causes, opposite fixes. + The function must reconcile the two results. It must not print them side by side. If + Python rejects a certificate and curl accepts it, the fault is the trust store of + MeshTerm. If both reject it, the network signs the traffic again, or the server is bad. + The Python exception is the same, but the causes and the fixes are opposite. """ print(f" The map cannot fetch tiles because {cause}.") if kind in ("cert", "tls"): @@ -364,7 +375,10 @@ def verdict(kind: str, cause: str, fix: str, curl: Curl) -> None: def count_cached(config_dir: str | None) -> None: - """Print how many tiles are already on disk, which the map can draw with no network.""" + """Print the number of tiles that are already on the disk. + + The map can draw these tiles with no network. + """ if not config_dir: return tiles = Path(config_dir) / "tilecache" / "tiles" @@ -375,7 +389,7 @@ def count_cached(config_dir: str | None) -> None: def main() -> int: - """Run every check and print one verdict. Returns a process exit status.""" + """Run each check and print one verdict. Returns an exit status for the process.""" print("MeshTerm basemap doctor") reexec_under_meshterm() url, config_dir = describe_env() diff --git a/scripts/cardputer-zero/launcher-entry.sh b/scripts/cardputer-zero/launcher-entry.sh index ddd0dee..74cd581 100755 --- a/scripts/cardputer-zero/launcher-entry.sh +++ b/scripts/cardputer-zero/launcher-entry.sh @@ -1,20 +1,20 @@ #!/bin/sh # SPDX-License-Identifier: Apache-2.0 -# launcher-entry -- put MeshTerm on the Cardputer Zero's app launcher, from a checkout. +# launcher-entry -- put MeshTerm on the app launcher of the Cardputer Zero, from a checkout. # # Usage: scripts/cardputer-zero/launcher-entry.sh [install|remove] # -# The stock launcher (APPLaunch) lists what it finds in -# /usr/share/APPLaunch/applications. An entry with Terminal=false gets the panel and the -# keyboard to itself, the launcher naming them in APPLAUNCH_LINUX_FBDEV_DEVICE and -# APPLAUNCH_LINUX_KEYBOARD_DEVICE -- which is how `python -m meshterm.emulator` knows to -# draw on the panel rather than open a window. Holding Esc for three seconds still ends it: -# the launcher sends SIGTERM, and MeshTerm takes that as a quit. +# The stock launcher (APPLaunch) lists the entries that it finds in +# /usr/share/APPLaunch/applications. An entry with Terminal=false gets the display and the +# keyboard for itself. The launcher names them in APPLAUNCH_LINUX_FBDEV_DEVICE and +# APPLAUNCH_LINUX_KEYBOARD_DEVICE. This is how `python -m meshterm.emulator` knows that it +# must draw on the display and not open a window. If the user holds Esc for three seconds, +# the app still ends. The launcher sends SIGTERM, and MeshTerm treats it as a quit. # -# This is the developer's entry, run from a source checkout with its venv at .venv (see -# docs/devices/cardputer-zero.md). It writes into /usr/share, so it asks sudo for the two -# files and nothing else, then restarts the launcher so the icon appears. A store package -# will replace it; `remove` takes it out again. +# This is the entry for developers. Run it from a source checkout that has its venv at +# .venv (refer to docs/devices/cardputer-zero.md). It writes into /usr/share, so it asks +# sudo for the two files and for nothing else. Then it restarts the launcher, so that the +# icon appears. A package from a store will replace it, and `remove` takes it out again. set -eu HERE=$(cd "$(dirname "$0")" && pwd) diff --git a/scripts/cardputer-zero/type-text.py b/scripts/cardputer-zero/type-text.py index c9dfa01..869e341 100755 --- a/scripts/cardputer-zero/type-text.py +++ b/scripts/cardputer-zero/type-text.py @@ -1,25 +1,27 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 -"""type-text: type on a Cardputer Zero's keyboard from another computer. +"""Type text on the keyboard of a Cardputer Zero from another computer. -A long string — an API key, a Wi-Fi password — is miserable to enter on a 46-key keyboard -with sticky modifiers. This sends text over SSH and presses the keys for you: it writes key -events into the Cardputer's own keyboard device, so whatever has the screen (the launcher, -MeshTerm, any of M5's apps) gets them exactly as if they had been typed. +A long string, such as an API key or a Wi-Fi password, is difficult to enter on a keyboard +with 46 keys and sticky modifiers. This script sends the text over SSH and presses the keys +for you. It writes key events into the keyboard device of the Cardputer. Thus the program +that has the screen (the launcher, MeshTerm, or any app of M5) gets the keys in the same +way as if a person typed them. python scripts/cardputer-zero/type-text.py --host pi@192.168.0.165 -i ~/.ssh/id_ed25519 -It asks for the text with your typing hidden: paste it, then press Enter. A secret never -shows on screen, never enters your shell history, and never sits on a command line where -the process list would show it — it travels over SSH on standard input. Piped input works -too (``... | type-text.py``). ``--enter`` presses Enter on the Cardputer afterwards. +The script asks for the text and hides what you type. Paste the text, then press Enter. A +secret never shows on the screen. It never enters your shell history. It never appears on a +command line, where the process list can show it, because it goes over SSH on standard +input. Piped input also works (``... | type-text.py``). ``--enter`` presses Enter on the +Cardputer after the text. -What it can type is what the keyboard has keys for: letters, digits, space, and the -symbols on the Sym layer, which go out as the codes M5's keymap gives them. Anything else -is refused before a single key is pressed. +The script can type only the characters that the keyboard has keys for: letters, digits, +space, and the symbols on the Sym layer. The symbols go out as the codes that the keymap of +M5 gives them. The script refuses any other character before it presses a key. -Needs: ``ssh`` on this computer, and a Cardputer user in the ``input`` group (the stock -``pi`` is). +Necessary: ``ssh`` on this computer, and a Cardputer user in the ``input`` group (the +default user ``pi`` is in this group). """ from __future__ import annotations @@ -32,10 +34,11 @@ import os import subprocess import sys -#: The keyboard's event device, where the launcher says it is. +#: The event device of the keyboard, in the place that the launcher gives. KEYBOARD = "/dev/input/by-path/platform-3f804000.i2c-event" -#: Key codes (linux/input-event-codes.h) for what the keyboard can type unshifted. +#: The key codes (linux/input-event-codes.h) of the characters that the keyboard types +#: without Shift. PLAIN = { **{ch: 16 + i for i, ch in enumerate("qwertyuiop")}, **{ch: 30 + i for i, ch in enumerate("asdfghjkl")}, @@ -44,8 +47,8 @@ PLAIN = { " ": 57, } -#: The Sym layer: the placeholder codes its keys send, with the symbol M5's keymap -#: (``/usr/share/keymaps/tca8418_keypad_m5stack_keymap.map``) turns each into. +#: The Sym layer: the placeholder codes that its keys send. The keymap of M5 +#: (``/usr/share/keymaps/tca8418_keypad_m5stack_keymap.map``) changes each code to a symbol. SYM = { "!": 26, "@": 27, "#": 39, "$": 40, "%": 41, "^": 43, "&": 51, "*": 52, "(": 53, ")": 94, "~": 55, "`": 69, "_": 70, "-": 71, "+": 72, "=": 73, @@ -53,7 +56,8 @@ SYM = { "<": 83, ">": 85, "\\": 86, "|": 89, ",": 90, ".": 91, "/": 92, "?": 93, } # fmt: skip -#: Runs on the Cardputer, under its own Python: presses each key, Shift for capitals. +#: This program runs on the Cardputer, under its own Python. It presses each key, and Shift +#: for capitals. DEVICE = """ import base64, json, struct, sys, time EV = struct.Struct("llHHi") @@ -80,7 +84,10 @@ print(f"typed {len(text)} characters on the Cardputer", file=sys.stderr) def keys_for(text: str) -> dict[str, int]: - """The key for each character ``text`` uses, or exit naming the ones it can't type.""" + """The key for each character that ``text`` uses. + + If a character has no key, the function exits and names the characters. + """ table = {**PLAIN, **SYM} missing = sorted({ch for ch in text if (ch.lower() if ch.isalpha() else ch) not in table}) if missing: @@ -89,7 +96,10 @@ def keys_for(text: str) -> dict[str, int]: def read_text(show: bool) -> str: - """The text to type: piped in, or asked for (hidden unless ``show``).""" + """The text to type: from a pipe, or from a prompt. + + The prompt hides the text unless ``show`` is true. + """ if not sys.stdin.isatty(): text = sys.stdin.read() elif show: @@ -117,8 +127,8 @@ def main() -> int: print("type-text: nothing to type", file=sys.stderr) return 1 table = keys_for(text) - # Both go as base64: the remote shell sees letters and digits only, so no quote in the - # program or the symbol table (an apostrophe is one of its keys) can break its quoting. + # Both go as base64. The remote shell sees only letters and digits. Thus no quote in the + # program or in the symbol table (an apostrophe is one of its keys) can break its quoting. program = base64.b64encode(DEVICE.encode()).decode() keys = base64.b64encode(json.dumps(table).encode()).decode() remote = [ diff --git a/scripts/picocalc-lyra/calculinux-console-font-6x12.sh b/scripts/picocalc-lyra/calculinux-console-font-6x12.sh index 815e976..d8b3b6b 100755 --- a/scripts/picocalc-lyra/calculinux-console-font-6x12.sh +++ b/scripts/picocalc-lyra/calculinux-console-font-6x12.sh @@ -3,51 +3,56 @@ # calculinux-console-font-6x12.sh -- build, install, and persist the "meshterm" console fonts # and the 16-slot palette. # -# MeshTerm runs on a Luckfox Lyra inside a ClockworkPi PicoCalc under Calculinux, drawing -# to the bare ILI9488 framebuffer console (fbcon). That console loads a single PSF font, -# and every font Calculinux ships is a classic 256-glyph VGA font -- so out of the box the -# panel cannot draw what MeshTerm's UI leans on hardest: braille charts (U+2800-U+28FF), -# the node/status marks, the P3 compact-icon marks, rounded panel corners, or the list -# cursor. This script synthesizes the 512-glyph PSF2 font MeshTerm runs in: +# MeshTerm runs on a Luckfox Lyra inside a ClockworkPi PicoCalc under Calculinux. It draws +# to the bare ILI9488 framebuffer console (fbcon). That console loads one PSF font, and +# each font that Calculinux ships is a classic 256-glyph VGA font. Thus, as it comes, the +# display cannot draw the things that the UI of MeshTerm uses most: braille charts +# (U+2800-U+28FF), the node and status marks, the marks of the P3 compact icons, rounded +# panel corners, and the pointer of the list. This script makes the 512-glyph PSF2 font in +# which MeshTerm runs: # # * meshterm.psf.gz 6x12 (Terminus base) -> 53x26 -- the installed default # -# meshterm.psf.gz is a derivative of Terminus Font (OFL-1.1) and must not itself be called -# "Terminus" -- the stock ter-u12n.psf.gz base keeps its own OFL-1.1 licence, distinct from -# this repository's Apache-2.0. +# meshterm.psf.gz is a derivative of Terminus Font (OFL-1.1). It must not have the name +# "Terminus". The stock ter-u12n.psf.gz base keeps its own OFL-1.1 licence, which is not +# the Apache-2.0 licence of this repository. # -# The taller-screen companion, meshterm8.psf.gz (6x8 -> 53x40), is built by -# calculinux-console-font-6x8.sh, which this script runs for you when it sits alongside -# (set SKIP_6X8=1 to build only the default). It lives in its own file because its base -# bitmap is the Linux kernel's font_6x8 and therefore GPL-2.0: keeping that material in one -# clearly marked file is the point of the split. The two scripts share ONE copy of the -# donor / alias / keeper tables and the whole generator -- the block between the -# "shared generator" markers below, which the 6x8 script extracts from this file and execs. -# Edit those tables here and both fonts move together. The shared generator block is -# additionally offered under GPL-2.0-only, to permit its use by calculinux-console-font-6x8.sh. +# The companion for the taller display, meshterm8.psf.gz (6x8 -> 53x40), is built by +# calculinux-console-font-6x8.sh. This script runs it for you when it is in the same +# directory (set SKIP_6X8=1 to build only the default). It is in its own file because its +# base bitmap is the font_6x8 of the Linux kernel, and that is GPL-2.0. The purpose of the +# split is to keep that material in one file with a clear mark. The two scripts share ONE +# copy of the donor, alias, and keeper tables, and the whole generator. This copy is the +# block between the "shared generator" markers below. The 6x8 script extracts it from this +# file and executes it with exec. If you change those tables here, both fonts change +# together. The shared generator block is also offered under GPL-2.0-only, to permit its +# use by calculinux-console-font-6x8.sh. # -# Both fonts install into /usr/share/consolefonts. Flip live with -# `setfont /usr/share/consolefonts/meshterm8.psf.gz` (and back with meshterm.psf.gz), or -# from MeshTerm's own Preferences page (Display -> Console font); persist a permanent -# choice via FONT= in /etc/vconsole.conf. +# Both fonts install into /usr/share/consolefonts. To change the font live, use +# `setfont /usr/share/consolefonts/meshterm8.psf.gz` (and use meshterm.psf.gz to change +# it back). You can also use the Preferences page of MeshTerm (Display -> Console font). +# To make a permanent choice, set FONT= in /etc/vconsole.conf. # -# Why 512 and not more: fbcon caps a font at 512 glyphs. Braille alone is 256 and the base -# set is another 256 -- the budget is already full, so every extra mark is drawn into a -# DONOR slot (a pictograph MeshTerm never emits) whose codepoint is repointed. Choosing a -# donor means knowing its slot's FULL codepoint list: bases alias lookalikes onto one glyph -# (donating pi once erased Cyrillic pe), so a donor miss aborts the build and a keeper list -# is verified after it. The rounded corners and the midline ellipsis cost no glyph: they -# are aliased onto existing bitmaps. +# Why 512 and not more: fbcon limits a font to 512 glyphs. Braille alone is 256, and the +# base set is another 256. The budget is full, so MeshTerm draws each extra mark into a +# DONOR slot (a pictograph that MeshTerm never sends) and points its codepoint to the new +# mark. To choose a donor, you must know the FULL codepoint list of its slot. Bases alias +# lookalikes onto one glyph (when we used pi as a donor, it erased Cyrillic pe). Thus a +# donor that is not found stops the build, and the script checks a keeper list after the +# build. The rounded corners and the midline ellipsis cost no glyph, because they are +# aliased onto existing bitmaps. # -# Everything else is pure geometry, generated on-device; only the stock Terminus font and -# python3 are required. Run as root on the Lyra (serial console is fine): +# All other glyphs are pure geometry, generated on the handheld. The script needs only the +# stock Terminus font and python3. Run it as root on the Lyra (the serial console is +# acceptable): # # sh calculinux-console-font-6x12.sh # -# Idempotent -- safe to re-run after a MeshTerm update or a font tweak. Also manages the -# 16-slot palette: a default run restores the stock VT palette (theme._VT_SLOTS) and -# removes any custom remap; MESHTERM_CUSTOM_PALETTE=1 installs the archived remap -# (theme._VT_SLOTS_CUSTOM, pinned to theme.vtrgb_lines() by a test). +# The script is idempotent, so it is safe to run again after an update of MeshTerm or a +# change to a font. It also manages the 16-slot palette. A default run restores the stock +# VT palette (theme._VT_SLOTS) and removes each custom remap. MESHTERM_CUSTOM_PALETTE=1 +# installs the archived remap (theme._VT_SLOTS_CUSTOM, which a test pins to +# theme.vtrgb_lines()). set -eu FONT_NAME=meshterm @@ -59,17 +64,18 @@ VCONSOLE=/etc/vconsole.conf HERE=$(dirname "$0") BUILD8="$HERE/calculinux-console-font-6x8.sh" # GPL-2.0; see its header -# --- preflight: fail early with a plain reason, never half-apply ----------------------- +# --- preflight: stop early with a plain reason, and never apply half of the change ------ [ "$(id -u)" = 0 ] || { echo "error: run as root (writes $CONSOLEFONTS and $VCONSOLE)" >&2; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "error: python3 not found" >&2; exit 1; } command -v setfont >/dev/null 2>&1 || { echo "error: setfont not found (install kbd tools)" >&2; exit 1; } [ -f "$BASE" ] || { echo "error: base font not found: $BASE" >&2; exit 1; } # --- build the 6x12 font ----------------------------------------------------------------- -# The generator is inlined (quoted heredoc, so the shell expands nothing) and reads its -# paths from the environment. It is the single source of truth for the synthesized glyphs: -# the block between the "shared generator" markers is extracted and exec'd verbatim by -# calculinux-console-font-6x8.sh, so the donor, alias and keeper tables exist ONCE. +# The generator is inline (a quoted heredoc, so the shell expands nothing) and reads its +# paths from the environment. It is the only source of truth for the glyphs that the +# script makes. calculinux-console-font-6x8.sh extracts the block between the "shared +# generator" markers and executes it with exec, without a change. Thus the donor, alias, +# and keeper tables exist ONCE. echo "building $OUT (6x12) ..." BASE="$BASE" OUT="$OUT" python3 - <<'PYEOF' import os @@ -268,12 +274,13 @@ build(glyphs12, entries12, 12, 12, MARKS, BANDS12, [0x043F, 0x03C0], OUT) PYEOF -# --- the 6x8 companion, out of its own (GPL-2.0) file ----------------------------------- -# Kept separate because its base bitmap is the kernel's font_6x8 and the produced PSF is -# therefore GPL-2.0 -- see scripts/picocalc-lyra/calculinux-console-font-6x8.sh and scripts/picocalc-lyra/LICENSE.GPL-2.0. -# It re-uses the shared generator block above rather than carrying a second copy of the -# donor/alias/keeper tables, so a run here still produces both fonts, as it always did. -# A failure there is not a failure here: the 6x12 default is the font the device boots in. +# --- the 6x8 companion, from its own (GPL-2.0) file ------------------------------------- +# It is a separate file because its base bitmap is the font_6x8 of the kernel, and thus +# the PSF that it produces is GPL-2.0. Refer to scripts/picocalc-lyra/calculinux-console-font-6x8.sh and scripts/picocalc-lyra/LICENSE.GPL-2.0. +# It uses the shared generator block above and does not have a second copy of the +# donor, alias, and keeper tables. Thus a run here still produces both fonts, as it +# always did. A failure there is not a failure here, because the handheld boots in the +# 6x12 default font. if [ "${SKIP_6X8:-0}" = 1 ]; then echo "skipping $OUT8 (SKIP_6X8=1)" elif [ -f "$BUILD8" ]; then @@ -282,13 +289,13 @@ else echo "note: $BUILD8 not found -- only $OUT was built (copy the whole scripts/picocalc-lyra/ dir)" >&2 fi -# --- apply live: setfont re-renders the whole console immediately ----------------------- +# --- apply live: setfont renders the whole console again immediately --------------------- TTY=/dev/tty1 [ -c "$TTY" ] || TTY=/dev/tty0 echo "applying $OUT (6x12, the default) to $TTY ..." setfont -C "$TTY" "$OUT" -# --- persist: systemd-vconsole-setup reads FONT= from vconsole.conf at every boot ------- +# --- persist: systemd-vconsole-setup reads FONT= from vconsole.conf at each boot -------- if [ -f "$VCONSOLE" ] && grep -q '^FONT=' "$VCONSOLE"; then sed -i "s/^FONT=.*/FONT=$FONT_NAME/" "$VCONSOLE" else @@ -301,16 +308,16 @@ if [ -f "$OUT8" ]; then fi # --- palette ----------------------------------------------------------------------------- -# By design, the console keeps its STANDARD kernel palette -- black -# background, stock hues -- and meshterm/ui/theme.MESH_THEME_16 is designed against those -# (theme._VT_SLOTS). The custom tailwind-family remap originally shipped is ARCHIVED, -# one environment variable away for anyone who wants it back: +# By design, the console keeps its STANDARD kernel palette (black background, stock hues). +# meshterm/ui/theme.MESH_THEME_16 is designed for that palette (theme._VT_SLOTS). The +# custom tailwind-family remap that we shipped first is ARCHIVED. A user who wants it back +# needs only one environment variable: # # MESHTERM_CUSTOM_PALETTE=1 sh calculinux-console-font-6x12.sh # -# The opt-in block's values match theme.vtrgb_lines() / theme._VT_SLOTS_CUSTOM exactly (a -# test keeps them in sync). A DEFAULT run removes any previously-installed remap and -# resets the live palette to stock. +# The values of the opt-in block are exactly the same as theme.vtrgb_lines() and +# theme._VT_SLOTS_CUSTOM (a test makes sure that they stay the same). A DEFAULT run removes +# each remap that was installed before, and sets the live palette to stock. VTRGB=/etc/vtrgb APPLIER=/usr/local/sbin/meshterm-vtrgb UNIT=/etc/systemd/system/meshterm-vtrgb.service @@ -357,9 +364,9 @@ else systemctl disable meshterm-vtrgb.service >/dev/null 2>&1 || true rm -f "$UNIT" "$VTRGB" "$APPLIER" systemctl daemon-reload 2>/dev/null || true - # Restore the STANDARD palette explicitly: a previous setvtrgb overwrote the - # kernel's *default* colormap, so the OSC reset (ESC ] R) alone would "reset" - # straight back to the custom values. Program the stock PC palette, then reset. + # Restore the STANDARD palette explicitly. A previous setvtrgb overwrote the default + # colormap of the kernel. Thus the OSC reset (ESC ] R) alone "resets" the palette to + # the custom values. Program the stock PC palette, then reset. TTY=/dev/tty1 [ -c "$TTY" ] || TTY=/dev/tty0 if command -v setvtrgb >/dev/null 2>&1; then diff --git a/scripts/picocalc-lyra/calculinux-console-font-6x8.sh b/scripts/picocalc-lyra/calculinux-console-font-6x8.sh index e2ee47b..9ad43a4 100755 --- a/scripts/picocalc-lyra/calculinux-console-font-6x8.sh +++ b/scripts/picocalc-lyra/calculinux-console-font-6x8.sh @@ -1,42 +1,44 @@ #!/bin/sh # SPDX-License-Identifier: GPL-2.0-only # calculinux-console-font-6x8.sh -- build and install meshterm8.psf.gz, the 6x8 (53x40) -# console font for the PicoCalc panel. +# console font for the PicoCalc display. # -# LICENCE -- READ THIS FIRST. The base bitmap embedded below is the Linux kernel's own -# font_6x8 (lib/fonts/font_6x8.c), which is GPL-2.0. This script is therefore GPL-2.0-only -# and so is the PSF it produces; the full licence text sits beside it as -# scripts/picocalc-lyra/LICENSE.GPL-2.0. The rest of MeshTerm is Apache-2.0 and stays that way: the -# produced meshterm8.psf.gz is a DATA FILE the Linux kernel console reads with setfont, on -# the device, at run time. MeshTerm never links it, never bundles it, and never -# redistributes it -- it is built on the machine it runs on, from this script, and the -# Python package excludes scripts/ from its sdist entirely. Nothing GPL-2.0 enters the -# distributed program; MeshTerm merely names a file on the console's filesystem. +# LICENCE -- READ THIS FIRST. The base bitmap that is embedded below is the own font_6x8 +# of the Linux kernel (lib/fonts/font_6x8.c), which is GPL-2.0. Thus this script is +# GPL-2.0-only, and the PSF that it produces is GPL-2.0-only too. The full licence text is +# beside this script, as scripts/picocalc-lyra/LICENSE.GPL-2.0. The rest of MeshTerm is +# Apache-2.0, and it stays Apache-2.0. The file meshterm8.psf.gz that this script produces +# is a DATA FILE. The console of the Linux kernel reads it with setfont, on the handheld, +# at run time. MeshTerm never links it, never bundles it, and never redistributes it. The +# handheld builds it on the machine where it runs, from this script. The Python package +# does not have scripts/ in its sdist at all. No GPL-2.0 material enters the distributed +# program. MeshTerm only names a file on the filesystem of the console. # -# WHERE THE SHARED PARTS LIVE. The donor, alias and keeper tables, the braille generator -# and the PSF2 writer are NOT duplicated here. They are read out of -# calculinux-console-font-6x12.sh (Apache-2.0), between its "shared generator (BEGIN)" and -# "(END)" marker lines, and exec'd -- so the two fonts can never drift, and a repo test -# that parses those tables has exactly one file to parse. That file must sit beside this -# one; point SHARED at it if it does not. The shared generator is additionally offered -# under GPL-2.0-only by its source file, permitting its extraction and use here. +# WHERE THE SHARED PARTS ARE. This script does NOT have its own copy of the donor, alias, +# and keeper tables, the braille generator, and the PSF2 writer. It reads them out of +# calculinux-console-font-6x12.sh (Apache-2.0), between the marker lines "shared generator +# (BEGIN)" and "(END)" of that file, and executes them with exec. Thus the two fonts +# cannot differ, and a test in the repository that parses those tables has only one file +# to parse. That file must be beside this script. If it is not, set SHARED to the path of +# that file. The source file also offers the shared generator under GPL-2.0-only. This +# permits the extraction and the use of the generator here. # # WHAT IS ONLY HERE. The GPL-2.0 base bitmap, its CP437 unicode mapping, and the same 20 -# marks redrawn for the shorter cell (MARKS8). +# marks that are redrawn for the shorter cell (MARKS8). # -# The 6x8 base has CP437 coverage, so unlike the Terminus base it has NO CYRILLIC: a node -# named in Cyrillic draws as tofu in this font. The keeper list drops the Cyrillic canary -# accordingly, and only pi is checked. +# The 6x8 base has CP437 coverage. Thus, unlike the Terminus base, it has NO CYRILLIC. A +# node with a name in Cyrillic draws as tofu in this font. The keeper list does not have +# the Cyrillic canary, and the script checks only pi. # -# Run as root on the Lyra, either on its own or through calculinux-console-font-6x12.sh, which -# calls it: +# Run this script as root on the Lyra, alone or through calculinux-console-font-6x12.sh, +# which calls it: # # sh calculinux-console-font-6x8.sh # -# Idempotent. It installs the font and nothing else -- it never runs setfont, never touches -# /etc/vconsole.conf and never touches the palette. Choose the font live from MeshTerm's -# Preferences page (Display -> Console font), or by hand with -# `setfont /usr/share/consolefonts/meshterm8.psf.gz`. +# The script is idempotent. It installs the font and nothing else. It never runs setfont, +# never changes /etc/vconsole.conf, and never changes the palette. To choose the font +# live, use the Preferences page of MeshTerm (Display -> Console font), or use this +# command by hand: `setfont /usr/share/consolefonts/meshterm8.psf.gz`. set -eu FONT_NAME=meshterm @@ -45,7 +47,7 @@ OUT8="$CONSOLEFONTS/${FONT_NAME}8.psf.gz" HERE=$(dirname "$0") SHARED=${SHARED:-"$HERE/calculinux-console-font-6x12.sh"} # Apache-2.0; holds the shared tables -# --- preflight: fail early with a plain reason, never half-apply ----------------------- +# --- preflight: stop early with a plain reason, and never apply half of the change ------ [ "$(id -u)" = 0 ] || { echo "error: run as root (writes $CONSOLEFONTS)" >&2; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "error: python3 not found" >&2; exit 1; } [ -f "$SHARED" ] || { echo "error: shared generator not found: $SHARED" >&2; exit 1; } diff --git a/scripts/picocalc-lyra/calculinux-radio-bridge.sh b/scripts/picocalc-lyra/calculinux-radio-bridge.sh index 4134277..ef5fc04 100755 --- a/scripts/picocalc-lyra/calculinux-radio-bridge.sh +++ b/scripts/picocalc-lyra/calculinux-radio-bridge.sh @@ -2,43 +2,49 @@ # calculinux-radio-bridge.sh -- put the UART-attached MeshCore radio on TCP for MeshTerm # (Luckfox Lyra / PicoCalc running Calculinux). # -# The hardware this serves: a Seeed XIAO nRF52840 + Wio-SX1262 running a custom MeshCore -# companion firmware whose frame protocol is bound to the XIAO's hardware UART1 (D6=TX, -# D7=RX) at 115200 8N1, soldered to the Lyra's UART1. UART0 carries the Linux console, so -# the radio lives on /dev/ttyS1 -- which this script never touches beyond opening it. +# The hardware that this script serves: a Seeed XIAO nRF52840 with a Wio-SX1262. It runs a +# custom MeshCore companion firmware. The frame protocol of the firmware is on the hardware +# UART1 of the XIAO (D6=TX, D7=RX) at 115200 8N1, and the XIAO is soldered to the UART1 of +# the Lyra. UART0 carries the Linux console, so the radio is on /dev/ttyS1. This script +# only opens that port and does nothing else with it. # -# Why a bridge instead of pointing MeshTerm straight at the port: pyserial's Linux -# enumeration hides platform-bus UARTs ("hide non-present internal serial ports"), so a -# soldered SoC port like /dev/ttyS1 never appears in discovery or the liveness poll. The -# TCP transport has none of those problems -- MeshTerm already lists a TCP profile on the -# startup splash and tracks the socket for liveness -- and the same bridge makes the radio -# reachable from the LAN (`meshterm --tcp :5000` on another machine). +# The reason for a bridge, and not a direct connection from MeshTerm to the port: the Linux +# enumeration of pyserial hides platform-bus UARTs ("hide non-present internal serial +# ports"). Thus a soldered SoC port such as /dev/ttyS1 never appears in discovery or in the +# liveness poll. The TCP transport does not have these problems. MeshTerm already lists a +# TCP profile on the startup splash, and it tracks the socket for liveness. The same bridge +# also makes the radio available from the LAN (`meshterm --tcp :5000` on another +# machine). # # Run as root on the device: # # sh calculinux-radio-bridge.sh # -# It is idempotent -- safe to re-run; each phase checks before it acts. +# The script is idempotent. It is safe to run it again, because each phase checks before it +# acts. # -# 1. UART sanity /dev/ttyS1 exists, the console isn't on it, no getty owns it. -# 2. bridge A stdlib-only python UART<->TCP pump (no pyserial, no venv) installed -# as /etc/meshterm-radio-bridge.py + a systemd service, enabled at boot. -# It serves ONE client at a time -- two clients would interleave -# companion frames -- which is why this isn't a `socat ... fork` line. -# 3. profile A `[profiles.radio]` TCP profile (127.0.0.1:5000) in the deploy -# user's ~/.meshterm/config.toml, made the default profile, so a bare -# `meshterm` lists the radio on the splash and `-p radio` connects. -# 4. report Service state and how to connect, locally and over the LAN. +# 1. UART sanity /dev/ttyS1 exists, the console is not on it, and no getty owns it. +# 2. bridge A python UART<->TCP pump that uses only the standard library (no +# pyserial, no venv). The script installs it as +# /etc/meshterm-radio-bridge.py with a systemd service, enabled at boot. +# It serves ONE client at a time, because two clients would interleave +# companion frames. This is the reason that it is not a `socat ... fork` +# line. +# 3. profile A `[profiles.radio]` TCP profile (127.0.0.1:5000) in the +# ~/.meshterm/config.toml of the deploy user. The script makes it the +# default profile. Thus a bare `meshterm` lists the radio on the splash, +# and `-p radio` connects. +# 4. report The service state, and how to connect, locally and over the LAN. # -# What this script does NOT do: it never routes UART1 to physical pads. On stock -# Calculinux the DT enables the UART1 controller (hence /dev/ttyS1 existing) without -# applying its pinctrl, so TX/RX reach no pad and this pump would faithfully serve a port -# that is electrically dead. Pin routing now lives in xiao-radio/uart1-mux.py, which uses -# a different pad pair (header GP4/GP5) than this script's header once assumed -- run that -# (via xiao-radio/lyra-setup.sh) first if you need the radio actually wired up. +# What this script does NOT do: it never routes UART1 to physical pads. On stock Calculinux, +# the DT enables the UART1 controller (thus /dev/ttyS1 exists) but does not apply its +# pinctrl. Thus TX and RX reach no pad, and this pump serves a port that is electrically +# dead. The pin routing is now in xiao-radio/uart1-mux.py. It uses a different pad pair +# (header GP4/GP5) than the pair that the header of this script once assumed. If you need +# the radio to be wired, run that script first (through xiao-radio/lyra-setup.sh). set -eu -# --- knobs (override via the environment) ---------------------------------------------- +# --- knobs (the environment can override them) ----------------------------------------- DEPLOY_USER="${DEPLOY_USER:-meshterm}" RADIO_PORT="${RADIO_PORT:-/dev/ttyS1}" # the Lyra UART the XIAO is soldered to RADIO_BAUD="${RADIO_BAUD:-115200}" # must match the firmware's Serial1.begin() @@ -68,14 +74,14 @@ log "1/4 UART sanity ($RADIO_PORT)" the UART1 overlay may be disabled -- enable it (luckfox-config / the board's dtbo mechanism), reboot, and re-run this script" -# The kernel console must stay on UART0; a console sharing the radio's UART would corrupt -# the companion frame stream in both directions. +# The kernel console must stay on UART0. A console on the UART of the radio corrupts the +# companion frame stream in both directions. if grep -q "console=$TTY_NAME" /proc/cmdline; then die "the kernel console is on $TTY_NAME -- refusing to bridge over it" fi info "console is not on $TTY_NAME" -# Same story for a login getty: it would eat radio bytes and echo garbage back. +# The same is true for a login getty. It takes radio bytes and echoes invalid data back. if ps w | grep -v grep | grep getty | grep -q "$TTY_NAME"; then die "a getty holds $TTY_NAME -- free it first: systemctl disable --now serial-getty@$TTY_NAME.service" @@ -220,8 +226,8 @@ mkdir -p "$(dirname "$CONFIG")" if grep -q "^\[profiles\.$PROFILE_NAME\]" "$CONFIG"; then info "profile already present -- leaving config.toml untouched" else - # default_profile is a top-level key, so it must sit ABOVE any [table] header -- - # prepend it rather than appending after the profile blocks. + # default_profile is a top-level key, so it must be ABOVE each [table] header. + # Put it at the start of the file. Do not add it after the profile blocks. if ! grep -q '^default_profile' "$CONFIG"; then TMP="$CONFIG.tmp" printf 'default_profile = "%s"\n\n' "$PROFILE_NAME" > "$TMP" diff --git a/scripts/picocalc-lyra/calculinux-setup.sh b/scripts/picocalc-lyra/calculinux-setup.sh index 42bfdb8..e2eb0af 100755 --- a/scripts/picocalc-lyra/calculinux-setup.sh +++ b/scripts/picocalc-lyra/calculinux-setup.sh @@ -1,51 +1,62 @@ #!/bin/sh # calculinux-setup.sh -- one-time bring-up of MeshTerm on a Luckfox Lyra / PicoCalc -# running Calculinux (Yocto, systemd, read-only root with /usr + /etc + /home overlays -# backed by /data). Run as root on a freshly-flashed SD, over the serial console is fine: +# that runs Calculinux (Yocto, systemd, a read-only root with /usr, /etc, and /home overlays +# stored on /data). Run it as root on a new SD card. The serial console is sufficient: # # sh calculinux-setup.sh # -# It is idempotent -- safe to re-run; each phase checks before it acts. It sequences the -# whole deploy this repo's Lyra target needed, in the order the discoveries fell out: +# The script is idempotent. It is safe to run it again, because each phase checks before it +# acts. It does the complete deploy that the Lyra target of this repository needs, in the +# order in which we found the problems: # -# 1. opkg packages Calculinux ships a STRIPPED python3.13 (no pip/venv/ensurepip and -# missing stdlib: sqlite3, ctypes, curses, shlex, xml). /usr is a -# writable overlay, so `opkg install python3-modules python3-pip` -# restores the full interpreter and persists on /data. -# 2. wi-fi kick The USB RTL8188EU (rtl8xxxu) firmware finishes loading AFTER iwd's -# first scans, so on cold boot wlan0 stays UP/NO-CARRIER and the known -# network is never joined. A oneshot service drives RAW `iw` scans -# until a DHCP lease -- a completed raw scan is what lets iwd associate. -# 3. time sync The Lyra has no battery-backed RTC, so every cold boot starts at the -# kernel's build-time epoch until something sets the clock -- wrong -# until then, which is fatal for "heard" ordering, TTL, etc. A oneshot -# service waits for a real route, then steps the clock once (NTP client -# if the image has one, else an HTTPS Date-header fallback). -# 4. timezone `$TIMEZONE`, Eastern (America/Toronto) unless you say otherwise, via -# timedatectl if tzdata's opkg feed is reachable, else a POSIX TZ rule -# in /etc/environment -- glibc honours it with no zoneinfo database. -# The POSIX fallback rule is only known for the default zone; a custom -# $TIMEZONE without tzdata is set via timedatectl only, with a warning. -# 5. deploy user The `$DEPLOY_USER` login MeshTerm runs under, `meshterm` unless you -# say otherwise (created if absent; no password is set here -- run -# `passwd` for it yourself). -# 6. clone Pull MeshTerm over SSH with the read-only GitHub deploy key if one -# is present at $KEY_PATH, else a public HTTPS clone of $REPO_URL. -# 7. venv + install A venv + `pip install -e .`. pip's C builds hit ENOSPC because /tmp -# is a tiny RAM tmpfs, so TMPDIR is redirected to $HOME/tmp on /data. -# 8. PATH Put the venv's `meshterm` on that user's login PATH via ~/.profile. -# 9. console font Hand off to calculinux-console-font-6x12.sh (braille + node glyphs + -# rounded frame corners + the list cursor the bare console can't draw). +# 1. opkg packages Calculinux ships a STRIPPED python3.13. It has no pip, venv, or +# ensurepip, and it does not have some of the standard library: +# sqlite3, ctypes, curses, shlex, and xml. /usr is a writable +# overlay, so `opkg install python3-modules python3-pip` restores +# the full interpreter, and it stays on /data. +# 2. wi-fi kick The firmware of the USB RTL8188EU (rtl8xxxu) finishes loading AFTER +# the first scans of iwd. Thus, at a cold boot, wlan0 stays +# UP/NO-CARRIER and the known network is never joined. A oneshot +# service runs RAW `iw` scans until a DHCP lease arrives. A completed +# raw scan is what lets iwd associate. +# 3. time sync The Lyra has no RTC with a battery. Thus each cold boot starts at the +# build-time epoch of the kernel, until something sets the clock. The +# time is wrong until then, and this is a serious problem for the +# order of "heard" times, for TTL, and for other time values. A +# oneshot service waits for a real route, then sets the clock one time +# (with an NTP client if the image has one, or else with the Date +# header of an HTTPS response). +# 4. timezone `$TIMEZONE`, Eastern (America/Toronto) unless you set another zone. +# The script uses timedatectl if the opkg feed of tzdata is available. +# If it is not, the script uses a POSIX TZ rule in /etc/environment. +# glibc accepts that rule with no zoneinfo database. The script knows +# the POSIX fallback rule only for the default zone. For a custom +# $TIMEZONE without tzdata, the script uses timedatectl only, with a +# warning. +# 5. deploy user The `$DEPLOY_USER` login that MeshTerm runs under. The default is +# `meshterm`. The script makes it if it does not exist. The script does +# not set a password. Run `passwd` for the user yourself. +# 6. clone Pull MeshTerm over SSH with the read-only GitHub deploy key if the +# key is at $KEY_PATH. If not, make a public HTTPS clone of $REPO_URL. +# 7. venv + install A venv and `pip install -e .`. The C builds of pip fail with ENOSPC, +# because /tmp is a small RAM tmpfs. Thus the script changes TMPDIR to +# $HOME/tmp on /data. +# 8. PATH Put the `meshterm` of the venv on the login PATH of that user, through +# ~/.profile. +# 9. console font Hand off to calculinux-console-font-6x12.sh (braille, node glyphs, +# rounded frame corners, and the list cursor that the bare console +# cannot draw). # -# Two prerequisites this script cannot safely embed and will check for / guide you through: -# * the read-only deploy key at $KEY_PATH, if you want the SSH clone (never commit a -# private key) -- omit it and the clone falls back to public HTTPS; -# * Wi-Fi credentials known to iwd. The kick only nudges an ALREADY-known network. Either -# provision it once by hand (`iwctl station wlan0 connect `), or export -# WIFI_SSID and WIFI_PSK before running and this script writes the iwd config for you. +# This script cannot safely include two prerequisites. It checks for them and guides you: +# * The read-only deploy key at $KEY_PATH, if you want the SSH clone (never commit a +# private key). If you omit it, the clone uses public HTTPS. +# * Wi-Fi credentials that iwd knows. The kick only nudges a network that iwd ALREADY +# knows. Provide the network one time by hand (`iwctl station wlan0 connect `). +# Or export WIFI_SSID and WIFI_PSK before you run the script, and the script writes the +# iwd config for you. set -eu -# --- knobs (override via the environment) ---------------------------------------------- +# --- knobs (the environment can override them) ----------------------------------------- DEPLOY_USER="${DEPLOY_USER:-meshterm}" TIMEZONE="${TIMEZONE:-America/Toronto}" REPO_SSH="${REPO_SSH:-git@github.com:jpmartineau/MeshTerm.git}" @@ -69,7 +80,7 @@ runas() { su - "$DEPLOY_USER" -c "$1"; } log "1/9 system packages (opkg)" if have opkg; then opkg update >/dev/null 2>&1 || info "opkg update failed (no network yet?) -- continuing" - # git and kbd ship in the base image; the python pieces are what the stripped build drops. + # git and kbd are in the base image. The stripped build removes the python parts. for pkg in python3-modules python3-pip git kbd; do if opkg status "$pkg" 2>/dev/null | grep -q '^Status:.*installed'; then info "$pkg already installed" @@ -86,8 +97,9 @@ have python3 || die "python3 still missing after opkg (check the opkg feed / net # --- 2. wi-fi boot-scan kick (rtl8xxxu race workaround) -------------------------------- log "2/9 wi-fi boot-scan kick" -# Optionally provision the iwd network so the kick has something known to join. Secrets -# come from the environment only -- nothing is written to disk from this repo. +# Optional: provide the iwd network, so that the kick has a known network to join. The +# secrets come only from the environment. The script writes nothing from this repository to +# the disk. if [ -n "$WIFI_SSID" ] && [ -n "$WIFI_PSK" ]; then info "writing iwd credentials for '$WIFI_SSID'" mkdir -p /var/lib/iwd @@ -243,10 +255,10 @@ if [ -f "/usr/share/zoneinfo/$TIMEZONE" ]; then info "already $TIMEZONE (timedatectl)" fi elif [ "$TIMEZONE" = "America/Toronto" ] || [ "$TIMEZONE" = "America/Montreal" ]; then - # opkg.calculinux.org's feed has been observed fully empty (404 at the index, not just - # this package) -- no zoneinfo database reaches the device then. glibc still honours a - # POSIX TZ rule without one, so fall back to the exact US/Canada Eastern DST rule - # (2nd Sun Mar -> 1st Sun Nov) America/Toronto and America/Montreal have shared since 2007. + # We saw that the feed of opkg.calculinux.org was fully empty (404 at the index, not only + # for this package). Then no zoneinfo database reaches the device. glibc still accepts a + # POSIX TZ rule without one. Thus use the exact US and Canada Eastern DST rule (2nd Sun + # Mar -> 1st Sun Nov). America/Toronto and America/Montreal have used it since 2007. TZ_POSIX='EST5EDT,M3.2.0,M11.1.0/2' if grep -q "^TZ=$TZ_POSIX\$" /etc/environment 2>/dev/null; then info "already set (TZ=$TZ_POSIX in /etc/environment, no tzdata)" @@ -262,8 +274,9 @@ elif [ "$TIMEZONE" = "America/Toronto" ] || [ "$TIMEZONE" = "America/Montreal" ] info "added TZ export to $DEPLOY_USER's ~/.profile (belt-and-suspenders for non-PAM logins)" fi else - # No POSIX DST rule is known here for a non-default $TIMEZONE without a tzdata feed; - # set it via timedatectl if it takes, otherwise warn and move on rather than guess. + # The script does not know a POSIX DST rule for a $TIMEZONE that is not the default, + # when there is no tzdata feed. Set the zone with timedatectl if it works. If it does + # not work, give a warning and continue. Do not guess. if timedatectl set-timezone "$TIMEZONE" 2>/dev/null; then info "set via timedatectl" else @@ -282,12 +295,12 @@ else else adduser -D -s /bin/bash "$DEPLOY_USER" || die "adduser failed" fi - # wheel -> sudo; harmless if the group is absent. + # wheel gives sudo. There is no problem if the group does not exist. (usermod -aG wheel "$DEPLOY_USER" 2>/dev/null || adduser "$DEPLOY_USER" wheel 2>/dev/null) || true info "no password set -- run: passwd $DEPLOY_USER" fi -# input -> the F-key lane's Shift watcher reads /dev/input (services/modifier_watch); -# dialout/video are needed for serial radios and the framebuffer. All idempotent. +# input: the Shift watcher of the F-key lane reads /dev/input (services/modifier_watch). +# dialout and video are necessary for serial radios and the framebuffer. All are idempotent. for grp in input dialout video; do (usermod -aG "$grp" "$DEPLOY_USER" 2>/dev/null || adduser "$DEPLOY_USER" "$grp" 2>/dev/null) || true done @@ -298,7 +311,7 @@ if [ -f "$KEY_PATH" ]; then CLONE_URL="$REPO_SSH" KNOWN_HOSTS="/home/$DEPLOY_USER/.ssh/known_hosts" SSH_CMD="ssh -i $KEY_PATH -o IdentitiesOnly=yes -o UserKnownHostsFile=$KNOWN_HOSTS" - # Pin github.com's host key up front so the clone never blocks on an interactive prompt. + # Pin the host key of github.com at the start. Thus the clone never stops at an interactive prompt. if ! runas "test -f $KNOWN_HOSTS && grep -q github.com $KNOWN_HOSTS"; then info "recording github.com host key" runas "ssh-keyscan -t ed25519 github.com >> $KNOWN_HOSTS 2>/dev/null" || info "ssh-keyscan failed (offline?)" @@ -318,12 +331,12 @@ else runas "${CLONE_ENV}git clone $CLONE_URL ~/MeshTerm" \ || die "clone failed (key not authorized, or offline)" if [ -f "$KEY_PATH" ]; then - # Bake the deploy key into the checkout so later pulls just work. + # Put the deploy key in the checkout. Thus later pulls work with no more steps. runas "git -C ~/MeshTerm config core.sshCommand '$SSH_CMD'" fi fi -# --- 6. venv + editable install (TMPDIR off the RAM tmpfs) ----------------------------- +# --- 6. venv + editable install (TMPDIR not on the RAM tmpfs) -------------------------- log "7/9 python venv + install" if runas "test -x ~/MeshTerm/.venv/bin/python"; then info "venv exists" diff --git a/scripts/picocalc-lyra/calculinux-wifi-set.sh b/scripts/picocalc-lyra/calculinux-wifi-set.sh index cf91b98..7f4bac3 100755 --- a/scripts/picocalc-lyra/calculinux-wifi-set.sh +++ b/scripts/picocalc-lyra/calculinux-wifi-set.sh @@ -3,15 +3,16 @@ # # Usage: wifi-set # -# Shows known and nearby networks for reference, asks for an SSID and a -# password, writes the credential the way iwd expects it -# (/var/lib/iwd/.psk -- the same pattern calculinux-setup.sh uses for -# first-boot provisioning), then asks iwd to join it right away. Re-execs -# itself under sudo if not already root, since /var/lib/iwd is root-only. +# The script shows the known and nearby networks for reference. It asks for an SSID and a +# password. It writes the credential in the form that iwd expects +# (/var/lib/iwd/.psk, the same pattern that calculinux-setup.sh uses for +# provisioning at the first boot). Then it asks iwd to join the network at once. If the +# user is not root, the script runs itself again under sudo, because /var/lib/iwd is +# only for root. # -# Unrelated to /etc/wifi-kick.sh: that one only nudges the rtl8xxxu dongle -# past a cold-boot firmware-load race so iwd can associate to whatever -# network is already known -- it does not care which network that is. +# This script is not related to /etc/wifi-kick.sh. That script only nudges the rtl8xxxu +# dongle past a race in the firmware load at a cold boot, so that iwd can associate to a +# network that is already known. It does not care which network that is. set -eu diff --git a/scripts/picocalc-lyra/xiao-radio/build-firmware.sh b/scripts/picocalc-lyra/xiao-radio/build-firmware.sh index 0ec8437..d132ad5 100755 --- a/scripts/picocalc-lyra/xiao-radio/build-firmware.sh +++ b/scripts/picocalc-lyra/xiao-radio/build-firmware.sh @@ -1,22 +1,24 @@ #!/bin/sh # build-firmware.sh -- build the XIAO nRF52840 MeshCore radio firmware for the PicoCalc. # -# Runs on your DEV MACHINE (not the Lyra). Produces a single .uf2 you flash with flash.py. +# Run it on your DEVELOPMENT MACHINE (not the Lyra). It makes one .uf2 that you flash with +# flash.py. # # What it does: -# 1. clones MeshCore (pinned to a tested commit) if you don't already have it -# 2. applies meshcore-uart1.patch -- the two changes that make this work: +# 1. clones MeshCore (pinned to a tested commit) if you do not already have it +# 2. applies meshcore-uart1.patch, which has the two changes that make this work: # * teach the existing SERIAL_RX companion interface to build on nRF52 (upstream -# declares an ESP32-only HardwareSerial; nRF52 wants Serial1) -# * add a Xiao_nrf52_companion_radio_serial env: companion on D6/D7, and I2C moved -# off those pads (to internal pins 16/17) so it can't seize them +# declares a HardwareSerial for ESP32 only, and nRF52 needs Serial1) +# * add a Xiao_nrf52_companion_radio_serial env: the companion is on D6/D7, and I2C +# moves off those pads (to internal pins 16/17), so that I2C cannot take them # 3. builds that environment # 4. converts the .hex to .uf2 # -# The patch is upstream-shaped and has been submitted to MeshCore. Once it lands, delete -# the `git apply` step below -- the env ships with the firmware and nothing needs patching. +# The patch has the form that upstream needs, and we submitted it to MeshCore. After it is +# merged, delete the `git apply` step below. The env is then part of the firmware, and +# nothing needs a patch. # -# Prerequisites: git, and PlatformIO CLI on PATH (`pio`). Install pio with: +# Prerequisites: git, and the PlatformIO CLI on PATH (`pio`). Install pio with: # pip install platformio # # Usage: @@ -29,9 +31,9 @@ HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) PATCH="$HERE/meshcore-uart1.patch" MC="${MESHCORE_DIR:-$HERE/_meshcore-build}" ENV=Xiao_nrf52_companion_radio_serial -# Commit this patch was cut against (a `dev` commit -- that is the branch MeshCore takes work -# on). Newer MeshCore may need the edits re-applied by hand (see README "Updating"). -# Override with MESHCORE_COMMIT= to track upstream. +# The commit that this patch is made against (a `dev` commit, because `dev` is the branch +# where MeshCore takes new work). A newer MeshCore can need the edits to be applied again by +# hand (refer to the README, "Updating"). Set MESHCORE_COMMIT= to follow upstream. PIN="${MESHCORE_COMMIT:-e9edfc8e}" command -v git >/dev/null 2>&1 || { echo "ERROR: git not found"; exit 1; } diff --git a/scripts/picocalc-lyra/xiao-radio/flash.py b/scripts/picocalc-lyra/xiao-radio/flash.py index 3fb3a73..fa643d3 100755 --- a/scripts/picocalc-lyra/xiao-radio/flash.py +++ b/scripts/picocalc-lyra/xiao-radio/flash.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 -"""Flash the built .uf2 onto the XIAO nRF52840 via its UF2 bootloader. +"""Flash the built .uf2 onto the XIAO nRF52840 through its UF2 bootloader. -Runs on your DEV MACHINE with the XIAO's own USB-C plugged into it. Works on -Windows / Linux / macOS. +This script runs on your development machine. The USB-C port of the XIAO must be connected +to that machine. It works on Windows, Linux, and macOS. -The XIAO enters bootloader mode either by a physical **double-tap of its reset button** -(it mounts as the ``XIAO-SENSE`` drive) or, if it is running app firmware that exposes a -serial port, by the automatic 1200-baud "touch" this script attempts first. +The XIAO goes into bootloader mode in one of two ways. The first way is a physical +**double-tap of its reset button**. Then it mounts as the ``XIAO-SENSE`` drive. The second +way is for a XIAO that runs app firmware with a serial port. This script first tries an +automatic 1200-baud "touch" on that port. Usage: python flash.py # flash ./meshcore-xiao-radio.uf2 @@ -43,12 +44,12 @@ def find_uf2_drive(): def touch_1200(): - """Best-effort: pulse any Seeed XIAO app serial port at 1200 baud to reset to bootloader. + """Try to reset to the bootloader: open each Seeed XIAO app serial port at 1200 baud. - Matched on Seeed's USB vendor id (2886) *only*. Deliberately not widened to Adafruit's - 239A even though nRF52840 boards commonly use it: other nRF52840 gear on the same bench - (a LilyGo T-Echo, say) answers to 239A, and knocking somebody else's radio into its - bootloader is a rude way to find out you grabbed the wrong board. + The function matches only the USB vendor id of Seeed (2886). We did not add the Adafruit + id 239A on purpose, although nRF52840 boards often use it. Other nRF52840 devices on the + same bench (for example, a LilyGo T-Echo) use 239A. If the function puts the radio of + another person into its bootloader, you find the wrong board in a bad way. """ try: import serial @@ -66,15 +67,15 @@ def touch_1200(): def bootloader_port(): - """Return the COM/tty device of a XIAO sitting in its bootloader, or None.""" + """Return the COM or tty device of a XIAO that is in its bootloader, or None.""" try: import serial.tools.list_ports as lp except Exception: return None for p in lp.comports(): hwid = (p.hwid or "").upper() - # Seeed VID + bootloader PID: 0x0045 (Sense) or 0x0044 (plain nRF52840), both from - # Adafruit's UF2 bootloader board.h for this chip family. + # The Seeed VID and the bootloader PID: 0x0045 (Sense) or 0x0044 (plain nRF52840). + # Both come from the board.h of the Adafruit UF2 bootloader for this chip family. if "2886" in hwid and ("0045" in hwid or "0044" in hwid): return p.device return None @@ -95,12 +96,13 @@ def read_uf2_info(drive): def check_drive_is_ours(drive, force=False): - """Refuse to write to a bootloader that isn't the board this firmware was built for. + """Refuse to write to a bootloader that is not the board that this firmware is built for. - Two different mistakes are cheap to make and expensive to debug. A drive letter is not an - identity -- when one board leaves the bus another can inherit its letter, so the volume you - found may not be the one you just touched. And this firmware links for SoftDevice S140 v7 - (app at 0x27000); a board carrying S140 6.1.1 wants it at 0x26000 and would simply not boot. + Two mistakes are easy to make and difficult to debug. The first is that a drive letter is + not an identity. When one board leaves the bus, another board can get its letter. Thus the + volume that you found can be different from the volume that you touched. The second is + that this firmware links for SoftDevice S140 v7 (the app is at 0x27000). A board with + S140 6.1.1 needs the app at 0x26000, and it does not boot with this firmware. """ info = read_uf2_info(drive) model = info.get("Model", "?") @@ -122,11 +124,11 @@ def check_drive_is_ours(drive, force=False): def main(): - """Put the XIAO in its bootloader and copy the firmware onto the drive it exposes. + """Put the XIAO in its bootloader and copy the firmware onto the drive that it shows. - Takes the .uf2 to flash, defaulting to the one built beside this script, and - refuses to write to a drive that turns out to be some other board unless - ``--force`` says otherwise. + The function takes the .uf2 to flash. The default is the .uf2 that the build made next + to this script. The function refuses to write to a drive that is a different board, + unless the user gives ``--force``. """ args = [a for a in sys.argv[1:] if a != "--force"] force = "--force" in sys.argv[1:] @@ -145,8 +147,8 @@ def main(): break time.sleep(1) if drive and drive == before: - # The letter was already there before we touched anything, so it may belong to some - # other board entirely -- check what it says rather than trusting the letter. + # The letter was there before we touched anything, so it can belong to a different + # board. Check what the drive reports. Do not trust the letter. print(f" note: {drive} was already mounted before the touch") if not drive: port = bootloader_port() @@ -171,7 +173,7 @@ def main(): dst.flush() os.fsync(dst.fileno()) - # the bootloader reboots into the app once written; the drive disappears + # After the write, the bootloader reboots into the app, and the drive goes away. for _ in range(10): if not os.path.exists(os.path.join(drive, "INFO_UF2.TXT")): print("DONE. XIAO flashed and rebooting into the radio firmware.") diff --git a/scripts/picocalc-lyra/xiao-radio/lyra-setup.sh b/scripts/picocalc-lyra/xiao-radio/lyra-setup.sh index 5ba4a04..8a61c43 100755 --- a/scripts/picocalc-lyra/xiao-radio/lyra-setup.sh +++ b/scripts/picocalc-lyra/xiao-radio/lyra-setup.sh @@ -1,17 +1,18 @@ #!/bin/sh # lyra-setup.sh -- make the UART-attached XIAO radio work on the Luckfox Lyra (Calculinux). # -# Runs ON THE DEVICE as root. Idempotent -- safe to re-run. +# Run it ON THE HANDHELD, as root. The script is idempotent, so it is safe to run it again. # -# 1. installs uart1-mux.py and a systemd oneshot that re-applies the UART1->GP4/GP5 pin -# routing on every boot (Calculinux leaves /dev/ttyS1 wired to no pad otherwise) -# 2. adds the MeshTerm user to the `dialout` group so it can open /dev/ttyS1 -# 3. writes a default MeshTerm serial profile pointing at /dev/ttyS1 +# 1. installs uart1-mux.py and a systemd oneshot that applies the UART1->GP4/GP5 pin +# routing again at each boot (without it, Calculinux leaves /dev/ttyS1 connected to no +# pad) +# 2. adds the MeshTerm user to the `dialout` group, so that it can open /dev/ttyS1 +# 3. writes a default MeshTerm serial profile for /dev/ttyS1 # -# The MeshTerm user defaults to `meshterm`; override with: MT_USER=youruser sh lyra-setup.sh +# The default MeshTerm user is `meshterm`. To change it, use: MT_USER=youruser sh lyra-setup.sh # -# Prereq: the XIAO must be flashed with the radio firmware (see build-firmware.sh / flash.py) -# and soldered per the README wiring table. +# Prerequisite: the XIAO must have the radio firmware (refer to build-firmware.sh and +# flash.py), and it must be soldered as the README wiring table shows. set -eu HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) diff --git a/scripts/picocalc-lyra/xiao-radio/uart1-mux.py b/scripts/picocalc-lyra/xiao-radio/uart1-mux.py index d8ef1ec..69f7953 100755 --- a/scripts/picocalc-lyra/xiao-radio/uart1-mux.py +++ b/scripts/picocalc-lyra/xiao-radio/uart1-mux.py @@ -2,20 +2,21 @@ # SPDX-License-Identifier: Apache-2.0 """Route the RK3506 UART1 controller to the PicoCalc header pads GP4/GP5. -Calculinux enables the UART1 controller (so ``/dev/ttyS1`` exists) but never wires it to any -physical pad. The RK3506 "matrix IO" can carry UART1 on almost any pad; this poke selects it -on gpio0-0 (TX) and gpio0-1 (RX), which are the PicoCalc header holes GP4 and GP5 — where the -XIAO's D7 (RX) and D6 (TX) are soldered. +Calculinux enables the UART1 controller (so ``/dev/ttyS1`` exists), but it does not connect +the controller to a physical pad. The "matrix IO" of the RK3506 can carry UART1 on almost +any pad. This poke selects UART1 on gpio0-0 (TX) and gpio0-1 (RX). These are the header +holes GP4 and GP5 of the PicoCalc, where D7 (RX) and D6 (TX) of the XIAO are soldered. -Two register groups, both write-masked (high 16 bits = write-enable mask): +There are two register groups. Both are write-masked (the high 16 bits are the +write-enable mask): * RMIO signal select @ 0xff910080 + pin*4 -> uart1-tx = 0x01, uart1-rx = 0x02 - (the signal id is the pinctrl "func" value minus 0x0f; verified against 8 live pins) + (the signal id is the pinctrl "func" value minus 0x0f. We checked it on 8 live pins.) * primary iomux @ 0xff950000 + (pin//4)*4, 4-bit field (pin%4)*4 -> matrix mode = 7 -Run as root, every boot (installed as the ``uart1-radio-mux`` systemd service by -``lyra-setup.sh``). Idempotent. After it runs, the XIAO radio is reachable at -``/dev/ttyS1`` @ 115200 8N1. +Run this script as root, at each boot. ``lyra-setup.sh`` installs it as the +``uart1-radio-mux`` systemd service. It is idempotent. After it runs, the XIAO radio is +available at ``/dev/ttyS1`` @ 115200 8N1. """ import mmap @@ -25,17 +26,17 @@ import struct RMIO = 0xFF910000 # matrix-IO signal-select block PMU = 0xFF950000 # gpio0 primary iomux block (PMU IOC) -# gpio0-0 = RM_IO0 = PicoCalc GP4 (wired to XIAO D7 / UART RX) -> carry UART1 TX -# gpio0-1 = RM_IO1 = PicoCalc GP5 (wired to XIAO D6 / UART TX) -> carry UART1 RX +# gpio0-0 = RM_IO0 = PicoCalc GP4 (wired to XIAO D7 / UART RX) -> carries UART1 TX +# gpio0-1 = RM_IO1 = PicoCalc GP5 (wired to XIAO D6 / UART TX) -> carries UART1 RX UART1_TX_SIG = 0x01 UART1_RX_SIG = 0x02 def poke(addr: int, value: int) -> None: - """Write one 32-bit word to a physical address, through a one-page /dev/mem map. + """Write one 32-bit word to a physical address, with a one-page /dev/mem map. - Needs root, and the address must be a register: this maps the page it falls in and - writes straight to it. + This function must run as root, and the address must be a register. The function maps the + page that has the address, and writes directly to it. """ page = addr & ~0xFFF off = addr - page @@ -53,8 +54,8 @@ def poke(addr: int, value: int) -> None: def main() -> None: """Point the two pads at UART1 and switch them to matrix mode. - The four writes are the whole job, and re-running them changes nothing, so this is - safe to run on every boot. See the module docstring for the register layout. + The three writes are the whole job. A second run changes nothing, so it is safe to run + this function at each boot. Refer to the module docstring for the register layout. """ poke(RMIO + 0x80 + 0 * 4, (0x7F << 16) | UART1_TX_SIG) # gpio0-0 -> UART1 TX poke(RMIO + 0x80 + 1 * 4, (0x7F << 16) | UART1_RX_SIG) # gpio0-1 -> UART1 RX diff --git a/scripts/uconsole/meshterm-spi-bridge b/scripts/uconsole/meshterm-spi-bridge index dd3bb7b..8ecb59a 100755 --- a/scripts/uconsole/meshterm-spi-bridge +++ b/scripts/uconsole/meshterm-spi-bridge @@ -1,26 +1,26 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 """ -meshterm-spi-bridge — expose an SPI-attached MeshCore radio to MeshTerm. +meshterm-spi-bridge: make an SPI-attached MeshCore radio available to MeshTerm. -Some boards (the ClockworkPi uConsole hackergadgets AIO, Waveshare LoRa HATs) -wire the LoRa chip straight to the host's SPI bus — there is no companion MCU -and no firmware to flash. The node runs *in software* on the host via the -`openhop_core` library (`pymc_core` until its rename in mid-2026; both are -supported, the newer preferred). MeshTerm, though, speaks the MeshCore -companion protocol over serial/BLE/TCP and expects to talk to a node, not to +Some boards (the hackergadgets AIO of the ClockworkPi uConsole, and Waveshare LoRa HATs) +connect the LoRa chip directly to the SPI bus of the host. They have no companion MCU and +no firmware to flash. The node runs in software on the host, with the `openhop_core` +library. The name of the library was `pymc_core` until its rename in mid-2026. The bridge +supports both names, and it prefers the newer one. But MeshTerm uses the MeshCore +companion protocol over serial, BLE, or TCP. It expects to talk to a node. It does not drive a bare radio. -This tool bridges the two: it runs the software node itself and puts the -standard companion protocol in front of it on a local TCP port, so that +This tool connects the two. It runs the software node itself, and it puts the standard +companion protocol in front of the node, on a local TCP port. Then meshterm --tcp 127.0.0.1:5000 -connects exactly as if a real companion device were plugged in. +connects in the same way as if a real companion device is connected. -It is BOTH a friendly setup menu and the bridge process. Run it with any -Python 3 — it finds the node runtime for you and re-execs the bridge half -under that interpreter automatically. You never need to know about venvs. +This file is two things: a setup menu and the bridge process. Run it with any Python 3. +It finds the node runtime for you, and it runs the bridge half again under that +interpreter automatically. You do not need to know about venvs. ./meshterm-spi-bridge # interactive menu ./meshterm-spi-bridge --check # run prerequisite checks and exit @@ -47,18 +47,18 @@ DEFAULT_NODE_NAME = "uConsole" HOME = Path.home() -# The node runtimes this bridge can drive, newest first. `pymc_core` was renamed -# `openhop_core` in 2026 (PyPI `pymc-core` stops at 1.0.12; `openhop-core` carries on -# from 1.1.x) and the newer one has taken two of this file's three compat shims upstream, -# so when both are installed the newer wins. +# The node runtimes that this bridge can drive, newest first. `pymc_core` became +# `openhop_core` in 2026. (PyPI `pymc-core` stops at 1.0.12, and `openhop-core` continues +# from 1.1.x.) The newer library has taken two of the three compat shims of this file +# upstream. Thus, when both are installed, the bridge uses the newer one. RUNTIMES = ("openhop_core", "pymc_core") -# Interpreters likely to have a runtime, in preference order. First the bridge's own -# venv — the answer on a host whose distro package cannot be upgraded (the -# meshcore-uconsole .deb for trixie is built against Python 3.13 and will not run on a -# bookworm uConsole; `python3 -m venv && pip install "openhop-core[hardware]"` -# gives the bridge the current runtime while the GUI keeps its own). Then the -# hackergadgets uConsole .deb's private virtualenv. +# The interpreters that probably have a runtime, in order of preference. The first is the +# own venv of the bridge. This is the answer on a host where the distro package cannot be +# upgraded. (The meshcore-uconsole .deb for trixie is built against Python 3.13 and does +# not run on a bookworm uConsole. `python3 -m venv && pip install +# "openhop-core[hardware]"` gives the bridge the current runtime, and the GUI keeps its +# own.) The second is the private virtualenv of the hackergadgets uConsole .deb. VENV_CANDIDATES = [ str(HOME / ".local" / "share" / APP_NAME / "venv" / "bin" / "python"), "/opt/venvs/meshcore-uconsole/bin/python", @@ -73,7 +73,7 @@ THIN = "-" * 62 # --------------------------------------------------------------------------- # -# Colour (restrained — off when piped, dumb terminal, or NO_COLOR is set) +# Colour (restrained, and off when piped, on a dumb terminal, or when NO_COLOR is set) # --------------------------------------------------------------------------- # _USE_COLOR = ( @@ -113,12 +113,12 @@ def cyan(t: str) -> str: def cmd(t: str) -> str: - """Highlight a command the user should type/copy.""" + """Highlight a command that the user must type or copy.""" return bold(cyan(t)) # --------------------------------------------------------------------------- # -# Small text helpers (deliberately plain — a little colour, no flourish) +# Small text helpers (plain on purpose: a little colour, and no decoration) # --------------------------------------------------------------------------- # @@ -150,10 +150,11 @@ def confirm(prompt: str, default_yes: bool = False) -> bool: def python_runtime(python: str) -> str | None: - """The node runtime `python` can import — a name from RUNTIMES — or None. + """The node runtime that `python` can import (a name from RUNTIMES), or None. - One subprocess per interpreter: it reports the first runtime it finds in - RUNTIMES order, so an interpreter carrying both answers `openhop_core`. + The function starts one subprocess for each interpreter. The subprocess reports the + first runtime that it finds, in the order of RUNTIMES. Thus an interpreter that has both + runtimes answers `openhop_core`. """ if not python or not os.path.exists(python): return None @@ -178,12 +179,12 @@ def python_runtime(python: str) -> str | None: def python_has_pymc(python: str) -> bool: - """Return whether `python` can import a node runtime (kept for the older name).""" + """Return whether `python` can import a node runtime. This function keeps the older name.""" return python_runtime(python) is not None def _meshcore_console_python() -> str | None: - """Resolve the interpreter behind the `meshcore-console` command, if present.""" + """Resolve the interpreter behind the `meshcore-console` command, if the command exists.""" exe = shutil.which("meshcore-console") if not exe: return None @@ -201,11 +202,11 @@ def _meshcore_console_python() -> str | None: def find_runtime_python() -> tuple[str, str] | None: """Find an interpreter that has a node runtime: `(python, runtime)`, or None. - Candidates in order: the explicit override, this interpreter, the known venv - paths, then the interpreter behind an installed `meshcore-console`. The newest - runtime wins across all of them — an interpreter with `openhop_core` beats an - earlier candidate that only has `pymc_core` — and the override is the one - exception: MESHTERM_PYMC_PYTHON names a python, and whatever it has is used. + The candidates, in order, are: the explicit override, this interpreter, the known venv + paths, and the interpreter behind an installed `meshcore-console`. The newest runtime + wins across all of them. An interpreter with `openhop_core` is better than an earlier + candidate that has only `pymc_core`. The override is the one exception. + MESHTERM_PYMC_PYTHON names a python, and the function uses the runtime that it has. """ seen: set[str] = set() override = os.environ.get("MESHTERM_PYMC_PYTHON", "") @@ -232,7 +233,10 @@ def find_runtime_python() -> tuple[str, str] | None: def find_pymc_python() -> str | None: - """The interpreter `find_runtime_python` picks, or None (kept for the older name).""" + """The interpreter that `find_runtime_python` chooses, or None. + + This function keeps the older name. + """ hit = find_runtime_python() return hit[0] if hit else None @@ -260,7 +264,7 @@ def _first_spidev() -> str | None: def port_listening(host: str, port: int) -> bool: - """Return whether something is already accepting connections on host:port.""" + """Return whether a process already accepts connections on host:port.""" try: with socket.create_connection((host, port), timeout=1.5): return True @@ -269,7 +273,10 @@ def port_listening(host: str, port: int) -> bool: def meshcore_console_running() -> list[str]: - """Best-effort list of running meshcore-console (GUI/CLI) command lines.""" + """The command lines of the meshcore-console (GUI or CLI) processes that run. + + The list can be incomplete. + """ try: out = subprocess.check_output( ["pgrep", "-af", "meshcore-console"], text=True, stderr=subprocess.DEVNULL @@ -278,8 +285,8 @@ def meshcore_console_running() -> list[str]: return [] lines = [] for line in out.splitlines(): - # our own bridge invokes the meshcore-uconsole venv python, not the - # meshcore-console binary, so it never matches — but guard anyway. + # Our own bridge calls the python of the meshcore-uconsole venv, not the + # meshcore-console binary. Thus it never matches, but the check is a guard. if "--run-bridge" in line or APP_NAME in line: continue lines.append(line.strip()) @@ -343,7 +350,7 @@ class Preflight: print(f" {tag(self.gpio_ok)} GPIO chip /dev/gpiochip0 — {self.gpio_detail}") if not self.gpio_ok and "permission" in self.gpio_detail: print(" fix: sudo usermod -aG gpio $USER (then log out/in)") - # 4. radio availability (informational) + # 4. radio availability (information only) if self.svc_active: print(f" {tag(True)} bridge service running (radio in use by the boot service)") elif self.gui: @@ -470,7 +477,7 @@ def action_install_service(pre: Preflight) -> None: print("Cancelled.") return - # Install a stable copy of this script for the unit to point at. + # Install a stable copy of this script. The unit points to this copy. INSTALL_PATH.parent.mkdir(parents=True, exist_ok=True) if os.path.abspath(__file__) != str(INSTALL_PATH): shutil.copyfile(os.path.abspath(__file__), INSTALL_PATH) @@ -484,7 +491,7 @@ def action_install_service(pre: Preflight) -> None: print(" " + (r.stderr.strip() or "unknown error")) return - # Try to make it start at boot even before you log in. + # Try to make the service start at boot, also before you log in. linger = subprocess.run( ["loginctl", "enable-linger", os.environ.get("USER", "")], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -518,7 +525,8 @@ def action_uninstall_service() -> None: except FileNotFoundError: pass systemctl_user("daemon-reload") - # Remove the installed helper copy (but not a repo checkout you ran from). + # Remove the installed copy of the helper. Do not remove a repository checkout that + # you ran the script from. if INSTALL_PATH.exists() and os.path.abspath(__file__) != str(INSTALL_PATH): try: INSTALL_PATH.unlink() @@ -546,9 +554,9 @@ def action_status(pre: Preflight) -> None: def offer_meshterm_profile() -> None: - """Optionally add a MeshTerm TCP profile so bare `meshterm` connects here.""" + """Offer to add a MeshTerm TCP profile, so that a bare `meshterm` connects here.""" if MESHTERM_CONFIG.exists() and "[profiles.bridge]" in MESHTERM_CONFIG.read_text(errors="replace"): - return # already there + return # the profile is already there if not confirm( f"\nAdd a 'bridge' profile to {MESHTERM_CONFIG} so you can just run " "`meshterm`?", @@ -612,15 +620,15 @@ def menu() -> int: # --------------------------------------------------------------------------- # -# The bridge itself (runs under the pymc_core interpreter via --run-bridge) +# The bridge itself (runs under the pymc_core interpreter, through --run-bridge) # --------------------------------------------------------------------------- # def _import_runtime(): - """Import the node runtime this interpreter has: `(module_name, module)`. + """Import the node runtime that this interpreter has: `(module_name, module)`. - RUNTIMES order, so `openhop_core` wins over a `pymc_core` sitting beside it. - Raises ImportError when neither is importable. + The function uses the order of RUNTIMES. Thus `openhop_core` wins over a `pymc_core` + that is installed next to it. It raises ImportError when neither is importable. """ import importlib @@ -633,7 +641,7 @@ def _import_runtime(): def _rt_import(rt: str, dotted: str, attr: str | None = None): - """`from {rt}.{dotted} import {attr}` for whichever runtime is loaded.""" + """`from {rt}.{dotted} import {attr}` for the runtime that is loaded.""" import importlib module = importlib.import_module(f"{rt}.{dotted}") @@ -641,24 +649,24 @@ def _rt_import(rt: str, dotted: str, attr: str | None = None): def _patch_pymc_ack_compat(rt: str, log) -> None: - """Accept modern MeshCore ACKs that carry extra bytes after the CRC. + """Accept modern MeshCore ACKs that have extra bytes after the CRC. - Newer firmware sends discrete ACK payloads longer than 4 bytes (the 4-byte - CRC first, then extras). pymc_core's AckHandler requires exactly 4 bytes - and drops the rest, so delivery confirmations never reach MeshTerm. - Override it to take the leading 4 bytes as the CRC — the same tolerant - parse pymc_core already uses for path-bundled ACKs. + Newer firmware sends discrete ACK payloads that are longer than 4 bytes (the 4-byte CRC + first, then extra bytes). The AckHandler of pymc_core needs exactly 4 bytes and drops the + rest. Thus delivery confirmations never reach MeshTerm. This function overrides the + handler to use the first 4 bytes as the CRC. This is the same tolerant parse that + pymc_core already uses for ACKs that are bundled in a path. - openhop_core carries this upstream (its `process_discrete_ack` takes the - first four bytes of anything four bytes or longer), so under that runtime - the shim stands down rather than re-stating what the library already does. + openhop_core has this change upstream. Its `process_discrete_ack` uses the first four + bytes of any payload of four bytes or more. Thus, under that runtime, the shim does + nothing, because the library already does the same thing. """ if rt != "pymc_core": log.info("compat: %s accepts >4-byte ACK payloads itself; ack shim not needed", rt) return try: AckHandler = _rt_import(rt, "node.handlers.ack", "AckHandler") - except Exception as exc: # package layout changed — leave it alone + except Exception as exc: # the package layout changed, so do not change the package log.warning("ack compat shim not applied (%s)", exc) return @@ -680,11 +688,12 @@ def _patch_pymc_ack_compat(rt: str, log) -> None: def _wire_contact_persistence(companion, log) -> None: - """Keep the node's contact list across bridge restarts. + """Keep the contact list of the node when the bridge restarts. - pymc_core's ContactStore is memory-only, so a restart forgot every contact - and DMs failed with ERR_CODE_NOT_FOUND until the peer was heard again. - Reload the last snapshot at startup and re-snapshot after every mutation. + The ContactStore of pymc_core keeps data in memory only. Thus a restart forgot each + contact, and DMs failed with ERR_CODE_NOT_FOUND until the node heard the peer again. + This function loads the last snapshot at startup and saves a new snapshot after each + change. """ import json @@ -747,13 +756,13 @@ def _wire_contact_persistence(companion, log) -> None: def _wire_channel_persistence(companion, log) -> None: - """Keep the node's channel table across bridge restarts. + """Keep the channel table of the node when the bridge restarts. - The runtime's ChannelStore is memory-only, like its contacts: every restart - came up with no channels, and MeshTerm's Channels page was empty until each - one was added again. Reload the last snapshot at startup and re-snapshot - after every set or clear, which the frame server makes through - ``set_channel`` / ``remove_channel`` on the companion. + The ChannelStore of the runtime keeps data in memory only, as it does for contacts. Each + restart started with no channels, and the Channels page of MeshTerm was empty until the + user added each channel again. This function loads the last snapshot at startup and + saves a new snapshot after each set or clear. The frame server does a set or a clear + through ``set_channel`` and ``remove_channel`` on the companion. """ import json @@ -811,61 +820,61 @@ def _wire_channel_persistence(companion, log) -> None: def _wire_trace_push(rt: str, companion, server, log) -> None: """Forward over-the-air trace replies to the client as PUSH_CODE_TRACE_DATA. - pymc_core parses received TRACE packets but never hands them to the - companion client: its frame server registers every push callback *except* - ``on_trace_received``, and nothing in the library ever fires the - ``trace_received`` event — so the whole path is dead code. The only - ``push_trace_data`` call is the self-hop shortcut in ``_cmd_send_trace_path``. - The upshot is that MeshTerm's ``run_trace`` sends the trace, gets - ``RESP_CODE_SENT``, then waits on ``TRACE_DATA`` and times out while the node - quietly drops the reply. + pymc_core parses the TRACE packets that it receives, but it never gives them to the + companion client. Its frame server registers each push callback except + ``on_trace_received``, and nothing in the library fires the ``trace_received`` event. + Thus the whole path is dead code. The only ``push_trace_data`` call is the self-hop + shortcut in ``_cmd_send_trace_path``. As a result, ``run_trace`` of MeshTerm sends the + trace and gets ``RESP_CODE_SENT``. Then it waits for ``TRACE_DATA`` and times out, + while the node drops the reply with no message. - Bridge it at the one seam that sees every received packet with its measured - SNR — the dispatcher's packet-received callback (``CompanionRadio`` already - installs ``_on_packet_received`` there and only acts on group data). Wrap it: - run the original, then, for a TRACE payload, re-slice the on-air bytes into - the hash/SNR blocks ``push_trace_data`` wants and push the standard 0x89 - frame — exactly what the ``meshcore`` client on MeshTerm's side decodes. + This function bridges the gap at the one place that sees each received packet with its + measured SNR. This is the packet-received callback of the dispatcher (``CompanionRadio`` + already installs ``_on_packet_received`` there, and it acts only on group data). The + function wraps the callback. It runs the original callback. Then, for a TRACE payload, + it cuts the on-air bytes again into the hash and SNR blocks that ``push_trace_data`` + needs, and it pushes the standard 0x89 frame. The ``meshcore`` client on the side of + MeshTerm decodes exactly this frame. - Reading the SNRs is the subtle part. A TRACE packet keeps the *route* (the - hop hashes) in its payload, unchanged as it travels; the per-hop SNRs ride in - the packet **header's path field** instead — one signed byte (SNR × 4) appended - by each hop, which is where ``push_trace_data``'s SNR block and MeshTerm's - decoder both source them. So the payload never grows, and an earlier attempt - that waited for SNRs to appear *in the payload* never fired. + The SNR values are the difficult part. A TRACE packet keeps the route (the hop hashes) + in its payload, and the payload does not change while the packet travels. The SNR of + each hop is in the **path field of the packet header** instead. Each hop appends one + signed byte (SNR × 4). The SNR block of ``push_trace_data`` and the decoder of MeshTerm + both take the SNR values from this field. Thus the payload never grows. An earlier + attempt waited for SNR values to appear in the payload, and it never fired. - A received TRACE packet is also not a reply just because it exists. The - dispatcher is promiscuous — it runs the trace handler and this callback for - every non-duplicate TRACE frame it overhears, including third parties' traces - and our own outbound echoed back by the first repeater (same tag, header path - still filling). Pushing on those makes MeshTerm's tag-filtered wait resolve - instantly against a half-walked trace, so *every* trace reads as success. So - we gate hard: remember the tag and route of every trace we originate - (``send_trace_path_raw`` is the one send seam the frame server calls), and push - only a received TRACE whose tag we sent *and* whose header has accumulated an - SNR for every hop of that route — the completed reply. + A received TRACE packet is not a reply only because it exists. The dispatcher is + promiscuous. It runs the trace handler and this callback for each TRACE frame that it + overhears and that is not a duplicate. This includes the traces of third parties. It + also includes our own outbound trace that the first repeater echoes back (the same + tag, and the header path is still filling). If the function pushes these packets, the + wait of MeshTerm, which filters by tag, resolves at once against a trace that is half + complete. Then each trace reads as a success. Thus the function uses a strict gate. It + remembers the tag and the route of each trace that we originate (``send_trace_path_raw`` + is the one send point that the frame server calls). It pushes only a received TRACE that + has a tag that we sent and a header that has an SNR for each hop of that route. This is + the completed reply. - Upstream, the clean fix is the mirror of every other push: have - ``_on_packet_received`` fire ``trace_received`` for a completed trace and have - ``frame_server._setup_push_callbacks`` register ``on_trace_received`` → - ``push_trace_data``. Until a pymc_core release carries that, this shim stands - (same spirit as :func:`_patch_pymc_ack_compat`). + Upstream, the clean repair is the same as for each other push. ``_on_packet_received`` + fires ``trace_received`` for a completed trace, and + ``frame_server._setup_push_callbacks`` registers ``on_trace_received`` for + ``push_trace_data``. Until a pymc_core release has this, this shim stays (it has the + same purpose as :func:`_patch_pymc_ack_compat`). """ import struct import time - # openhop_core carries this upstream: its CompanionRadio fires - # ``trace_received`` for a completed trace and its frame server registers - # ``_on_trace_received`` → ``push_trace_data``. Bridging it a second time - # would push every reply twice, so where the server has the handler, the - # shim stands down. + # openhop_core has this upstream. Its CompanionRadio fires ``trace_received`` for a + # completed trace, and its frame server registers ``_on_trace_received`` for + # ``push_trace_data``. A second bridge pushes each reply two + # times. Thus, where the server has the handler, the shim does nothing. if hasattr(server, "_on_trace_received"): log.info("compat: %s pushes completed TRACE replies itself; trace shim not needed", rt) return try: PAYLOAD_TYPE_TRACE = _rt_import(rt, "protocol.constants", "PAYLOAD_TYPE_TRACE") - except Exception as exc: # package layout changed — leave it alone + except Exception as exc: # the package layout changed, so do not change the package log.warning("trace push not wired (%s)", exc) return @@ -879,9 +888,9 @@ def _wire_trace_push(rt: str, companion, server, log) -> None: log.warning("trace push not wired (no send_trace_path_raw)") return - # tag -> (flags, path_bytes, deadline). Only a trace WE originated, walked in - # full, becomes a push. Entries expire so a never-answered trace can't later - # match a recycled tag. + # tag -> (flags, path_bytes, deadline). Only a trace that we originated, and that walked + # the full route, becomes a push. The entries expire. Thus a trace that nobody answered + # cannot match a tag that the node uses again later. SENT_TTL_S = 90.0 sent: dict = {} @@ -911,12 +920,12 @@ def _wire_trace_push(rt: str, companion, server, log) -> None: tag, auth_code, flags = struct.unpack_from(" None: tag, hops, len(header_snrs), payload.hex().upper(), header_path.hex().upper(), ) if hops == 0 or len(header_snrs) < hops: - return # still in flight: not every hop has reported an SNR yet + return # the trace is still in flight: not all hops have reported an SNR path_snrs = header_snrs[:hops] snr = pkt.get_snr() if hasattr(pkt, "get_snr") else getattr(pkt, "_snr", 0.0) final_snr_byte = max(-128, min(127, int(round((snr or 0.0) * 4)))) & 0xFF - sent.pop(tag & 0xFFFFFFFF, None) # one reply per trace + sent.pop(tag & 0xFFFFFFFF, None) # one reply for each trace server.push_trace_data( hops * hw, flags, tag, auth_code, route, path_snrs, final_snr_byte ) @@ -953,24 +962,25 @@ def _wire_trace_push(rt: str, companion, server, log) -> None: def _wire_rx_log_push(companion, server, log) -> None: - """Forward the promiscuous RX packet log to the client (PUSH_CODE_LOG_RX_DATA). + """Send the promiscuous RX packet log to the client (PUSH_CODE_LOG_RX_DATA). - The same missing-wiring gap as the trace push, one seam over. MeshTerm's live - feed is built from RX_LOG_DATA events — every overheard frame with its SNR/RSSI. - pymc_core's frame server has ``push_rx_raw`` (which builds the 0x88 push) but its - ``_setup_push_callbacks`` never registers ``on_rx_log_data``, so it is never - called. Adverts still show because they carry their *own* decoded push - (``advert_received``, which is wired); everything else we overhear — requests, - channel text we can't decrypt, acks — travels only via this raw log and is - dropped. So the feed looks advert-only. + This is the same gap in the wiring as the trace push, in a place next to it. The live + feed of MeshTerm comes from RX_LOG_DATA events. Each overheard frame has its SNR and + RSSI. The frame server of pymc_core has ``push_rx_raw`` (which builds the 0x88 push), + but its ``_setup_push_callbacks`` never registers ``on_rx_log_data``, so nothing calls + it. Adverts still show, because they have their own decoded push (``advert_received``, + which is wired). Everything else that we overhear travels only through this raw log, and + it is dropped. This includes requests, channel text that we cannot decrypt, and acks. + Thus the feed looks as if it has only adverts. - Subscribe to the dispatcher's raw-packet feed directly and push each frame. - Going through the dispatcher (not ``on_rx_log_data``) keeps it clear of the - frame server's per-connection ``clear_push_callbacks()``, and that feed fires - before dedup, so the client sees every repeat — exactly what a live feed wants. + This function subscribes to the raw-packet feed of the dispatcher directly, and it + pushes each frame. The path through the dispatcher (not ``on_rx_log_data``) does not + meet the ``clear_push_callbacks()`` that the frame server calls for each connection. + That feed fires before the removal of duplicates, so the client sees each repeat. A + live feed needs this. - Upstream, the fix is again the mirror of the wired pushes: register - ``on_rx_log_data`` → ``push_rx_raw`` in ``frame_server._setup_push_callbacks``. + Upstream, the repair is again the same as for the wired pushes: register + ``on_rx_log_data`` for ``push_rx_raw`` in ``frame_server._setup_push_callbacks``. """ dispatcher = getattr(getattr(companion, "node", None), "dispatcher", None) if dispatcher is None or not hasattr(dispatcher, "add_raw_packet_subscriber"): @@ -1022,10 +1032,10 @@ def run_bridge(host: str, port: int, node_name: str) -> int: CompanionFrameServer = _rt_import(rt, "companion", "CompanionFrameServer") CompanionRadio = _rt_import(rt, "companion", "CompanionRadio") - # Prefer the hackergadgets meshcore_console helpers (correct board pins + - # the SAME node identity the GUI uses). Fall back to a direct setup for a - # generic install (a Waveshare HAT with a pip'd runtime), or for the - # bridge's own venv on a host whose GUI package is behind the runtime. + # Prefer the hackergadgets meshcore_console helpers. They have the correct board pins and + # the same node identity that the GUI uses. Fall back to a direct setup for a generic + # install (a Waveshare HAT with a runtime from pip), or for the own venv of the bridge on + # a host where the GUI package is older than the runtime. identity = None radio = None try: @@ -1033,8 +1043,8 @@ def run_bridge(host: str, port: int, node_name: str) -> int: from meshcore_console.meshcore.config import load_hardware_config_from_env from meshcore_console.meshcore.paths import identity_key_path - # The helper is named for the runtime the GUI was built against; either - # spelling hands back the same five-tuple. + # The name of the helper comes from the runtime that the GUI was built against. + # Each spelling returns the same five-tuple. _import_stack = getattr(_mc_runtime, "import_openhop_core", None) or getattr( _mc_runtime, "import_pymc_core" ) @@ -1043,9 +1053,9 @@ def run_bridge(host: str, port: int, node_name: str) -> int: hw = load_hardware_config_from_env() log.info("radio config %s", hw.to_log_string()) radio = create_radio(SX1262Radio, hw, log.info) - # The same preamble rule as the direct path below: unless the environment - # names one, follow MeshCore (32 symbols up to SF8, 16 above) so the radio - # hears the mesh; begin() reads it, so this is in time. + # This is the same preamble rule as the direct path below. Unless the environment + # names a preamble, follow MeshCore (32 symbols up to SF8, 16 above). Thus the radio + # hears the mesh. begin() reads the value, so the change is in time. if "MESHCORE_PREAMBLE_LENGTH" not in os.environ and hasattr(radio, "preamble_length"): radio.preamble_length = 32 if hw.spreading_factor <= 8 else 16 key_path = identity_key_path() @@ -1064,11 +1074,11 @@ def run_bridge(host: str, port: int, node_name: str) -> int: SX1262Radio = _rt_import(rt, "hardware.sx1262_wrapper", "SX1262Radio") LocalIdentity = _rt_import(rt, "protocol.identity", "LocalIdentity") - # The uConsole AIO preset, knob for knob what meshcore_console's own - # HardwareRadioConfig defaults to — because the runtime's *own* defaults - # are a Waveshare HAT's (TX-enable on pin 6, no DIO2 RF switch, no DIO3 - # TCXO, 868 MHz), and on the AIO those leave the radio deaf. Every knob is - # env-overridable under the same MESHCORE_* names the GUI reads. + # The uConsole AIO preset. Each value is the same as the default of the + # HardwareRadioConfig of meshcore_console. The defaults of the runtime itself are + # the defaults of a Waveshare HAT (TX-enable on pin 6, no DIO2 RF switch, no DIO3 + # TCXO, 868 MHz), and on the AIO they make the radio deaf. The environment can + # override each value, with the same MESHCORE_* names that the GUI reads. env = os.environ.get def _int(name: str, default: int) -> int: @@ -1099,10 +1109,11 @@ def run_bridge(host: str, port: int, node_name: str) -> int: "spreading_factor": _int("MESHCORE_SPREADING_FACTOR", 7), "bandwidth": _int("MESHCORE_BANDWIDTH", 62_500), "coding_rate": _int("MESHCORE_CODING_RATE", 5), - # MeshCore's own rule (RadioLibWrapper::preambleLengthForSF): 32 symbols up - # to SF8, 16 above. The receiver must match it, not just the sender — the - # SX1262 waits for the sync word only about as long as the preamble it expects, - # so the old fixed 12 against a mesh sending 32 decoded almost nothing. + # The rule of MeshCore (RadioLibWrapper::preambleLengthForSF): 32 symbols up + # to SF8, 16 above. The receiver must use the same value as the sender. The + # SX1262 waits for the sync word only for approximately the length of the + # preamble that it expects. Thus the old fixed value of 12, against a mesh that + # sent 32, decoded almost nothing. "preamble_length": _int( "MESHCORE_PREAMBLE_LENGTH", 32 if _int("MESHCORE_SPREADING_FACTOR", 7) <= 8 else 16, @@ -1111,8 +1122,8 @@ def run_bridge(host: str, port: int, node_name: str) -> int: "use_dio2_rf": _bool("MESHCORE_USE_DIO2_RF", True), "use_dio3_tcxo": _bool("MESHCORE_USE_DIO3_TCXO", True), } - # Pass only what this runtime's constructor knows: pymc_core 1.0.x lacks - # some of these (en_pins, the gpiod backend) and would refuse the call. + # Pass only the arguments that the constructor of this runtime knows. pymc_core 1.0.x + # does not have some of them (en_pins, the gpiod backend) and refuses the call. accepted = inspect.signature(SX1262Radio.__init__).parameters kwargs = {k: v for k, v in wanted.items() if k in accepted and v is not None} log.info("radio config %s", " ".join(f"{k}={v}" for k, v in kwargs.items())) @@ -1122,9 +1133,9 @@ def run_bridge(host: str, port: int, node_name: str) -> int: "spreading_factor": kwargs["spreading_factor"], "coding_rate": kwargs["coding_rate"], "tx_power": kwargs["tx_power"], } - # The GUI's identity first, where the meshcore-uconsole stack keeps one, so - # a bridge running from its own venv is still the same node the GUI is; then - # the bridge's own. + # Use the identity of the GUI first, in the place where the meshcore-uconsole stack + # keeps it. Thus a bridge that runs from its own venv is the same node as the GUI. + # Then use the own identity of the bridge. xdg = Path(env("XDG_DATA_HOME") or (HOME / ".local" / "share")) for key_path in ( xdg / "meshcore-uconsole" / "identity.key", @@ -1136,8 +1147,8 @@ def run_bridge(host: str, port: int, node_name: str) -> int: break if identity is None: - # No persisted identity anywhere — mint one and save it so the node - # keeps a stable identity across restarts. + # There is no stored identity anywhere. Make one and save it. Thus the node keeps a + # stable identity when it restarts. _LI = _rt_import(rt, "protocol.identity", "LocalIdentity") tmp = _LI() @@ -1149,8 +1160,8 @@ def run_bridge(host: str, port: int, node_name: str) -> int: log.info("generated a new node identity at %s", save_path) async def _main() -> int: - # radio.begin() with backoff — GPIO lines may still be held briefly by - # a just-closed session. + # radio.begin() with backoff. A session that just closed can still hold the GPIO + # lines for a short time. import time for attempt in range(4): @@ -1213,7 +1224,7 @@ def run_bridge(host: str, port: int, node_name: str) -> int: return asyncio.run(_main()) except KeyboardInterrupt: return 0 - except Exception as exc: # surface a clean message, not a traceback wall + except Exception as exc: # show a clean message, not a long traceback log.error("bridge failed: %s", exc) return 1 @@ -1235,7 +1246,7 @@ def main() -> int: ap.add_argument("--name", default=DEFAULT_NODE_NAME) args = ap.parse_args() - # Internal: we've already been re-execed under the node runtime's interpreter. + # Internal: the script already ran again under the interpreter of the node runtime. if args.run_bridge: return run_bridge(args.host, args.port, args.name)