Epic: every identity is a real Meshtastic node - #5
Merged
Merged
Conversation
radio.driver: spi drives an SX126x on a Linux SPI bus directly, with radio.device set to the meshtasticd board file (config.d/lora-*.yaml). Pure Go: spidev and GPIO v2 chardev ioctls, no CGO. 49 of meshtasticd's 61 board files parse; LR1121, RF95 and CH341 USB adapters are rejected. - internal/radio/sx126x: board parser (pins, gpiochip maps, Enable_Pins, SX126X_ANT_SW, TCXO, DIO2 RF switch, SX126X_MAX_POWER, TX_GAIN_LORA), Linux HAL, driver with datasheet errata, tests against a fake chip - kisstool --board: info (SPI readback, device errors, noise floor), listen and send-text - setup wizard keeps an spi radio's board file; editing a radio no longer forces kiss - docs/spi-radio-testing.md: step-by-step guide for testers with hardware Untested on hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
…, board names and auto The native radio driver moves to internal/radio/spi and splits into a shared radio loop and chip drivers, so it now covers every chip and bus meshtasticd's board files use: - chips: SX1262/SX1268/LLCC68, SX1276/SX1278 (RF95), SX1280 (2.4 GHz) and LR1110/LR1120/LR1121, each from the datasheet and RadioLib's command sequences, with fake-chip tests for RX and TX - CH341 USB-to-SPI sticks (MeshStick, Meshtoad, RAK19714, uMesh, PiNedio, PiggyStick) over usbfs, in pure Go - boards: meshtasticd's 61 board files are built in; radio.device takes a file path, a board name, or auto (CH341 product string, Pi HAT+ EEPROM, RAK I2C EEPROM, as meshtasticd's autoconf) - board file keys: per-chip power limits and defaults, LR11x0 rfswitch_table, optional BUSY, repeated YAML keys resolved like yaml-cpp (first wins) - kisstool boards lists them; kisstool info prints each chip's diagnostics LR2021 is left out: no meshtasticd board uses it. Untested on hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
A kiss radio's device can now be tcp://host:port. The driver dials it with a connect timeout and TCP keepalives instead of opening a serial port, and reconnects and re-applies the config exactly as for a serial modem. This is how RepeaterTastic uses meshtasticd's raw modem mode, which serves a Linux LoRa HAT or CH341 stick with the Mesh KISS v2 protocol (currently the raw-modem branch of A13xB0/firmware). - config: a malformed tcp:// device is rejected, and two radios can't share one (host names compared case-insensitively). - kisstool: --dev tcp://host:port works. - web: the setup "Test modem" probe accepts tcp:// devices; before a password is set it only dials loopback addresses. - docs: meshtasticd-raw-modem.md, linked from the README, configuration and hardware pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Edit radio and Add radio get a USB modem / meshtasticd switch: meshtasticd takes a host and port and saves tcp://host:port, with a hint about RawModemPort. The setup wizard lists meshtasticd's raw modem next to the serial ports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
FESC FESC TFEND decoded to FEND; the second FESC is an invalid escaped byte and is dropped, so TFEND is then a literal 0xDC, matching the modem firmware. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
… uses Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
GET /boards (setup-level auth) lists what radio.device can be with driver spi: auto, meshtasticd's board files in /etc/meshtasticd/config.d and available.d, then the built-in copies, each with its module, bus and whether this driver supports it. POST /setup/probe takes driver: spi and opens the board, answering with the chip's diagnostic lines in a new details field; a board a running radio already drives is reported, not opened twice, and board files are limited to /etc/meshtasticd since the probe runs before a password is set. POST /setup takes driver (kiss or spi) and applies it with device; an older wizard that sends only a serial port still can't overwrite an spi board. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
ModemDeviceField switches between "USB modem" (the serial port as before) and "Board (SPI or USB stick)": a select of the boards from GET /boards, grouped by where they come from, with a board file path as the last choice. It sets the radio's driver (kiss or spi) with the device, so Edit radio and Add radio save both. The setup wizard gets a "LoRa board on SPI or a USB stick" card under the serial ports; Test modem probes the board and shows the chip's diagnostics, and finishing sends driver with device. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
One lazy radio serves both drivers, so picking a Board in first-time setup takes effect at once instead of showing the kiss driver until a restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
ModemDeviceField now offers all three ways to reach a radio: a USB KISS modem on a serial port, meshtasticd's raw modem over TCP (from kiss-tcp), and a LoRa board the spi driver runs itself. The setup wizard shows the meshtasticd card and the board card under the serial ports. Picking meshtasticd or a port sets driver kiss; picking a board sets spi. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
…erTastic into native-spi-radio-gui
…uto finds it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
A reply left in the adapter by an interrupted session is longer than the next
short read expects, and usbfs answers EOVERFLOW ('value too large for defined
data type'). Read full 32-byte packets, and throw away leftovers when opening.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
…ial port by name experimental.meshtasticd_raw_modem gates radio.device tcp://…, the setup probe and the meshtasticd choice in the modem pickers, until raw modem mode lands upstream (meshtastic/firmware#11863). The setup wizard gets a card for typing a serial port that isn't listed, and Windows COM ports pass the serial-path check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
…ial port by name experimental.meshtasticd_raw_modem gates radio.device tcp://…, the setup probe and the meshtasticd choice in the modem pickers, until raw modem mode lands upstream (meshtastic/firmware#11863). The setup wizard gets a card for typing a serial port that isn't listed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
…tible The same HAT has one meshtasticd file per host (Raspberry Pi, Luckfox Lyra Zero W, OK3506…) and they all carry the same name, so the dropdown groups by host and files found on this machine say where they came from. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
The raw modem mode isn't going upstream: meshtasticd stays a full node and RepeaterTastic will drive meshtasticd instances over the client API instead. Remove the tcp:// KISS transport, the experimental switch, the meshtasticd choice in the modem pickers and setup wizard, and the guide. The KISS decoder escape fix and its test stay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Every identity becomes a real Meshtastic node: boards running Meshtastic firmware attach over the client API, and the relay persona and identities run as meshtasticd instances sharing our radio through an air bridge. Child pull requests merge into this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
internal/mtclient speaks the client API to a board over serial or to meshtasticd over TCP: the want_config handshake, a mirror of the node's config, channels and node DB, admin requests that wait for the response or the ack, and reconnecting after drops and reboots. Live tests (RT_TEST_MESHTASTICD) bridge two sim-radio meshtasticd instances the way the air bridge will. Measured on 2.7.26 and 2.8.0 and written up in docs/meshtasticd-nodes.md: hosted nodes need 2.8, since 2.7 can't take a PKI DM back in; about 3 MB private memory per instance. The interop harness gets UDP=0 to leave the sim radio as the only path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Real nodes 0+1: Meshtastic client API client and meshtasticd measurements
radio.driver meshtastic takes a node's serial port or a meshtasticd address. internal/nodes.Attached is the radio and the one identity's mesh.Remote: the node does its own routing, ACKs and encryption, and the host keeps chat, the node DB, the packet log, traceroutes and app clients from what the node delivers. - mesh: remote identities (no private key). Sends go to the node, RemoteReceived takes its deliveries, periodic broadcasts and module replies are skipped, link frames are ignored, SwapRemote replaces a placeholder once the node answers. - Settings both ways: UpdateConfig writes region, preset, slot, power, hop limit, primary channel and role to the node in one edit transaction; MirrorConfig and the config file follow the node. - App ports: a node's identity can have one, and admin messages from the app are forwarded to the node. - Web: setup and probe take the meshtastic driver (probe limited to this machine and the LAN before a password exists); names and channels are written to the node; extra identities, moves and key export are refused on a node radio; switching to or from a node needs a restart. - GUI: radio switch USB modem | HAT or stick | Meshtastic node, a node card and probe in the setup wizard (which restarts by itself), node role wording, a Meshtastic node badge. - mtclient/mtclienttest: a fake node shared by the tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Real nodes 2: a node on Meshtastic firmware as a radio
Hosted nodes get MQTT and UDP traffic
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Remove a scratch tool committed by mistake
Found testing a XIAO on Meshtastic 2.8.0 against the Pole. - A board takes a PKI downlink only when it knows both nodes: the recipient is added as a contact (from the host's node DB) first. Identity -> Pole DMs now arrive, one hop out. - Channels written to a board keep MQTT uplink and downlink on, so an edited channel still carries the identities. - The notice and docs say that DMs to identities behind a board don't arrive: 2.8.0 publishes only what it can read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Board radios: direct messages out, channels keep the proxy
Found testing the CH341 bridge: the bridge rebooted and came back as a new USB device, but the spi driver kept its old handle, reporting "connected" while every transfer failed (1179 errors), and the lazy radio never reopened a radio once it had opened. - spi: a transfer failing because the adapter is gone (ENODEV, ESHUTDOWN, ENOENT, or EBUSY after a reset dropped our interface claim) closes the radio, and Stats reports it disconnected. - lazy: when the radio closes (not on shutdown) it opens it again and applies the last settings. Verified: after a USB reset of the bridge the radio reopened within a second and received the Pole's next message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Radios reopen when their adapter goes away
The Go node stack is retired: the relay persona and every identity always run as meshtasticd instances, with no fallback. The host keeps the air bridge, transmit queue, packet log, node DB, chats, links, plugins and the client API. Identities on several radios (federation) and the experimental switches are removed; old config keys still load. - GET /status has a nodes health object (ok, starting, warning, error), shown as a meshtasticd chip in the top bar that opens the new Configuration → meshtasticd tab - the setup wizard's meshtasticd step is no longer optional - the Docker image is now based on the meshtasticd image, and the Go build stage runs on the build platform - deploy/install.sh sets up meshtasticd (--meshtasticd auto|apt|docker|skip) - README quick start and docs updated Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Run every node on meshtasticd; status chip; packaging
deploy/install.sh now always installs meshtasticd 2.8+ (no options), and downloads the latest release binaries for the machine when none are given, checked against SHA256SUMS. The README quick start is two short paths, Pi or Docker, then the browser; the device details moved to docs/hardware.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Shorter quick start: a Pi install or Docker
…able Structure - internal/web: handlers.go and extras.go split by area (setup, auth, status, identities, channels, messages, nodes, stats, config, backup, links); nodes.go (meshtasticd) renamed meshtasticd.go. Moves only. - cmd/repeatertastic: radio startup moved to radio.go and split into openRadio, attachBoard and startLinks. - internal/nodes: the settings a node is given moved to settings.go, and ApplyConfig split into small builders (cognitive complexity 78 -> ~15). Fixes found by golangci-lint and SonarQube - spi: NUL/0xFF padding was never cut from EEPROM and SX1280 version strings (strings.IndexAny/TrimRight work on runes, not bytes). - errors wrapped with %w; unused parameters, fields and a deprecated MQTT field removed; deferred pin writes and cleanup made explicit. - GUI: every button has an explicit type (form submits kept), unlabelled inputs and selects labelled, setup table has row headers, a mock bug. Tooling - .golangci.yml and `make lint` (0 issues), sonar-project.properties. Sonar: 0 bugs, 0 vulnerabilities, A ratings, duplication 0.1%. GUI - Configuration -> Position & hardware: drop a pin on a map (click or drag) to set the site position; map setup shared with the nodes map. - Identities: one table of every radio's identities with a Radio column, and a "Port busy" status when an identity's app port can't be opened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Review: structure, lint and Sonar fixes; drop a pin; one identities table
The radio picker that reloaded the page is gone: every page shows the whole
site, with a radio filter where it helps.
API
- ?radio=all on /nodes (merged, with heard_by), /packets (radio_id),
/links (radio_id, radio_name), /events (a status per radio) and the
/stats endpoints. Single-radio /nodes has heard_by too.
GUI
- Top bar: one row per radio (connection, preset, airtime, its own relay
mode) and the site's meshtasticd chip; unchanged for one radio.
- Dashboard, Statistics, Packets, Nodes, Links: radio filter and a radio
column or chips; Dashboard has a per-radio overview.
- Identities, Channels, Chat: every radio's identities, labelled by radio.
- Configuration: Relay, Airtime, Position and MQTT have a "Settings for"
radio selector; the site-wide tabs always show.
- Sidebar: each radio's relay persona.
meshtasticd
- Each instance keeps its last 1000 lines with timestamps, and its last 20
stops with the reason. A stop right after RepeaterTastic committed
settings is counted as a reboot (meshtasticd reboots to apply some), not
as a failure, and doesn't warn in the status chip.
- GET /hosted/{name}/log; Configuration -> meshtasticd has a Log drawer per
instance with its stops and output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Replaces the full-width radio rows. The menu says what each mode does, and the top bar sits above the page so menus aren't drawn under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
- A radio with tx_power 0 (the region's limit) sent 0 while meshtasticd stores the limit it picked, so every reconnect pushed the LoRa config again and meshtasticd rebooted every 7 seconds. The resolved power is sent now, and a node that comes back without the same settings three times isn't pushed again: the log and the status chip say which settings it won't keep. - Log lines carry radio and identity fields; meshtasticd nodes log with their identity. The Logs page labels each line and filters by radio or identity. Lines marked ERROR/WARN by the node code get that level. - An identity whose app port can't open is logged once, not on every sync. - The Logs page updates live again (the store replaced the array in place, which computed views don't notice). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Code smells (218 -> 0) - Every function over cognitive complexity 15 split into named helpers (daemon startup, config validation, host receive/transmit, meshtasticd node settings, web handlers, plugin manager and API, CLI, mDNS, SPI chip drivers and board parser, KISS, MQTT, client API), tests included. - Duplicated literals are constants, empty functions say why they're empty. - GUI: nested ternaries and template literals unpicked, native elements instead of ARIA roles (fieldset, dialog, output, ul/li, hr), labels tied to their controls, modern string/number APIs. Copy falls back to a selected prompt where the Clipboard API isn't available (plain http). Coverage (54% -> 95%): tests added for every package, with fakes for modems, boards, meshtasticd, docker, MQTT brokers and plugin hosts. Test seams only where a clock, a system path or os.Exit had to be swapped. Bugs found and fixed on the way - nodes: a board's delayed echo could send on the frame channel as it was closed (data race); sends and the close now share a lock. - links/udp: when no multicast group could be joined, Run returned nil and left its output socket open. - mesh: with no state dir, identities' positions were rewritten every 250 ms after the first minute; the timer now runs once a minute. - web: a generated identity key only avoided last-byte clashes on its own radio, so it could be impossible to move; every radio is checked now. - spi: flaky and hardware-dependent board detection made testable. - mqtt: the broker test expected in-process delivery; it now checks what hosted nodes get through the air bridge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Site-wide GUI: every radio on every page; meshtasticd logs
…dentities; footer - Identities have an "App can change node settings" switch (app_settings), off by default. Off, the app port refuses admin requests that change the node's radio, device, module or position settings, or reboot or reset it, with NOT_AUTHORIZED; reading, names, channels and the node list still go through. - Removing a running radio lists its identities and moves them to another radio first (the default), or leaves them off air on disk. - A footer on every page: Made in Scotland, linking ScotMesh, and the repo. - Text about the old radio switcher updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
App settings switch, radio removal moves identities, footer
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc
Docs: binaries on the PATH in the new image
A13xB0
marked this pull request as ready for review
September 16, 2026 22:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Epic: every RepeaterTastic identity becomes a real Meshtastic node, instead of RepeaterTastic re-implementing the node in Go.
Why
A Meshtastic maintainer's feedback on the raw modem PR (meshtastic/firmware#11863) was that meshtasticd is a full node, and a host should drive it through the client API the way Meshtasticator does rather than bypass it. Following that gives us every routing rule, module and future firmware change for free, and it needs no firmware change: SimRadio's
SIMULATOR_APPenvelope already carries frames both ways and honours RSSI/SNR.The raw modem work (#4, the
tcp://KISS transport, meshtastic/firmware#11863, meshtastic/meshtastic#2702) is dormant and has been taken out of #3.Shape
Every identity is a
Node, and nodes join an Air. The GUI, chat, plugins and MQTT sit on top.meshtasticdprocess we start withLora: Module: sim, driven over the client API. The relay persona is a node too. Needs meshtasticd 2.8.0 or newer, installed or in Docker.Join,LeaveandRelay(). One rule holds on every air: its nodes hear each other at hop 0, so nothing from the same mast is repeated.spiradio.Relay()is nil, so the host starts a hosted persona and joins it. Nodes'SIMULATOR_APPtransmissions go out through the transmit queue (CSMA, duty cycle, site turns), and every heard frame is injected into every node with real RSSI/SNR.radio.driver: meshtastic).Relay()is the board, and joined nodes sit one hop behind it, carried over its MQTT client proxy.The relay's settings use Meshtastic's names: device roles (client, client_base, client_mute, router, router_late) plus monitor and off, and rebroadcast modes.
Child pull requests
Each merges into
epic/real-nodes; this PR merges intomainonce they're all in (and after #3).docs/, Real nodes 0+1: Meshtastic client API client and meshtasticd measurements #6)internal/mtclient: Meshtastic client API over TCP and serial (Real nodes 0+1: Meshtastic client API client and meshtasticd measurements #6)Removed
Base
Branched from #3 (
native-spi-radio), because hosted nodes use itsspidriver. Until #3 merges, this PR's diff includes #3's commits.🤖 Generated with Claude Code
https://claude.ai/code/session_01SJZ75CWob1eMXeTa8Q2nLc