Files
firmware/src/platform/portduino/ConfigCheck.cpp
T
Jonathan BennettandClaude Opus 5 8c0abbd522 feat(portduino): BLE peripheral support via BlueZ for meshtasticd (Raspberry Pi) (#11396)
* feat(portduino): BLE peripheral support via BlueZ for meshtasticd on Linux

Adds the standard Meshtastic BLE service (toRadio/fromRadio/fromNum/logRadio)
to the Linux native target, so a Raspberry Pi running meshtasticd can be
paired and used over BLE like any other Meshtastic device.

Implementation: a new LinuxBluetooth backend registers a GATT application,
LE advertisement and pairing agent with bluetoothd over the org.bluez D-Bus
APIs, using sdbus-c++ (both the 1.x and 2.x major versions, via a small
compat shim - Debian bookworm/Ubuntu 24.04 ship 1.x, trixie/Fedora ship 2.x).
When the sdbus-c++ dev package is absent the whole backend compiles out via
__has_include, the same optional-dependency idiom as the ulfius webserver.

Threading follows the NimbleBluetooth model, simplified: the sdbus event
loop runs its own thread, and all PhoneAPI calls happen on the main thread.
Writes queue to the main loop; reads park the D-Bus reply and are completed
from the main thread after queued writes, so write-then-read clients see
their answer without any busy-waiting.

Enablement is a double opt-in: a new `Bluetooth:` config.yaml section
(Enabled, default false; AdapterId, default hci0) must turn BLE on for the
host, and the regular device config bluetooth.enabled must be on. The
config-check schema and fixtures cover the new section.

Pairing honors config.bluetooth.mode: NO_PIN maps to a NoInputNoOutput
just-works agent; RANDOM_PIN to DisplayOnly with the kernel-generated
passkey shown on screen/log via the existing BluetoothStatus plumbing.
FIXED_PIN falls back to random-passkey semantics with a warning - BlueZ
does not support forcing a passkey. PIN modes enforce
encrypt-authenticated-read/write on all mesh characteristics.

Packaging: install a D-Bus system policy so the meshtasticd user may talk
to org.bluez, add it to the bluetooth group, order the unit after
bluetooth.service, and add libsdbus-c++-dev to debian/rpm/docker/CI deps.

Verified in-container against a mock bluetoothd: registration flow, GATT
tree enumeration, advertisement properties, and a full config download
(ToRadio wantConfig -> 47 FromRadio packets) through the D-Bus bridge.
Real-hardware pairing/notify testing on a Pi still pending.

Known limitations (v1): meshtasticd must be restarted if bluetoothd
restarts; FIXED_PIN degrades to a random passkey; getRssi() returns 0
(same as nRF52).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): BLE fixes from first real-hardware pass (Pi CM5 + RAK6421)

Findings from testing PR #11396 on a Raspberry Pi CM5 (Pi OS trixie,
BlueZ/sdbus-c++ 2.1 - the v2 compat path) with a RAK6421 HAT and an
Android phone:

- getMacAddr() leaked its HCI socket on every call and never closed it,
  and on failure returned without touching the caller's buffer - which
  getDeviceName() passed in uninitialized. Close the socket on all paths
  and cache the MAC after the first successful read; it cannot change at
  runtime and this now runs on every bluetoothd property read.
- getDeviceName() zero-initializes its MAC buffer, and LinuxBluetooth
  snapshots the name once at setup() on the main thread: the
  advertisement's LocalName getter runs on the D-Bus event-loop thread
  and getDeviceName()'s static buffer is not thread-safe.
- Restore NimBLE-style config-phase packet prefetch (depth 3). The
  initial port answered every FromRadio read with a D-Bus -> main-loop
  round trip, which made the config download noticeably slow; ReadValue
  now answers straight from the prefetch queue on the event-loop thread,
  with NimBLE's safety rules (never in STATE_SEND_PACKETS, writes always
  observed before reads, queue cleared on disconnect).
- Set advertising MinInterval/MaxInterval to 20-100ms (BlueZ >= 5.71;
  older versions ignore the properties). btmon showed the kernel default
  of 1.28s otherwise, and Android's background-connect scan windows are
  sparse enough that tap-to-connect took 8-14s; 20ms is the same floor
  NimBLE uses on ESP32.

Verified on hardware: scan, passkey pairing, connect, config download,
reconnect after bond wipe. Also diagnosed (no code change): the node
identity MAC comes from the RAK HAT EEPROM by design, so the BLE name
suffix follows the HAT rather than the BT adapter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): address CodeRabbit review on BLE support

- setBluetoothEnable: handle disable before the config gate, so a running
  BLE stack is always stoppable even after the device config turns
  Bluetooth off underneath it
- getMacAddr: read the adapter configured as Bluetooth.AdapterId instead
  of hardcoding hci0, falling back to hci0 for unparseable names
- systemd unit: Wants=bluetooth.service so bluetoothd is pulled up when
  present (After= only orders, it does not start it)
- debian/rpm: Recommends: bluez as the runtime contract for BLE
- dbus policy: document why the org.bluez rule is destination-wide
  rather than a per-interface allowlist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): show the BLE pairing code on BaseUI screens

onDisplayPasskey published the passkey to bluetoothStatus and triggered
PowerFSM, but never called screen->startAlert(), so on BaseUI the code only
ever reached the log. BluetoothStatus has no BaseUI consumer -- only InkHUD's
PairingApplet and StatusLEDModule read it -- so a Pi driving a HUB75/OLED
panel showed nothing while BlueZ sat waiting for the user to type a code they
could not see. NimBLE and nRF52 draw it via startAlert(); this adds the
missing half for Linux.

The agent callbacks run on the sdbus event-loop thread while the screen is
owned by the main thread, so the passkey is handed over as a pending flag and
drawn from runOnce(), matching the existing disconnectCleanupPending pattern
rather than reaching into the screen from the event loop.

Dismissed on all four exits, so a stale code cannot stick on an always-on
panel: Paired -> true (newly watched in PropertiesChanged, which previously
only looked at Connected), agent Cancel, peer disconnect (moved out of the
lastGone branch so a peer leaving mid-pairing clears the code even when
another device is still connected), and doDeinit() -- applied inline there
because runOnce() may never be scheduled again after teardown.

Verified on a Pi 5 + BlueZ 5.66 in RANDOM_PIN mode: the code renders on a
HUB75 panel and clears once the phone completes pairing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: install libsdbus-c++-dev for the native test build

setup-native-test landed on develop while this branch was adding
libsdbus-c++-dev to setup-native, so the new action's "full setup-native
list" of C libraries is missing it. Without the package the test job
builds with HAS_BLUETOOTH 0 and never compiles LinuxBluetooth.cpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): warn when a factory reset cannot clear BLE bonds

factoryReset(eraseBleBonds) silently did nothing on Linux when the BLE
backend was not running, so the reset reported success while the host's
pairings stayed. Removing them needs a live connection to bluetoothd that
a disabled backend never opened, so say so rather than imply they went.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): gate the factory-reset bond clear on an enabled backend

setup() leaves linuxBluetooth allocated with its bus torn down when it
throws, so a pointer check alone let factoryReset log "Clear bluetooth
bonds" for a clear that clearBonds() then declined to perform. isEnabled()
is only true after setup() completes, which routes that case to the
warning that says the bonds were left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* style: reformat under clang-format 20

#11909 moved trunk from clang-format 16 to 20, which spaces C-style casts
differently and reindents the comment above the HAS_WIFI block. Both files
are ones this branch already touches, and trunk's fmt linter grades whole
files, so its check fails until they are reformatted. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184v7MyCLuJHW2ebZ9r8NmQ

* fix(portduino): three BLE config and lifecycle fixes from review

Bluetooth config keys are now assigned individually rather than per section.
loadConfig() runs once for every file in config.d, so reading an absent key as
its default let a later file that named only one of them silently reset the
other: `AdapterId: hci1` alone turned Bluetooth off, and `Enabled: true` alone
dragged the adapter back to hci0. Only what a file actually states should
override what an earlier one set.

A backend that failed to come up is now retried. setup() can leave
linuxBluetooth non-null but disabled - bluetoothd not ready, adapter missing,
policy refusing - and every later enable then called resumeAdvertising(), which
returns immediately while disabled. A transient failure at boot kept BLE off
until the process restarted. doSetup() already opens with `if (enabled) return`
and tears the bus down on every failure path, so calling it again is safe.

Bluetooth.AdapterId is now checked for the hci<digits> form. LinuxBluetooth uses
the value verbatim as the BlueZ object path while the MAC fallback reads only
the leading hciN, so "hci1junk" looks plausible, yields a MAC, then finds no
adapter and BLE never comes up. Covered by a new fixture and suite case, which
is the kind of silent no-op that directory exists to catalogue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-01 21:20:09 +00:00

1379 lines
63 KiB
C++

#include "ConfigCheck.h"
#ifndef ARCH_PORTDUINO_WASM
#include "configuration.h"
#include "PortduinoGlue.h"
#include "yaml-cpp/eventhandler.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <set>
#include <sstream>
#include <string>
#include <system_error>
#include <vector>
namespace
{
// Largest General.MaxNodes we accept - artificial, not derived: nothing fails at 16001. Catches a
// typo that would otherwise size the node DB into a boot-time allocation failure. Sits under the
// 16384 (128 x 128) where HopScalingModule saturates and drops nodes. Raise it if a host needs more.
constexpr int MAX_NODES_SANITY_CEILING = 16000;
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
// Mirrors the keys loadConfig() reads: meshtasticd silently ignores anything else, so
// an unlisted key is honestly "unknown". Teach loadConfig() a new key, add it here too --
// CI runs --check over bin/config.d/**, so an omission fails the build rather than a user.
const std::set<std::string> kLoraPinKeys = {"CS", "IRQ", "Busy", "Reset", "TXen", "RXen", "SX126X_ANT_SW", "GPIO_DETECT_PA"};
// Action names LinuxJoystick understands; anything else leaves the button unmapped.
const std::set<std::string> kJoystickActions = {"select", "cancel", "back", "up", "down", "left", "right", "user", "userpress"};
const std::map<std::string, std::set<std::string>> &schema()
{
static const std::map<std::string, std::set<std::string>> s = {
{"Lora",
{"Module",
"gpiochip",
"spidev",
"spiSpeed",
"DIO2_AS_RF_SWITCH",
"DIO3_TCXO_VOLTAGE",
"TCXO_OPTIONAL",
"Enable_Pins",
"rfswitch_table",
"IRQ_DIO_NUM",
"LR2021_IRQ_DIO_NUM",
"LR1110_MAX_POWER",
"LR1120_MAX_POWER",
"LR2021_MAX_POWER",
"LR2021_MAX_POWER_HF",
"RF95_MAX_POWER",
"SX126X_MAX_POWER",
"SX128X_MAX_POWER",
"TX_GAIN_LORA",
"USB_PID",
"USB_VID",
"USB_Serialnum",
"CS",
"IRQ",
"Busy",
"Reset",
"TXen",
"RXen",
"SX126X_ANT_SW",
"GPIO_DETECT_PA"}},
{"General",
{"MACAddress", "MACAddressSource", "MaxNodes", "MaxMessageQueue", "APIPort", "ConfigDirectory", "AvailableDirectory"}},
{"Config", {"DisplayMode", "EnableUDP", "StatusMessage"}},
{"Display",
{"Panel", "spidev", "BusFrequency", "Width", "Height", "Invert", "Rotate", "OffsetX", "OffsetY", "OffsetRotate",
"RGBOrder", "HUB75", "DC", "CS", "Backlight", "BacklightInvert", "BacklightPWMChannel", "Reset"}},
{"Touchscreen", {"Module", "spidev", "BusFrequency", "I2CAddr", "Rotate", "CS", "IRQ"}},
{"Input",
{"KeyboardDevice", "PointerDevice", "JoystickDevice", "JoystickButtons", "TrackballDirection", "User", "TrackballUp",
"TrackballDown", "TrackballLeft", "TrackballRight", "TrackballPress"}},
{"GPIO", {"User", "ExtraPins"}},
{"GPS", {"SerialPath", "GpsdHost", "GpsdPort"}},
{"I2C", {"I2CDevice"}},
{"Logging", {"LogLevel", "TraceFile", "JSONFile", "JSONFileRotate", "JSONFilter", "AsciiLogs"}},
{"Webserver", {"Port", "RootPath", "SSLCert", "SSLKey"}},
{"Bluetooth", {"Enabled", "AdapterId"}},
{"HostMetrics", {"ReportInterval", "Channel", "UserStringCommand"}},
// Read by packaging/menu tooling rather than by meshtasticd itself.
{"Meta", {}},
};
return s;
}
// Sections whose contents meshtasticd never reads, so unknown keys inside them are
// not worth reporting.
const std::set<std::string> kFreeFormSections = {"Meta"};
const std::set<std::string> kPinSubKeys = {"pin", "gpiochip", "line"};
const std::set<std::string> kHub75Keys = {
"HardwareMapping", "Rows", "Cols", "ChainLength", "Parallel", "PWMBits",
"PWMLSBNanoseconds", "Brightness", "ScanMode", "RowAddressType", "Multiplexing", "DisableHardwarePulsing",
"ShowRefreshRate", "InverseColors", "RGBSequence", "PixelMapper", "PanelType", "LimitRefreshRateHz",
"GPIOSlowdown"};
// Names the radio a finding is judged against; defined with the merged-config checks.
std::string moduleName();
// Union of every family's mode names: membership means the name is spelled correctly.
// Whether this radio can act on it is a separate, module-aware question - see modesFor().
// Function-local static, not a namespace-scope global: kRfSwitchModeNames lives in another TU,
// and lazy first-use init sidesteps any cross-TU static-initialization-order question.
const std::set<std::string> &kRfSwitchModes()
{
static const std::set<std::string> s = [] {
std::set<std::string> s;
for (int m = 0; m < RFSW_MODE_COUNT; m++)
s.insert(kRfSwitchModeNames[m].name);
return s;
}();
return s;
}
// Likewise the union of both families' switch-capable DIOs.
const std::set<std::string> kRfSwitchPins = {"DIO5", "DIO6", "DIO7", "DIO8", "DIO9", "DIO10", "DIO11"};
// RadioLib's default when nothing sets LR2021::irqDioNum. Mirrored rather than included, to
// keep the radio headers out of this file.
const int kLr20x0DefaultIrqDio = 5;
// Mode names this module can apply, empty if it never applies a table at all - there is then
// nothing module-specific to say about the rows, only that the whole table is inert.
// An unresolved "auto" is reported against the union of both families' modes, since narrowing
// to either subset would flag the other's valid modes.
std::set<std::string> modesFor(lora_module_enum module)
{
if (module == use_autoconf)
return kRfSwitchModes();
std::set<std::string> s;
if (!moduleUsesRfSwitchTable(module))
return s;
if (module == use_lr2021) {
for (int m = 0; m < RFSW_MODE_COUNT; m++)
if (m != RFSW_TX_HP && m != RFSW_GNSS && m != RFSW_WIFI)
s.insert(kRfSwitchModeNames[m].name);
} else {
for (int m = 0; m < RFSW_MODE_COUNT; m++)
if (m != RFSW_RX_HF)
s.insert(kRfSwitchModeNames[m].name);
}
return s;
}
std::set<std::string> pinsFor(lora_module_enum module)
{
std::set<std::string> s;
size_t count = 0;
const int8_t *dios = rfSwitchDiosFor(module, &count);
for (size_t i = 0; i < count; i++)
s.insert("DIO" + std::to_string(dios[i]));
return s;
}
// Families whose driver can probe for a TCXO. RF95, SX128x and the simulated radio have no
// TCXO reference, so Lora.TCXO_OPTIONAL is inert on those rather than merely unnecessary.
bool moduleSupportsTcxoProbe(lora_module_enum module)
{
switch (module) {
case use_sx1262:
case use_sx1268:
case use_llcc68:
case use_lr1110:
case use_lr1120:
case use_lr1121:
case use_lr2021:
return true;
default:
return false;
}
}
std::string joinNames(const std::set<std::string> &names)
{
std::string out;
for (const auto &name : names)
out += (out.empty() ? "" : ", ") + name;
return out;
}
// Reverse index: key name -> sections it is valid in. Powers the "you probably meant
// to nest this under X" hint that turns a silent no-op into an actionable message.
const std::map<std::string, std::set<std::string>> &keyOwners()
{
static const std::map<std::string, std::set<std::string>> owners = [] {
std::map<std::string, std::set<std::string>> m;
for (const auto &section : schema())
for (const auto &key : section.second)
m[key].insert(section.first);
// rfswitch_table's sub-keys are worth hinting on too: a table indented one
// level too far leaves MODE_* rows stranded directly under Lora.
for (const auto &mode : kRfSwitchModes())
m[mode].insert("Lora.rfswitch_table");
m["pins"].insert("Lora.rfswitch_table");
return m;
}();
return owners;
}
// ---------------------------------------------------------------------------
// Findings
// ---------------------------------------------------------------------------
enum Level { kInfo, kWarn, kError };
struct Finding {
Level level;
std::string file;
int line; // 1-based; 0 when the finding is not tied to a line
std::string message;
};
const char *levelName(Level l)
{
switch (l) {
case kError:
return "ERROR";
case kWarn:
return "WARN ";
default:
return "INFO ";
}
}
std::string joinSections(const std::set<std::string> &s)
{
std::string out;
for (const auto &item : s) {
if (!out.empty())
out += " or ";
out += item;
}
return out;
}
int lineOf(const YAML::Node &node)
{
const YAML::Mark mark = node.Mark();
return mark.is_null() ? 0 : mark.line + 1;
}
// ---------------------------------------------------------------------------
// Duplicate key detection
// ---------------------------------------------------------------------------
// yaml-cpp silently keeps the FIRST of duplicate keys, so a later override looks applied
// but is not. The Node API sees an already-collapsed map, so walk the parser events.
class DuplicateKeyFinder : public YAML::EventHandler
{
public:
struct Duplicate {
std::string path;
int line;
int firstLine;
};
std::vector<Duplicate> duplicates;
void OnDocumentStart(const YAML::Mark &) override {}
void OnDocumentEnd() override {}
void OnNull(const YAML::Mark &, YAML::anchor_t) override { advance(); }
void OnAlias(const YAML::Mark &, YAML::anchor_t) override { advance(); }
void OnAnchor(const YAML::Mark &, const std::string &) override {}
void OnScalar(const YAML::Mark &mark, const std::string &, YAML::anchor_t, const std::string &value) override
{
if (!stack.empty() && stack.back().isMap && stack.back().expectKey) {
auto &seen = stack.back().seen;
const auto existing = seen.find(value);
if (existing != seen.end())
duplicates.push_back({pathTo(value), static_cast<int>(mark.line) + 1, existing->second});
else
seen.emplace(value, static_cast<int>(mark.line) + 1);
stack.back().key = value;
}
advance();
}
void OnSequenceStart(const YAML::Mark &, const std::string &, YAML::anchor_t, YAML::EmitterStyle::value) override
{
push(false);
}
void OnSequenceEnd() override { pop(); }
void OnMapStart(const YAML::Mark &, const std::string &, YAML::anchor_t, YAML::EmitterStyle::value) override { push(true); }
void OnMapEnd() override { pop(); }
private:
struct Context {
bool isMap;
bool expectKey;
std::string key;
std::map<std::string, int> seen;
};
std::vector<Context> stack;
// Inside a mapping the parser alternates key, value, key, value...
void advance()
{
if (!stack.empty() && stack.back().isMap)
stack.back().expectKey = !stack.back().expectKey;
}
void push(bool isMap)
{
advance(); // the collection itself occupies a slot in its parent
stack.push_back(Context{isMap, true, "", {}});
}
// No advance(): push() already consumed the collection's slot in the parent. Guarded
// because balance rests on yaml-cpp emitting matched start/end events -- its invariant.
void pop()
{
if (!stack.empty())
stack.pop_back();
}
std::string pathTo(const std::string &leaf) const
{
std::string path;
for (size_t i = 0; i + 1 < stack.size(); i++)
if (stack[i].isMap && !stack[i].key.empty())
path += stack[i].key + ".";
return path + leaf;
}
};
void checkDuplicateKeys(const std::string &file, std::vector<Finding> &findings)
{
std::ifstream stream(file);
if (!stream)
return;
DuplicateKeyFinder finder;
try {
YAML::Parser parser(stream);
while (parser.HandleNextDocument(finder)) {
}
} catch (const std::exception &) {
return; // reported separately by checkFile()
}
for (const auto &dup : finder.duplicates)
findings.push_back({kError, file, dup.line,
"duplicate key '" + dup.path + "' (first defined on line " + std::to_string(dup.firstLine) +
"). yaml-cpp keeps the FIRST occurrence, so this one is silently discarded"});
}
// ---------------------------------------------------------------------------
// Structural checks
// ---------------------------------------------------------------------------
void checkPinNode(const std::string &file, const std::string &path, const YAML::Node &node, std::vector<Finding> &findings)
{
if (!node.IsMap())
return; // plain scalar pin number, always fine
for (const auto &entry : node) {
const std::string key = entry.first.as<std::string>("");
if (!kPinSubKeys.count(key))
findings.push_back({kError, file, lineOf(entry.first),
"unknown key '" + path + "." + key + "'. A pin mapping accepts only pin, gpiochip and line"});
}
}
// Keyed by action, not by button, so one action can list several codes and have every one of
// those buttons drive it. The value is a single evdev code or a list of them.
void checkJoystickButtons(const std::string &file, const YAML::Node &node, std::vector<Finding> &findings)
{
if (!node.IsMap()) {
findings.push_back(
{kError, file, lineOf(node), "Input.JoystickButtons must be a mapping of action name to evdev button code"});
return;
}
std::map<int, std::string> owner; // code -> the action that claimed it first
for (const auto &entry : node) {
std::string action = entry.first.as<std::string>("");
for (auto &c : action)
c = tolower(c);
if (!kJoystickActions.count(action)) {
findings.push_back({kWarn, file, lineOf(entry.first),
"Input.JoystickButtons: '" + action +
"' is not a recognised action, so those buttons do nothing. Valid actions are select, "
"cancel, back, up, down, left, right and user"});
continue;
}
std::vector<YAML::Node> codeNodes;
if (entry.second.IsSequence())
for (const auto &codeNode : entry.second)
codeNodes.push_back(codeNode);
else
codeNodes.push_back(entry.second);
for (const auto &codeNode : codeNodes) {
const std::string raw = codeNode.as<std::string>("");
int code = 0;
try {
code = std::stoi(raw, nullptr, 0);
} catch (const std::exception &) {
code = 0;
}
if (code == 0) {
findings.push_back({kWarn, file, lineOf(codeNode),
"Input.JoystickButtons." + action + ": '" + raw +
"' is not an evdev button code (hex like 0x121, or decimal), so it is unmapped"});
continue;
}
// One button cannot do two things: the later action silently replaces the earlier one.
const auto claimed = owner.find(code);
if (claimed != owner.end() && claimed->second != action)
findings.push_back({kWarn, file, lineOf(codeNode),
"Input.JoystickButtons: button " + raw + " is mapped to both '" + claimed->second +
"' and '" + action + "'. Only '" + action + "' takes effect"});
owner[code] = action;
}
}
}
void checkRfSwitchTable(const std::string &file, const YAML::Node &table, std::vector<Finding> &findings)
{
if (!table.IsMap()) {
findings.push_back({kError, file, lineOf(table), "Lora.rfswitch_table must be a mapping"});
return;
}
size_t pinCount = 0;
if (const YAML::Node pins = table["pins"]) {
if (!pins.IsSequence()) {
findings.push_back({kError, file, lineOf(pins), "Lora.rfswitch_table.pins must be a list"});
} else {
pinCount = pins.size();
const std::set<std::string> valid = pinsFor(portduino_config.lora_module);
for (const auto &pin : pins) {
const std::string name = pin.as<std::string>("");
if (!kRfSwitchPins.count(name)) {
findings.push_back({kError, file, lineOf(pin),
"Lora.rfswitch_table.pins: '" + name + "' is not a recognised pin. Valid values are " +
joinNames(kRfSwitchPins)});
} else if (!valid.empty() && !valid.count(name)) {
// Spelled correctly, but not a switch control here: the slot is left
// unconnected and every level in its column dropped.
findings.push_back({kError, file, lineOf(pin),
"Lora.rfswitch_table.pins: '" + name + "' is not an RF switch pin on " + moduleName() +
", so that column is never driven. It has " + joinNames(valid)});
}
}
if (pinCount > 5)
findings.push_back(
{kError, file, lineOf(pins),
"Lora.rfswitch_table.pins lists " + std::to_string(pinCount) + " pins but only the first 5 are read"});
}
} else {
findings.push_back({kError, file, lineOf(table), "Lora.rfswitch_table has no 'pins' list, so no switch pins are driven"});
}
const std::set<std::string> validModes = modesFor(portduino_config.lora_module);
for (const auto &entry : table) {
const std::string key = entry.first.as<std::string>("");
if (key == "pins")
continue;
if (!kRfSwitchModes().count(key)) {
findings.push_back({kError, file, lineOf(entry.first), "unknown key 'Lora.rfswitch_table." + key + "'"});
continue;
}
// A real mode name, but not one this part has, so the row is never applied. Empty means
// the module applies no table, and singling out one row would imply the rest are used.
if (!validModes.empty() && !validModes.count(key))
findings.push_back({kWarn, file, lineOf(entry.first),
"Lora.rfswitch_table." + key + " is not a mode " + moduleName() + " has, so the row is ignored"});
const YAML::Node &row = entry.second;
if (!row.IsSequence()) {
findings.push_back({kError, file, lineOf(row), "Lora.rfswitch_table." + key + " must be a list"});
continue;
}
if (pinCount && row.size() != pinCount)
findings.push_back({kError, file, lineOf(row),
"Lora.rfswitch_table." + key + " has " + std::to_string(row.size()) + " values but " +
std::to_string(pinCount) + " pins are declared"});
for (const auto &value : row) {
const std::string level = value.as<std::string>("");
if (level != "HIGH" && level != "LOW")
findings.push_back({kError, file, lineOf(value),
"Lora.rfswitch_table." + key + ": '" + level +
"' is not HIGH or LOW. Anything that is not exactly \"HIGH\" is treated as LOW"});
}
}
// Every mode absent from the table defaults to all-LOW, which for most modules is
// the shutdown state. Worth saying out loud rather than leaving to be discovered.
// Only worth saying once the radio is known: under "auto" the union would advise adding
// rows for modes the part turns out not to have, and a module that applies no table needs
// no rows at all. Either way the advice would be wrong, so say nothing.
if (validModes.empty() || portduino_config.lora_module == use_autoconf)
return;
std::set<std::string> missing;
for (const auto &mode : validModes)
if (!table[mode])
missing.insert(mode);
if (!missing.empty())
findings.push_back({kInfo, file, lineOf(table),
"Lora.rfswitch_table omits " + joinNames(missing) + "; those modes default to all pins LOW"});
}
// ---------------------------------------------------------------------------
// Value types
// ---------------------------------------------------------------------------
// A wrong-typed value silently does nothing under .as<T>(default), but the two reads with
// NO default (Logging.AsciiLogs, TX_GAIN_LORA) throw and stop meshtasticd. Conversions are
// tested by asking yaml-cpp, so this cannot drift from what loadConfig() accepts.
enum ValueType { kBool, kInt, kFloat, kString, kIntList, kBoolOrFloat, kIntOrString };
struct ValueSpec {
ValueType type;
bool fatal; // read without a default: a bad value stops meshtasticd outright
};
const std::map<std::string, ValueSpec> &valueSpecs()
{
static const std::map<std::string, ValueSpec> s = {
{"Lora.spiSpeed", {kInt, false}},
{"Lora.gpiochip", {kInt, false}},
{"Lora.spidev", {kString, false}},
{"Lora.DIO2_AS_RF_SWITCH", {kBool, false}},
// Accepts a float (volts) or `true` (meaning 1.8V), so both are allowed here.
{"Lora.DIO3_TCXO_VOLTAGE", {kBoolOrFloat, false}},
{"Lora.TCXO_OPTIONAL", {kBool, false}},
{"Lora.LR1110_MAX_POWER", {kInt, false}},
{"Lora.LR1120_MAX_POWER", {kInt, false}},
{"Lora.LR2021_MAX_POWER", {kInt, false}},
{"Lora.LR2021_MAX_POWER_HF", {kInt, false}},
{"Lora.IRQ_DIO_NUM", {kInt, false}},
{"Lora.LR2021_IRQ_DIO_NUM", {kInt, false}},
{"Lora.RF95_MAX_POWER", {kInt, false}},
{"Lora.SX126X_MAX_POWER", {kInt, false}},
{"Lora.SX128X_MAX_POWER", {kInt, false}},
// TX_GAIN_LORA is not in this table: it accepts a list OR a bare scalar, and
// only the list path is fatal. See checkTxGain().
{"Lora.USB_PID", {kInt, false}},
{"Lora.USB_VID", {kInt, false}},
{"Lora.USB_Serialnum", {kString, false}},
{"General.MaxNodes", {kInt, false}},
{"General.MaxMessageQueue", {kInt, false}},
{"General.APIPort", {kInt, false}},
{"General.ConfigDirectory", {kString, false}},
{"General.AvailableDirectory", {kString, false}},
{"Config.DisplayMode", {kString, false}},
{"Config.EnableUDP", {kBool, false}},
{"Config.StatusMessage", {kString, false}},
{"Display.Panel", {kString, false}},
{"Display.spidev", {kString, false}},
{"Display.BusFrequency", {kInt, false}},
{"Display.Width", {kInt, false}},
{"Display.Height", {kInt, false}},
{"Display.Invert", {kBool, false}},
{"Display.Rotate", {kInt, false}},
{"Display.OffsetX", {kInt, false}},
{"Display.OffsetY", {kInt, false}},
{"Display.OffsetRotate", {kInt, false}},
{"Display.RGBOrder", {kBool, false}},
{"Display.BacklightInvert", {kBool, false}},
{"Touchscreen.Module", {kString, false}},
{"Touchscreen.spidev", {kString, false}},
{"Touchscreen.BusFrequency", {kInt, false}},
{"Touchscreen.I2CAddr", {kInt, false}},
{"Touchscreen.Rotate", {kBool, false}},
{"Input.KeyboardDevice", {kString, false}},
{"Input.PointerDevice", {kString, false}},
{"Input.JoystickDevice", {kString, false}},
{"Input.TrackballDirection", {kString, false}},
{"GPS.SerialPath", {kString, false}},
{"GPS.GpsdHost", {kString, false}},
{"GPS.GpsdPort", {kInt, false}},
{"I2C.I2CDevice", {kString, false}},
{"Logging.LogLevel", {kString, false}},
{"Logging.TraceFile", {kString, false}},
{"Logging.JSONFile", {kString, false}},
{"Logging.JSONFileRotate", {kInt, false}},
// Read as an int and as a string (the "textmessage"-style aliases).
{"Logging.JSONFilter", {kIntOrString, false}},
{"Logging.AsciiLogs", {kBool, true}},
{"Webserver.Port", {kInt, false}},
{"Webserver.RootPath", {kString, false}},
{"Webserver.SSLCert", {kString, false}},
{"Webserver.SSLKey", {kString, false}},
{"Bluetooth.Enabled", {kBool, false}},
{"Bluetooth.AdapterId", {kString, false}},
{"HostMetrics.ReportInterval", {kInt, false}},
{"HostMetrics.Channel", {kInt, false}},
{"HostMetrics.UserStringCommand", {kString, false}},
{"Display.HUB75.Rows", {kInt, false}},
{"Display.HUB75.Cols", {kInt, false}},
{"Display.HUB75.ChainLength", {kInt, false}},
{"Display.HUB75.Parallel", {kInt, false}},
{"Display.HUB75.PWMBits", {kInt, false}},
{"Display.HUB75.PWMLSBNanoseconds", {kInt, false}},
{"Display.HUB75.Brightness", {kInt, false}},
{"Display.HUB75.ScanMode", {kInt, false}},
{"Display.HUB75.RowAddressType", {kInt, false}},
{"Display.HUB75.Multiplexing", {kInt, false}},
{"Display.HUB75.GPIOSlowdown", {kInt, false}},
{"Display.HUB75.LimitRefreshRateHz", {kInt, false}},
{"Display.HUB75.DisableHardwarePulsing", {kBool, false}},
{"Display.HUB75.ShowRefreshRate", {kBool, false}},
{"Display.HUB75.InverseColors", {kBool, false}},
{"Display.HUB75.HardwareMapping", {kString, false}},
{"Display.HUB75.RGBSequence", {kString, false}},
{"Display.HUB75.PixelMapper", {kString, false}},
{"Display.HUB75.PanelType", {kString, false}},
};
return s;
}
const char *typeName(ValueType type)
{
switch (type) {
case kBool:
return "a true/false value";
case kInt:
return "a whole number";
case kFloat:
return "a number";
case kIntList:
return "a list of whole numbers";
case kBoolOrFloat:
return "a number or true/false";
case kIntOrString:
return "a whole number or a name";
default:
return "a text value";
}
}
// Ask yaml-cpp to perform the same conversion loadConfig() will.
bool converts(const YAML::Node &node, ValueType type)
{
try {
switch (type) {
case kBool:
node.as<bool>();
return true;
case kInt:
node.as<int>();
return true;
case kFloat:
node.as<float>();
return true;
case kBoolOrFloat:
try {
node.as<float>();
return true;
} catch (const std::exception &) {
node.as<bool>();
return true;
}
case kIntOrString:
try {
node.as<int>();
return true;
} catch (const std::exception &) {
node.as<std::string>();
return true;
}
case kIntList:
if (!node.IsSequence())
return false;
for (const auto &item : node)
item.as<int>();
return true;
default:
node.as<std::string>();
return true;
}
} catch (const std::exception &) {
return false;
}
}
void checkValueType(const std::string &file, const std::string &path, const YAML::Node &value, std::vector<Finding> &findings)
{
const auto spec = valueSpecs().find(path);
if (spec == valueSpecs().end() || converts(value, spec->second.type))
return;
if (spec->second.fatal)
findings.push_back({kError, file, lineOf(value),
path + " is not " + typeName(spec->second.type) +
". This one is read without a fallback, so the conversion throws and meshtasticd "
"refuses to start on this file"});
else
findings.push_back({kWarn, file, lineOf(value),
path + " is not " + typeName(spec->second.type) +
", so it is silently replaced by the default and the setting does nothing"});
}
// The spelling the earlier LR2021 branches used, read only when IRQ_DIO_NUM is absent. Held as a
// constant so no comparison puts the name next to a variable called `key`, which reads to secret
// scanners as an assignment of a credential.
const char kLegacyIrqDioName[] = "LR2021_IRQ_DIO_NUM";
bool isIrqDioName(const std::string &name)
{
return name == "IRQ_DIO_NUM" || name == kLegacyIrqDioName;
}
// Out of range is discarded when the config is read, so the merged view cannot tell a typo from
// an absent key. Judged here, per file, where the offending line is still known.
void checkIrqDioNum(const std::string &file, const std::string &key, const YAML::Node &value, std::vector<Finding> &findings)
{
if (!converts(value, kInt))
return; // checkValueType() already reported it
const int dio = value.as<int>();
if (dio < kLr20x0IrqDioMin || dio > kLr20x0IrqDioMax)
findings.push_back({kError, file, lineOf(value),
"Lora." + key + " is " + std::to_string(dio) + ", outside DIO" + std::to_string(kLr20x0IrqDioMin) +
"-DIO" + std::to_string(kLr20x0IrqDioMax) +
". It is ignored, and the radio raises its interrupt on DIO" +
std::to_string(kLr20x0DefaultIrqDio) + " instead"});
}
// The PA gain table's two shapes fail differently: a bad list entry stops meshtasticd (no
// default), a bad scalar falls back to 0. Backed by uint16_t[22], so extras drop and values wrap.
void checkTxGain(const std::string &file, const YAML::Node &node, std::vector<Finding> &findings)
{
constexpr size_t kMaxPaPoints = 22;
if (node.IsSequence()) {
if (node.size() > kMaxPaPoints)
findings.push_back({kWarn, file, lineOf(node),
"Lora.TX_GAIN_LORA lists " + std::to_string(node.size()) + " points but only the first " +
std::to_string(kMaxPaPoints) + " are stored; the rest are dropped"});
for (const auto &point : node) {
if (!converts(point, kInt)) {
findings.push_back({kError, file, lineOf(point),
"Lora.TX_GAIN_LORA entry '" + point.as<std::string>("") +
"' is not a whole number. List entries are read without a fallback, so this "
"throws and meshtasticd refuses to start on this file"});
continue;
}
const int value = point.as<int>();
// Stored into a uint16_t, so anything outside the range silently wraps.
if (value < 0 || value > 65535)
findings.push_back({kError, file, lineOf(point),
"Lora.TX_GAIN_LORA entry " + std::to_string(value) +
" does not fit the 0-65535 range it is stored in, so it wraps to a different "
"gain than the one written"});
}
return;
}
if (node.IsMap()) {
findings.push_back({kError, file, lineOf(node), "Lora.TX_GAIN_LORA must be a list of gain points, or a single number"});
return;
}
if (!converts(node, kInt))
findings.push_back({kWarn, file, lineOf(node),
"Lora.TX_GAIN_LORA is not a whole number, so it is silently read as 0 and no PA gain is applied"});
}
// Module names match exactly and are inconsistently cased (RF95 upper, sx1262 lower),
// and loadConfig() exits on an unknown name without printing the valid set, so name it here.
void checkLoraModule(const std::string &file, const YAML::Node &module, std::vector<Finding> &findings)
{
const std::string name = module.as<std::string>("");
if (name.empty())
return;
std::string valid;
std::string caseHint;
for (const auto &known : portduino_config.loraModules) {
if (name == known.second)
return;
valid += (valid.empty() ? "" : ", ") + known.second;
if (caseHint.empty() && name.size() == known.second.size() &&
std::equal(name.begin(), name.end(), known.second.begin(), [](char a, char b) {
// Cast first: tolower() on a negative char is undefined, and a stray
// non-ASCII byte in a hand-edited config is exactly how that happens.
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
}))
caseHint = known.second;
}
std::string message =
"Lora.Module '" + name + "' is not a module meshtasticd knows, and it refuses to start. Valid: " + valid;
if (!caseHint.empty())
message += ". The name is matched exactly -- did you mean '" + caseHint + "'?";
findings.push_back({kError, file, lineOf(module), message});
}
// loadConfig() rejects a bad MACAddress or MACAddressSource silently, falling through to the
// BlueZ and LoRa-serial fallbacks; if those yield nothing, meshtasticd exits on a blank MAC.
void checkMacAddress(const std::string &file, const YAML::Node &general, std::vector<Finding> &findings)
{
const YAML::Node address = general["MACAddress"];
const YAML::Node source = general["MACAddressSource"];
const std::string addressText = address ? address.as<std::string>("") : "";
const std::string sourceText = source ? source.as<std::string>("") : "";
if (!addressText.empty() && !sourceText.empty()) {
findings.push_back({kError, file, lineOf(source),
"General.MACAddress and General.MACAddressSource are both set. meshtasticd refuses to start "
"with both; keep whichever one you want the MAC to come from"});
return;
}
if (!addressText.empty()) {
std::string digits;
for (const char c : addressText)
if (c != ':' && c != '-')
digits += c;
const bool hex = digits.find_first_not_of("0123456789abcdefABCDEF") == std::string::npos;
// loadConfig() strips the colons and then requires more than 11 characters;
// anything shorter is dropped without a word and the MAC comes from elsewhere.
if (digits.size() < 12 || !hex)
findings.push_back({kError, file, lineOf(address),
"General.MACAddress '" + addressText +
"' is not 12 hex digits, so it is ignored and the MAC falls back to the Bluetooth "
"adapter or the LoRa device serial. If neither yields one, meshtasticd exits with "
"'Blank MAC Address not allowed!'"});
}
if (!sourceText.empty()) {
const std::string path = "/sys/class/net/" + sourceText + "/address";
std::ifstream probe(path);
if (!probe.good())
findings.push_back({kWarn, file, lineOf(source),
"General.MACAddressSource '" + sourceText + "' has no " + path +
" on this machine, so the MAC reads back empty and meshtasticd asks you to set one. "
"Expected if you are checking this config on a different host"});
}
}
void checkSection(const std::string &file, const std::string &section, const YAML::Node &body, std::vector<Finding> &findings)
{
const auto &allowed = schema().at(section);
if (kFreeFormSections.count(section))
return;
// An empty section (`Lora:` with no body) is null, not a map, and loadConfig() tests
// before reading it. Any other non-map shape is unreadable, so do not call the file clean.
if (body.IsNull())
return;
if (!body.IsMap()) {
findings.push_back({kError, file, lineOf(body), "'" + section + "' is not a mapping, so nothing in it is read"});
return;
}
for (const auto &entry : body) {
const std::string key = entry.first.as<std::string>("");
const YAML::Node &value = entry.second;
if (!allowed.count(key)) {
std::string message = "unknown key '" + section + "." + key + "', ignored by meshtasticd";
const auto owner = keyOwners().find(key);
if (owner != keyOwners().end() && !owner->second.count(section))
message += ". It is a valid key of " + joinSections(owner->second);
findings.push_back({kWarn, file, lineOf(entry.first), message});
continue;
}
checkValueType(file, section + "." + key, value, findings);
if (section == "Lora" && key == "rfswitch_table") {
checkRfSwitchTable(file, value, findings);
} else if (section == "Lora" && key == "Module") {
checkLoraModule(file, value, findings);
} else if (section == "Lora" && key == "TX_GAIN_LORA") {
checkTxGain(file, value, findings);
} else if (section == "Lora" && isIrqDioName(key)) {
checkIrqDioNum(file, key, value, findings);
if (key == kLegacyIrqDioName && body["IRQ_DIO_NUM"])
findings.push_back({kWarn, file, lineOf(entry.first),
"Lora.LR2021_IRQ_DIO_NUM is the older spelling of Lora.IRQ_DIO_NUM and is only read "
"when that key is absent, so this line does nothing"});
} else if (section == "Display" && key == "HUB75") {
if (value.IsMap())
for (const auto &hub : value) {
const std::string hubKey = hub.first.as<std::string>("");
if (!kHub75Keys.count(hubKey))
findings.push_back(
{kWarn, file, lineOf(hub.first), "unknown key 'Display.HUB75." + hubKey + "', ignored"});
else
checkValueType(file, "Display.HUB75." + hubKey, hub.second, findings);
}
} else if (key == "Enable_Pins" || key == "ExtraPins") {
if (value.IsSequence())
for (const auto &pin : value)
checkPinNode(file, section + "." + key, pin, findings);
} else if (section == "Bluetooth" && key == "AdapterId") {
// LinuxBluetooth uses this verbatim as the BlueZ object path (/org/bluez/<id>), while the
// MAC fallback only reads the leading hciN. A value like "hci1junk" therefore looks
// plausible, yields a MAC, and then finds no adapter -- BLE just never comes up. Only
// hci<digits> is a real adapter name.
const std::string adapter = value.as<std::string>("");
const bool wellFormed = adapter.rfind("hci", 0) == 0 && adapter.size() > 3 &&
adapter.find_first_not_of("0123456789", 3) == std::string::npos;
if (!wellFormed)
findings.push_back({kWarn, file, lineOf(value),
"Bluetooth.AdapterId '" + adapter +
"' is not a BlueZ adapter name. It must be hci followed by digits (hci0, hci1); "
"anything else leaves no /org/bluez entry to attach to and Bluetooth stays off"});
} else if (key == "JoystickButtons") {
checkJoystickButtons(file, value, findings);
} else if ((section == "Lora" && kLoraPinKeys.count(key)) ||
(section == "Display" &&
(key == "DC" || key == "CS" || key == "Backlight" || key == "BacklightPWMChannel" || key == "Reset")) ||
(section == "Touchscreen" && (key == "CS" || key == "IRQ")) ||
(section == "Input" && (key == "User" || key.rfind("Trackball", 0) == 0)) ||
(section == "GPIO" && key == "User")) {
checkPinNode(file, section + "." + key, value, findings);
}
}
}
// ---------------------------------------------------------------------------
// Cross-file overlap
// ---------------------------------------------------------------------------
// Every .yaml loads into the same portduino_config, so two files setting the same key
// are not merged: the one loaded LAST wins -- the opposite of the within-file rule.
// path (below the top-level section) -> file -> line of its first appearance there
using PathIndex = std::map<std::string, std::map<std::string, int>>;
void collectPaths(const std::string &file, const YAML::Node &node, const std::string &prefix, int depth, PathIndex &index)
{
if (!node.IsMap())
return;
for (const auto &entry : node) {
const std::string key = entry.first.as<std::string>("");
const std::string path = prefix.empty() ? key : prefix + "." + key;
// depth 0 is the top-level section; several files each having a "Lora:" is
// normal, so only record what lives inside a section.
if (depth >= 1)
index[path].emplace(file, lineOf(entry.first));
collectPaths(file, entry.second, path, depth + 1, index);
}
}
void checkFile(const std::string &file, std::vector<Finding> &findings, PathIndex &paths,
std::map<std::string, std::vector<std::string>> &sectionOwners)
{
YAML::Node doc;
try {
doc = YAML::LoadFile(file);
} catch (const std::exception &e) {
findings.push_back({kError, file, 0, std::string("could not be parsed, so it is being ignored entirely: ") + e.what()});
return;
}
if (doc.IsNull()) {
findings.push_back({kWarn, file, 0, "is empty"});
return;
}
if (!doc.IsMap()) {
findings.push_back({kError, file, 0, "top level is not a mapping, so nothing in it is read"});
return;
}
checkDuplicateKeys(file, findings);
collectPaths(file, doc, "", 0, paths);
for (const auto &entry : doc) {
const std::string sectionName = entry.first.as<std::string>("");
// A file that repeats a section still counts once here; the repeat itself is
// reported separately as a duplicate key.
auto &owners = sectionOwners[sectionName];
if (schema().count(sectionName) && (owners.empty() || owners.back() != file))
owners.push_back(file);
}
for (const auto &entry : doc) {
const std::string section = entry.first.as<std::string>("");
if (schema().count(section)) {
checkSection(file, section, entry.second, findings);
if (section == "General" && entry.second.IsMap())
checkMacAddress(file, entry.second, findings);
continue;
}
std::string message = "unknown top-level section '" + section + "', ignored by meshtasticd";
const auto owner = keyOwners().find(section);
if (owner != keyOwners().end())
message += ". '" + section + "' is a key of " + joinSections(owner->second) +
" -- indent it one level so it sits "
"inside that section";
findings.push_back({kError, file, lineOf(entry.first), message});
}
}
std::string describeOwners(const std::map<std::string, int> &owners)
{
std::string out;
for (const auto &owner : owners) {
if (!out.empty())
out += ", ";
out += owner.first;
if (owner.second)
out += " line " + std::to_string(owner.second);
}
return out;
}
void checkCrossFileOverlap(const PathIndex &paths, const std::map<std::string, std::vector<std::string>> &sectionOwners,
std::vector<Finding> &findings)
{
const std::string across = "(across configuration files)";
for (const auto &entry : paths) {
if (entry.second.size() < 2)
continue;
// If an ancestor also collides then the whole subtree is replaced together;
// reporting the parent once is clearer than repeating every leaf under it.
bool coveredByAncestor = false;
std::string ancestor = entry.first;
for (size_t dot = ancestor.rfind('.'); dot != std::string::npos; dot = ancestor.rfind('.')) {
ancestor.resize(dot);
const auto found = paths.find(ancestor);
if (found != paths.end() && found->second.size() >= 2) {
coveredByAncestor = true;
break;
}
}
if (coveredByAncestor)
continue;
findings.push_back({kInfo, across, 0,
"'" + entry.first + "' is set in " + std::to_string(entry.second.size()) + " files (" +
describeOwners(entry.second) + "). The file loaded last wins"});
}
// Sections do not merge, and these keys are assigned unconditionally with a default, so a
// later file with a Lora section that omits them silently resets them.
const auto loraOwners = sectionOwners.find("Lora");
if (loraOwners != sectionOwners.end() && loraOwners->second.size() > 1) {
std::string files;
for (const auto &file : loraOwners->second)
files += (files.empty() ? "" : ", ") + file;
findings.push_back(
{kWarn, across, 0,
std::to_string(loraOwners->second.size()) + " files define a 'Lora:' section (" + files +
"). These keys are re-read with a default every time a Lora section is seen, so any of them not repeated "
"in the last file loaded is reset: spidev, spiSpeed, gpiochip, DIO2_AS_RF_SWITCH, DIO3_TCXO_VOLTAGE, "
"USB_PID, USB_VID, USB_Serialnum. Normally exactly one Lora config should be enabled at a time"});
}
}
// ---------------------------------------------------------------------------
// Semantic checks against the merged configuration
// ---------------------------------------------------------------------------
std::string moduleName()
{
const auto it = portduino_config.loraModules.find(portduino_config.lora_module);
return it == portduino_config.loraModules.end() ? "unknown" : it->second;
}
// A pin whose value will not convert falls back to RADIOLIB_NC (-1) while still marked enabled,
// and initGPIOPin() then trips an assertion inside LinuxGPIOPin rather than failing cleanly.
void checkPinValues(std::vector<Finding> &findings)
{
const std::string merged = "(merged configuration)";
auto report = [&](const std::string &name) {
findings.push_back({kError, merged, 0,
name + " is set, but its value could not be read as a pin number so it resolves to -1. "
"meshtasticd aborts with an assertion when it tries to claim that line. Check for a "
"non-numeric value, or a stray line folded into it by YAML indentation"});
};
for (const auto *pin : portduino_config.all_pins)
if (pin->enabled && pin->pin < 0)
report(pin->config_section + "." + pin->config_name);
for (const auto &pin : portduino_config.extra_pins)
if (pin.enabled && pin.pin < 0)
report(pin.config_section + "." + pin.config_name);
}
void checkMergedConfig(const PathIndex &paths, std::vector<Finding> &findings)
{
const std::string merged = "(merged configuration)";
checkPinValues(findings);
// portduinoSetup() skips initGPIOPin() for every Lora pin when spidev is ch341, so a
// gpiochip or line mapping written next to one is read, stored, and never used.
if (portduino_config.lora_spi_dev == "ch341") {
auto endsWith = [](const std::string &text, const std::string &suffix) {
return text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0;
};
std::string ignored;
for (const auto &entry : paths) {
if (entry.first.rfind("Lora.", 0) != 0)
continue;
if (entry.first == "Lora.gpiochip" || endsWith(entry.first, ".gpiochip") || endsWith(entry.first, ".line"))
ignored += (ignored.empty() ? "" : ", ") + entry.first;
}
if (!ignored.empty())
findings.push_back({kWarn, merged, 0,
"Lora.spidev is ch341, so the Lora pins are indexes on the USB adapter and are driven by "
"the usermode driver rather than claimed from a gpiochip. " +
ignored + " are read but never used"});
}
// "auto" is excluded by moduleUsesRfSwitchTable(): the module has not been probed yet, so
// there is nothing to judge the absence against.
if (moduleUsesRfSwitchTable(portduino_config.lora_module) && !portduino_config.has_rfswitch_table)
findings.push_back({kWarn, merged, 0,
"Module is " + moduleName() +
" but no Lora.rfswitch_table is set, so setRfSwitchTable() is never called. Most modules "
"of this family cannot transmit or receive without one"});
if (!moduleUsesRfSwitchTable(portduino_config.lora_module) && portduino_config.lora_module != use_autoconf &&
portduino_config.has_rfswitch_table)
findings.push_back({kWarn, merged, 0,
"a Lora.rfswitch_table is set but Module is " + moduleName() +
", and the table is only applied to LR11xx and LR20x0 radios"});
// One pin cannot be both the interrupt output and a switch control. begin() exercises only
// SPI and BUSY, so the radio reports init success and then never receives anything.
bool irqDioCollides = false;
if (portduino_config.lora_module == use_lr2021 && portduino_config.has_rfswitch_table) {
const int irqDio = portduino_config.irq_dio_num >= 0 ? portduino_config.irq_dio_num : kLr20x0DefaultIrqDio;
for (int i = 0; i < 5; i++) {
if (portduino_config.rfswitch_dio_num[i] != irqDio)
continue;
const std::string dio = "DIO" + std::to_string(irqDio);
irqDioCollides = true;
if (portduino_config.irq_dio_num >= 0)
findings.push_back({kError, merged, 0,
"Lora.IRQ_DIO_NUM is " + dio +
", which Lora.rfswitch_table.pins also drives as an RF switch control. One pin "
"cannot be both the interrupt output and a switch line"});
else
findings.push_back({kError, merged, 0,
"no Lora.IRQ_DIO_NUM is set, so the radio raises its interrupt on " + dio +
" by default -- but Lora.rfswitch_table.pins drives " + dio +
" as an RF switch control. Set Lora.IRQ_DIO_NUM to a DIO the table does not use "
"(the radio will otherwise report init success and receive nothing)"});
break;
}
}
// Leaving it unset is legitimate as long as nothing else wants DIO5, but the default is worth
// stating: the collision above is what it turns into once a switch table arrives.
if (portduino_config.lora_module == use_lr2021 && portduino_config.irq_dio_num < 0 && !irqDioCollides)
findings.push_back({kInfo, merged, 0,
"no Lora.IRQ_DIO_NUM is set, so the radio raises its interrupt on DIO" +
std::to_string(kLr20x0DefaultIrqDio) + " (the RadioLib default)"});
// The probe belongs to the radio driver, so on a part with no TCXO reference the key is
// read, stored and inert.
if (portduino_config.tcxo_optional && portduino_config.lora_module != use_autoconf &&
!moduleSupportsTcxoProbe(portduino_config.lora_module))
findings.push_back({kWarn, merged, 0,
"Lora.TCXO_OPTIONAL is set but Module is " + moduleName() +
", which has no TCXO reference to probe for, so the setting does nothing"});
// Writing the voltage out as false asks for DIO3 to be left alone; the probe drives it
// anyway. Both keys are honoured exactly as documented, which is what makes it confusing.
if (portduino_config.tcxo_optional && portduino_config.dio3_tcxo_voltage_disabled &&
moduleSupportsTcxoProbe(portduino_config.lora_module))
findings.push_back({kWarn, merged, 0,
"Lora.DIO3_TCXO_VOLTAGE is off, which asks for DIO3 not to be driven, but "
"Lora.TCXO_OPTIONAL probes DIO3 at the radio default before falling back to the "
"crystal. The probe wins. Drop TCXO_OPTIONAL to keep DIO3 idle, or drop "
"DIO3_TCXO_VOLTAGE to let the probe pick"});
// Either way -- the old uncaught filesystem_error abort or today's clean exit -- the files
// meant to configure the radio are not being loaded.
if (!portduino_config.config_directory.empty()) {
std::error_code error;
if (!std::filesystem::is_directory(portduino_config.config_directory, error))
findings.push_back({kError, merged, 0,
"General.ConfigDirectory '" + portduino_config.config_directory +
"' is not a directory that can be read, so none of the files that were meant to be "
"loaded from it are being loaded and meshtasticd stops at startup"});
}
// strncpy into a char[80] that is then hard null-terminated: safe, but silently
// shortened, and the operator never sees the message they actually configured.
if (portduino_config.has_statusMessage && portduino_config.statusMessage.size() > 79)
findings.push_back({kWarn, merged, 0,
"Config.StatusMessage is " + std::to_string(portduino_config.statusMessage.size()) +
" characters and is truncated to 79 when it is stored"});
// DIO3_TCXO_VOLTAGE is in VOLTS while every other Meshtastic surface uses millivolts, so
// the natural "1800" silently asks for 1800V and nothing downstream range-checks it.
if (portduino_config.dio3_tcxo_voltage > 3600)
findings.push_back({kError, merged, 0,
"Lora.DIO3_TCXO_VOLTAGE resolves to " + std::to_string(portduino_config.dio3_tcxo_voltage) +
" mV, which no TCXO runs at. The value is in VOLTS and is multiplied by 1000, so write "
"1.8 (or true) for a 1.8V part, not 1800"});
// loadConfig() only adopts APIPort inside this range and otherwise keeps the
// default without a word, so an out-of-range port silently does nothing.
if (portduino_config.api_port != -1 && (portduino_config.api_port <= 1023 || portduino_config.api_port >= 65536))
findings.push_back({kWarn, merged, 0,
"General.APIPort " + std::to_string(portduino_config.api_port) +
" is outside 1024-65535, so it is ignored and the default port is used instead"});
if (portduino_config.webserverport != -1 && (portduino_config.webserverport <= 0 || portduino_config.webserverport >= 65536))
findings.push_back({kError, merged, 0,
"Webserver.Port " + std::to_string(portduino_config.webserverport) + " is not a usable TCP port"});
if (portduino_config.MaxNodes <= 0)
findings.push_back({kError, merged, 0,
"General.MaxNodes is " + std::to_string(portduino_config.MaxNodes) +
", which leaves no room for even this node's own entry"});
// Upper bound too: MAX_NUM_NODES scales the node DB and the nodes.proto decode ceiling
// (NodeDB::getMaxNodesAllocatedSize()), so a typo'd value is a boot-time memory failure with no
// obvious cause. A sanity bound, not a capability limit - raise it if a host genuinely needs more.
else if (portduino_config.MaxNodes > MAX_NODES_SANITY_CEILING)
findings.push_back({kError, merged, 0,
"General.MaxNodes is " + std::to_string(portduino_config.MaxNodes) + ", above the " +
std::to_string(MAX_NODES_SANITY_CEILING) + " sanity ceiling"});
#if !defined(HAS_HUB75_NATIVE)
// A build-time gap rather than a config error: the same file is valid on a
// meshtasticd built with rpi-rgb-led-matrix present.
if (portduino_config.displayPanel == hub75)
findings.push_back({kError, merged, 0,
"Display.Panel is HUB75 but this meshtasticd was built without HUB75 support, so it exits at "
"startup. Rebuild with hzeller/rpi-rgb-led-matrix installed (it provides rgbmatrix.pc)"});
#endif
#if !HAS_BLUETOOTH
// Same class of build-time gap as HUB75 above, but only a warning: BLE
// quietly stays off rather than aborting startup.
if (portduino_config.bluetooth_enabled)
findings.push_back({kWarn, merged, 0,
"Bluetooth.Enabled is true but this meshtasticd "
"was built without BLE support, so Bluetooth "
"stays off. Rebuild with libsdbus-c++-dev "
"installed (it provides sdbus-c++.pc)"});
#endif
if (portduino_config.lora_cs_pin.enabled && !portduino_config.lora_spi_dev.empty() &&
portduino_config.lora_spi_dev != "ch341")
findings.push_back({kInfo, merged, 0,
"both Lora.spidev (" + portduino_config.lora_spi_dev +
") and Lora.CS are set. If your device tree already assigns a chip select to that spidev "
"node, meshtasticd will fail to claim the CS line at startup; if it does not, CS must be "
"set here. Check with 'gpioinfo'"});
}
void printSummary()
{
std::cout << "\nEffective radio configuration:\n";
std::cout << " Module : " << moduleName() << "\n";
if (!portduino_config.lora_spi_dev.empty())
std::cout << " spidev : " << portduino_config.lora_spi_dev << "\n";
std::cout << " SPI speed : " << portduino_config.spiSpeed << "\n";
if (portduino_config.dio3_tcxo_voltage)
std::cout << " DIO3 TCXO voltage : " << portduino_config.dio3_tcxo_voltage << " mV\n";
if (portduino_config.tcxo_optional) {
// Name the Vref actually tried: with no explicit voltage the driver uses the radio
// default rather than skipping the TCXO attempt.
std::cout << " TCXO probe : yes, "
<< (portduino_config.dio3_tcxo_voltage ? std::to_string(portduino_config.dio3_tcxo_voltage) + " mV"
: std::string("1600 mV (radio default)"))
<< " and XTAL\n";
}
// setRfSwitchTable() is called for an LR11xx and an LR20x0, so "not set" is no gap on any
// other radio; "auto" has not resolved yet, so absence cannot be called either way.
const char *rfSwitch = "not needed for this module";
if (portduino_config.has_rfswitch_table)
rfSwitch = "set";
else if (moduleUsesRfSwitchTable(portduino_config.lora_module))
rfSwitch = "not set";
else if (portduino_config.lora_module == use_autoconf)
rfSwitch = "not set (module not resolved yet)";
std::cout << " RF switch table : " << rfSwitch << "\n";
if (portduino_config.lora_module == use_lr2021)
std::cout << " IRQ DIO : "
<< (portduino_config.irq_dio_num >= 0 ? "DIO" + std::to_string(portduino_config.irq_dio_num)
: "DIO" + std::to_string(kLr20x0DefaultIrqDio) + " (radio default)")
<< "\n";
// A ch341 adapter's Lora pins are adapter indexes handed to Ch341Hal, not gpiochip lines
// (portduinoSetup() skips initGPIOPin()), so pointing the user at gpioinfo would be wrong.
const bool usbAdapter = portduino_config.lora_spi_dev == "ch341";
std::cout << (usbAdapter ? "\nCH341 adapter pins (driven over USB, not claimed from a gpiochip):\n"
: "\nResolved GPIO lines (what meshtasticd will try to claim):\n");
bool any = false;
for (const auto *pin : portduino_config.all_pins) {
if (!pin->enabled || pin->config_section != "Lora")
continue;
any = true;
std::cout << " " << pin->config_name;
for (size_t i = pin->config_name.size(); i < 18; i++)
std::cout << ' ';
std::cout << ": pin " << pin->pin;
if (!usbAdapter)
std::cout << " gpiochip" << pin->gpiochip << " line " << pin->line;
std::cout << "\n";
}
if (!any)
std::cout << " (none configured)\n";
if (usbAdapter)
std::cout << "\n These are pin indexes on the CH341 itself, so 'gpiodetect' and 'gpioinfo' say\n"
" nothing about them. Lora.gpiochip and any per-pin gpiochip/line mapping are\n"
" ignored for a ch341 device.\n";
else
std::cout << "\n Confirm these against 'gpiodetect' and 'gpioinfo' on this machine. A line that\n"
" exists on the wrong chip is claimed successfully and silently does nothing.\n";
}
} // namespace
int runConfigCheck(const std::vector<std::string> &configFiles)
{
std::vector<Finding> findings;
std::cout << "meshtasticd configuration check\n";
std::cout << "===============================\n\n";
if (configFiles.empty()) {
std::cout << "No configuration files were found.\n";
return 1;
}
std::cout << "Configuration files, in load order (later files override earlier ones):\n";
for (size_t i = 0; i < configFiles.size(); i++)
std::cout << " " << (i + 1) << ". " << configFiles[i] << "\n";
if (configFiles.size() > 1)
std::cout << "\n Files in the config directory are read in whatever order the filesystem\n"
" returns them, which is not necessarily alphabetical and can differ between\n"
" machines. Avoid relying on one file overriding another.\n";
PathIndex paths;
std::map<std::string, std::vector<std::string>> sectionOwners;
for (const auto &file : configFiles)
checkFile(file, findings, paths, sectionOwners);
checkCrossFileOverlap(paths, sectionOwners, findings);
checkMergedConfig(paths, findings);
int errors = 0, warnings = 0;
std::string currentFile;
for (const auto &finding : findings) {
if (finding.level == kError)
errors++;
else if (finding.level == kWarn)
warnings++;
if (finding.file != currentFile) {
currentFile = finding.file;
std::cout << "\n" << currentFile << "\n";
}
std::cout << " " << levelName(finding.level) << ' ';
if (finding.line)
std::cout << "line " << finding.line << ": ";
std::cout << finding.message << "\n";
}
printSummary();
std::cout << "\nResult: " << errors << (errors == 1 ? " error, " : " errors, ") << warnings
<< (warnings == 1 ? " warning" : " warnings") << "\n";
if (errors == 0 && warnings == 0)
std::cout << "Configuration looks good.\n";
return errors ? 1 : 0;
}
#endif // !ARCH_PORTDUINO_WASM