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
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