#include "ConfigCheck.h" #ifndef ARCH_PORTDUINO_WASM #include "configuration.h" #include "PortduinoGlue.h" #include "yaml-cpp/eventhandler.h" #include #include #include #include #include #include #include #include #include #include #include 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 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 kJoystickActions = {"select", "cancel", "back", "up", "down", "left", "right", "user", "userpress"}; const std::map> &schema() { static const std::map> 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 kFreeFormSections = {"Meta"}; const std::set kPinSubKeys = {"pin", "gpiochip", "line"}; const std::set 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 &kRfSwitchModes() { static const std::set s = [] { std::set 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 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 modesFor(lora_module_enum module) { if (module == use_autoconf) return kRfSwitchModes(); std::set 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 pinsFor(lora_module_enum module) { std::set 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 &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> &keyOwners() { static const std::map> owners = [] { std::map> m; for (const auto §ion : 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 &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 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(mark.line) + 1, existing->second}); else seen.emplace(value, static_cast(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 seen; }; std::vector 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 &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 &findings) { if (!node.IsMap()) return; // plain scalar pin number, always fine for (const auto &entry : node) { const std::string key = entry.first.as(""); 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 &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 owner; // code -> the action that claimed it first for (const auto &entry : node) { std::string action = entry.first.as(""); 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 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(""); 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 &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 valid = pinsFor(portduino_config.lora_module); for (const auto &pin : pins) { const std::string name = pin.as(""); 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 validModes = modesFor(portduino_config.lora_module); for (const auto &entry : table) { const std::string key = entry.first.as(""); 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(""); 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 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(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 &valueSpecs() { static const std::map 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(); return true; case kInt: node.as(); return true; case kFloat: node.as(); return true; case kBoolOrFloat: try { node.as(); return true; } catch (const std::exception &) { node.as(); return true; } case kIntOrString: try { node.as(); return true; } catch (const std::exception &) { node.as(); return true; } case kIntList: if (!node.IsSequence()) return false; for (const auto &item : node) item.as(); return true; default: node.as(); return true; } } catch (const std::exception &) { return false; } } void checkValueType(const std::string &file, const std::string &path, const YAML::Node &value, std::vector &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 &findings) { if (!converts(value, kInt)) return; // checkValueType() already reported it const int dio = value.as(); 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 &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("") + "' 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(); // 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 &findings) { const std::string name = module.as(""); 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(a)) == std::tolower(static_cast(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 &findings) { const YAML::Node address = general["MACAddress"]; const YAML::Node source = general["MACAddressSource"]; const std::string addressText = address ? address.as("") : ""; const std::string sourceText = source ? source.as("") : ""; 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 §ion, const YAML::Node &body, std::vector &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(""); 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(""); 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/), 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 is a real adapter name. const std::string adapter = value.as(""); 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>; 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(""); 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 &findings, PathIndex &paths, std::map> §ionOwners) { 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(""); // 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(""); 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 &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> §ionOwners, std::vector &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 &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 &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 &configFiles) { std::vector 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> 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