Skip to content

variants/linux: local CLI console over a PTY - #25

Closed
rgrizzell wants to merge 17 commits into
l5yth:linuxfrom
rgrizzell:serial-handler
Closed

variants/linux: local CLI console over a PTY#25
rgrizzell wants to merge 17 commits into
l5yth:linuxfrom
rgrizzell:serial-handler

Conversation

@rgrizzell

Copy link
Copy Markdown

This PR adds a console on a pseudo-terminal (PTY) for configuring Repeaters. Ardulinux Serial is a stdout-only stub, so the CLI was unreachable — the node could only be configured over the mesh or via config file (if the option was supported). A PTY is used rather than a socket so that meshcore-cli can be used in repeater mode: meshcore-cli -r -s $/meshcored/console

The console_path can be set via meshcore.ini. If unset, it defaults to $XDG_RUNTIME_DIR/meshcore/console or /tmp/meshcore-<uid>/console.

root@dev:~# grep console_path /etc/meshcored/meshcored.ini
console_path   = /run/meshcored/console

If meshcore-cli is not installed, screen and minicom will work too. This can be useful for devices that don't have enough RAM to support Python.

root@femtodev:~# minicom -q /run/meshcored/console
clock
  -> 02:11 - 1/9/2026 UTC
get freq
  -> > 910.5250244
advert
  -> OK - Advert sent

Commands and replies are logged to stdout.

root@dev:~# logread | grep meshcored
Tue Sep  1 02:06:37 2026 daemon.info meshcored[1883]: meshcored is starting, VFS root at /root/meshcored
Tue Sep  1 02:06:38 2026 daemon.info meshcored[1883]: SPI begin /dev/spidev0.0
Tue Sep  1 02:06:38 2026 daemon.info meshcored[1883]: LoRa pins NSS=16 BUSY=22 IRQ=23 RESET=25 TX=24 RX=-1
Tue Sep  1 02:06:38 2026 daemon.info meshcored[1883]: CLI console on /run/meshcored/console
Tue Sep  1 02:06:38 2026 daemon.info meshcored[1883]: Radio begin 910.525024 62.500000 7 5 1.800000
Tue Sep  1 02:06:39 2026 daemon.info meshcored[1883]: Repeater ID: [redacted]
Tue Sep  1 02:06:40 2026 daemon.info meshcored[1883]: DEBUG: RX Boosted Gain Mode: Enabled
Tue Sep  1 02:11:20 2026 daemon.info meshcored[1883]: clock
Tue Sep  1 02:11:20 2026 daemon.info meshcored[1883]:   -> 02:11 - 1/9/2026 UTC
Tue Sep  1 02:11:29 2026 daemon.info meshcored[1883]: get freq
Tue Sep  1 02:11:29 2026 daemon.info meshcored[1883]:   -> > 910.5250244
Tue Sep  1 02:12:02 2026 daemon.info meshcored[1883]: advert
Tue Sep  1 02:12:02 2026 daemon.info meshcored[1883]:   -> OK - Advert sent

ggodlewski and others added 16 commits August 31, 2026 13:10
* variants: add scaffolding for linux native

* address review comments

* address review comments
…dev#2)

* variants: allow linux repeater to be configured at runtime

* address review comments

* address review comments

* address review comments
* replace portduino with ardulinux

* replace portduino with ardulinux

* replace portduino with ardulinux

* Remove flags now owned by the ardulinux platform framework

ARDULINUX_LINUX_HARDWARE, -lgpiod, and -li2c are detected and injected
by builder/frameworks/arduino.py via pkg-config. Hardcoding them here
caused linker failures on machines without libgpiod even though the
framework would have gracefully omitted them.

Also switch variants/ardulinux to the git+ platform URL (dropping the
platform-native + platform_packages indirection), update the linux
variant board name, and add arduino to the frameworks list in linux.json.

* address review comments

* Remove stale portduino branding note

The startup string was already fixed in l5yth/ardulinux — main.cpp
says "An application written with ardulinux". Remove the pending-fix
note and update the description to match the current behaviour.

* replace portduino with ardulinux

* Wire up ardulinux platform and fix SPI/VFS/printf for Linux target

variants/ardulinux/platformio.ini: revert local symlink:// back to
git+ URL — the symlink only works in a co-located checkout and would
break CI.

variants/linux/LinuxBoard.cpp:
- Add empty ardulinuxSetup() to satisfy the weak symbol; without it the
  default prints a noisy "No ardulinuxSetup() found" message on startup.
- Replace Serial.printf with printf — Serial.printf is not available
  until after Serial.begin(); using stdio printf is safe at this
  init-time call site.
- Pass 2MHz frequency to SPI.begin() to match the expected SPI clock.

variants/linux/target.cpp: fix spiTransfer — ArduLinux's SPI only has
a 2-arg transfer(buf, len) that operates in-place; copy out→in first
then call the 2-arg form.

examples/simple_repeater/main.cpp: fix arduLinuxVFS → ardulinuxVFS
(case was wrong; symbol is defined as ardulinuxVFS in ArduLinuxFS.cpp).

* set app info to meshcored

* fix linux sx1262 wrapper

* address review: fix GPIO hardware guard, document spiTransfer, use printf

* docs: fix linux variant README (binary name, deps, SPI setup, config keys; use install(1))
meshcore-dev#8)

* variants/linux: on-hardware smoke-test fixes (rx_boost, dio2, --fsdir, docs)

* address review comments
* proofread README

* proofread README
* variants/linux: use linux_base for platformio

* variants/linux: use linux_base for platformio
* pin ardulinux to wire-fix branch, document pkg-config

* pin ardulinux 0.2.1
…meshcore-dev#14)

* variants/linux: fail loud when libgpiod is missing or pin claim fails

* variants/linux: exit when configured GPIO pins fail to bind
…re-dev#15)

* variants/linux: make lora_gpiochip configurable, refresh docs

* ci: variants/linux: add lora_gpiochip hint to base meshcored.ini too

* bump ardulinux to 0.2.2
…shcore-dev#24)

`mesh::MainBoard::sleep()` is a no-op by default and `LinuxBoard` never overrode it, so `board.sleep(0)`/`sleep(30)` returned immediately and the repeater loop spun a core at 100%. Only `NRF52Board` and `ESP32Board` implement it.

Implement it with `sleep()`/`usleep()`, and add a `delay(1)` in the main loop's non-powersaving branch so platforms without power management do not spin either.

Reported and fixed by brianhealey, measured at 99% -> 0-9% CPU on a Pi Compute Module 5 with an SX1262. Cherry-picked from l5yth#21, which could not be merged.

(cherry picked from commit 11d6ddf)

Co-authored-by: brianhealey <brian.healey@gmail.com>
The linux repeater's CLI reads Serial, but on ardulinux Serial is a
stdout-only stub, so the CLI was unreachable — the node could only be
configured over the mesh, with no way to fix a bad radio preset locally
without a second node.

Add a console on a pseudo-terminal (PTY), in MeshCore application code
(not the ardulinux platform layer, so it survives a platform swap) with
logs and the CLI on separate streams. A PTY rather than a socket so
meshcore-cli's repeater mode can attach directly:
  meshcore-cli -r -s /run/meshcored/console
(meshcore-cli -r drives a raw-text serial CLI via pyserial, which needs
a tty.)

- src/helpers/PtyConsole.{h,cpp}: pure-POSIX PTY engine (no Arduino
  dependency, so it is host-unit-testable). posix_openpt + a stable
  symlink to /dev/pts/N; raw termios; maps '\n'->'\r' (1:1) so tools that
  send newline work with the CR-terminated CLI. The pts device is chmod
  0600 -- the unauthenticated local CLI's access gate. The master
  persists across client attach/detach, so there is no accept/reap and a
  write to a closed peer returns EIO (never SIGPIPE).
- src/helpers/LinuxConsole.h: thin Arduino Stream adapter; write()
  mirrors to stdout (so commands/replies reach journald) and the PTY.
- variants/linux: LinuxConfig.console_path INI key (default: a per-user
  path); systemd unit + README document /run/meshcored/console and
  meshcore-cli. User docs avoid the PTY/serial internals.
- examples/simple_repeater: route the CLI loop to the console stream;
  logs (MESH_DEBUG) and the boot banner stay on Serial. MCU targets keep
  console == &Serial, so their behavior is unchanged.
- test/test_console: googletest for the engine (client I/O, newline map,
  peek, reconnect, write-after-close, 0600 char-device perms). Not wired
  into [env:native] to avoid disturbing CI; run via a local override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mmmorks added a commit to mmmorks/meshcore-linux that referenced this pull request Sep 7, 2026
Builds on l5yth#25's PTY console (PtyConsole/LinuxConsole) and keeps its shape:
a pseudo-terminal published as a stable symlink, so meshcore-cli -r -s and
any serial tool attach directly, and the CLI's traffic mirrored to stdout
so journald records what was run. Four things change underneath it.

The daemon holds a descriptor of its own on the slave. Without one the
master reports POLLHUP and read() fails with EIO from the moment the last
client closes until the next one opens -- a level condition the poll()
loop introduced in the previous commit could only throttle, not clear, so
one detached client cost a wake-up per millisecond for the rest of the
run. With it a detached console is idle: poll() sleeps, read() says
"nothing yet", and the next client attaches as if the first had never
left. The master and holder are registered with the event loop as
permanent descriptors, so the daemon wakes on console input.

Every descriptor is close-on-exec and reboot() unpublishes the console
before re-exec. An inherited master would keep the old /dev/pts/N alive
with nobody reading it; a symlink left behind would point at a number the
exec has just freed, which the next image can only guess about. begin()
now tries candidates in order -- console_path, /run/meshcored/console when
the unit's RuntimeDirectory is present (so the packaged service needs no
INI edit), $XDG_RUNTIME_DIR/meshcore/console, /tmp/meshcore-<uid>/console
-- and declines one that a live console holds (its symlink resolves to an
existing device) or that is not a symlink at all, since the path is
operator input and unlink() does not care what it removes. A dangling
symlink from a crashed daemon is reclaimed.

stdin is a second door onto the same CLI when meshcored runs in a
terminal: raw, non-blocking, no kernel echo (the CLI echoes), restored on
end(). Replies follow the command to its source -- always to stdout, and
to the PTY only when the command arrived there -- so a foreground session's
output does not queue in the PTY to greet the next client.

The PTY engine joins the native test build under PIO_UNIT_TESTING, which
is what l5yth#25 wanted and reverted: the "SIGHUP" its author saw is
PlatformIO's runner mapping a plain exit status 1 (a failed assertion) to
signal 1. The suite covers the symlink and its mode, both directions and
the newline map, the holder (a detached client leaves poll() sleeping and
the event loop timing out cleanly), close-on-exec for every descriptor
begin() opens, the live/stale/non-symlink candidate rules, the XDG
default, stdin routing with a real terminal on fd 0, and end()/begin()
across a simulated reboot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXSCjgNEbJfHwLjD2WSHW4
l5yth pushed a commit that referenced this pull request Sep 9, 2026
The linux repeater's CLI reads Serial, but on ardulinux Serial is a
stdout-only stub, so the CLI was unreachable — the node could only be
configured over the mesh, with no way to fix a bad radio preset locally
without a second node.

Add a console on a pseudo-terminal (PTY), in MeshCore application code
(not the ardulinux platform layer, so it survives a platform swap) with
logs and the CLI on separate streams. A PTY rather than a socket so
meshcore-cli's repeater mode can attach directly:
  meshcore-cli -r -s /run/meshcored/console
(meshcore-cli -r drives a raw-text serial CLI via pyserial, which needs
a tty.)

- src/helpers/PtyConsole.{h,cpp}: pure-POSIX PTY engine (no Arduino
  dependency, so it is host-unit-testable). posix_openpt + a stable
  symlink to /dev/pts/N; raw termios; maps '\n'->'\r' (1:1) so tools that
  send newline work with the CR-terminated CLI. The pts device is chmod
  0600 -- the unauthenticated local CLI's access gate. The master
  persists across client attach/detach, so there is no accept/reap and a
  write to a closed peer returns EIO (never SIGPIPE).
- src/helpers/LinuxConsole.h: thin Arduino Stream adapter; write()
  mirrors to stdout (so commands/replies reach journald) and the PTY.
- variants/linux: LinuxConfig.console_path INI key (default: a per-user
  path); systemd unit + README document /run/meshcored/console and
  meshcore-cli. User docs avoid the PTY/serial internals.
- examples/simple_repeater: route the CLI loop to the console stream;
  logs (MESH_DEBUG) and the boot banner stay on Serial. MCU targets keep
  console == &Serial, so their behavior is unchanged.
- platformio.ini [env:native]: define MESHCORE_HOST_TEST and add
  PtyConsole.cpp to build_src_filter. PtyConsole is compiled out without
  the macro and the suite does not link without the source, so both are
  required for CI to build test_console at all.
- test/test_console: 18 googletest cases for the engine. Client I/O,
  newline mapping, peek, reconnect, write-after-close and the 0600
  char-device perms; begin() idempotency; default link resolution via
  XDG_RUNTIME_DIR and the /tmp/meshcore-<uid> fallback; path() falling
  back to the pts device when the symlink cannot be published;
  available() accounting for a peeked byte alongside the queue; end()
  idempotency and post-close inertness; destructor cleanup. All three
  begin() failure paths are exercised: posix_openpt via RLIMIT_NOFILE,
  and grantpt/ptsname_r (unreachable once posix_openpt has succeeded)
  via strong definitions that forward to libc unless a test arms them.

Refs #25
@l5yth

l5yth commented Sep 9, 2026

Copy link
Copy Markdown
Owner

I rebased and merged this in da0e692

@l5yth l5yth closed this Sep 9, 2026
mmmorks pushed a commit to mmmorks/meshcore-linux that referenced this pull request Sep 12, 2026
Squash of l5yth#25 (rgrizzell/MeshCore serial-handler, 914ee62 + 28725fd)
replayed onto pr/04-event-loop-cad as the base for the console rework. Only
conflict resolution here: console_path goes through the validated loader
(assign_string) and is const char* like the other string keys, and the
README's two first-run paragraphs keep this branch's corrected prefs.json
filename while taking l5yth#25's console-CLI wording.

Original message follows.

The linux repeater's CLI reads Serial, but on ardulinux Serial is a
stdout-only stub, so the CLI was unreachable — the node could only be
configured over the mesh, with no way to fix a bad radio preset locally
without a second node.

Add a console on a pseudo-terminal (PTY), in MeshCore application code
(not the ardulinux platform layer, so it survives a platform swap) with
logs and the CLI on separate streams. A PTY rather than a socket so
meshcore-cli's repeater mode can attach directly:
  meshcore-cli -r -s /run/meshcored/console
(meshcore-cli -r drives a raw-text serial CLI via pyserial, which needs
a tty.)

- src/helpers/PtyConsole.{h,cpp}: pure-POSIX PTY engine (no Arduino
  dependency, so it is host-unit-testable). posix_openpt + a stable
  symlink to /dev/pts/N; raw termios; maps '\n'->'\r' (1:1) so tools that
  send newline work with the CR-terminated CLI. The pts device is chmod
  0600 -- the unauthenticated local CLI's access gate. The master
  persists across client attach/detach, so there is no accept/reap and a
  write to a closed peer returns EIO (never SIGPIPE).
- src/helpers/LinuxConsole.h: thin Arduino Stream adapter; write()
  mirrors to stdout (so commands/replies reach journald) and the PTY.
- variants/linux: LinuxConfig.console_path INI key (default: a per-user
  path); systemd unit + README document /run/meshcored/console and
  meshcore-cli. User docs avoid the PTY/serial internals.
- examples/simple_repeater: route the CLI loop to the console stream;
  logs (MESH_DEBUG) and the boot banner stay on Serial. MCU targets keep
  console == &Serial, so their behavior is unchanged.
- test/test_console: googletest for the engine (client I/O, newline map,
  peek, reconnect, write-after-close, 0600 char-device perms). Not wired
  into [env:native] to avoid disturbing CI; run via a local override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mmmorks added a commit to mmmorks/meshcore-linux that referenced this pull request Sep 12, 2026
Builds on l5yth#25's PTY console (PtyConsole/LinuxConsole) and keeps its shape:
a pseudo-terminal published as a stable symlink, so meshcore-cli -r -s and
any serial tool attach directly, and the CLI's traffic mirrored to stdout
so journald records what was run. What changes is underneath it.

The daemon holds a descriptor of its own on the slave. Without one the
master reports POLLHUP and read() fails with EIO from the moment the last
client closes until the next one opens -- a level condition the poll()
loop introduced in the previous commit could only throttle, not clear, so
one detached client cost a wake-up per millisecond for the rest of the
run. With it a detached console is idle: poll() sleeps, read() says
"nothing yet", and the next client attaches as if the first had never
left. The master is registered with the event loop as a permanent
descriptor, so the daemon wakes on console input.

Every descriptor is close-on-exec and reboot() unpublishes the console
before re-exec. An inherited master would keep the old /dev/pts/N alive
with nobody reading it; a symlink left behind would point at a number the
exec has just freed, which the next image can only guess about. begin()
now tries candidates in order -- console_path, /run/meshcored/console when
the unit's RuntimeDirectory is present and writable (so the packaged
service needs no INI edit), $XDG_RUNTIME_DIR/meshcore/console,
/tmp/meshcore-<uid>/console -- and declines one that a live console holds
(its symlink resolves to an existing device) or that is not a symlink at
all, since the path is operator input and unlink() does not care what it
removes. A dangling symlink from a crashed daemon is reclaimed.

A candidate is declined too unless its parent directory is a directory,
owned by our own uid, with no group or other permissions. mkdir() is best
effort and its result discarded, so pre-creating that directory is
otherwise enough to take the console over: `ln -s /etc /tmp/meshcore-0`
aims a root daemon's symlink() and unlink() wherever the owner of the link
likes, and a 0777 directory lets them substitute their own PTY for the
console and read whatever the operator types at it -- `get prv.key`
included. For the same reason the slave device is chmod'd 0600 before
unlockpt() rather than after: open() on a locked pts fails, so there is no
longer a window in which the 0620 root:tty node devpts creates is open to
everyone in group tty.

stdin is a second door onto the same CLI when meshcored runs in the
foreground of a terminal: raw, no kernel echo (the CLI echoes), and
VMIN=0/VTIME=0 so a read never blocks the mesh loop. The foreground only
-- isatty() is just as true for `meshcored &`, and tcsetattr() from a
process in a background process group of its controlling terminal raises
SIGTTOU at that whole group, whose default action is to stop it, so the
daemon would report "Stopped" from inside setup() and never boot; a read()
would do the same via SIGTTIN. Both signals are ignored as well, so a
session backgrounded after startup gets an error return rather than a
stop. Replies follow the command to its source -- always to stdout, and to
the PTY only when the command arrived there -- so a foreground session's
output does not queue in the PTY to greet the next client. A terminal that
hangs up (nohup ... & over an ssh session that then drops) is
unregistered, instead of staying POLLHUP forever and reinstating the busy
loop the previous commit removed.

The terminal is handed back on the way out. end() restores it, and so does
a SIGINT/SIGTERM/SIGHUP handler installed alongside the raw mode: loop()
never returns and nothing calls exit(), so those signals -- not the
destructor -- are how this process really ends, and Ctrl-C used to leave
the operator at a shell with ECHO and ICANON off, typing blind until they
thought to run `reset`. The handler does nothing that is not
async-signal-safe: restore, then re-raise with the default disposition so
the exit status still reports the signal. fd 0 keeps the flags it came
with, too -- O_NONBLOCK belongs to the open file description, which fd 0
shares with the invoking shell's terminal, so setting it there outlived
this process ("bash: read error: 0: Resource temporarily unavailable"),
and with VMIN=0 it was never needed.

Nothing was line-buffering stdout. The C library gives stdout line
buffering only when it is a terminal; under StandardOutput=journal it is a
socket and fully buffered, so a reply or a log line waited for 4 KB to
pile up behind it before journald saw any of it. setup() now asks for
_IOLBF before the first write, which is what makes the README's claim that
no stdbuf wrapper is needed true, and the console's per-byte fflush() is
unconditional instead of skipped in exactly the case that needs it.

The PTY engine joins the native test build under PIO_UNIT_TESTING, which
is what l5yth#25 wanted, and the suite runs on Linux and macOS. It covers the
symlink and its mode, both directions and the newline map, the holder (a
detached client leaves poll() sleeping and the event loop timing out
cleanly), close-on-exec for every descriptor begin() opens, the candidate
rules (live, stale, not a symlink, parent directory open to others), the
XDG default, stdin routing with a real terminal on fd 0, a background job
left neither stopped nor in raw mode, a hung-up stdin dropped rather than
polled forever, Ctrl-C putting the terminal back, and end()/begin() across
a simulated reboot.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants