From fc2fc97730d272c6010bd05fa045943c028b4c67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:51:33 +0000 Subject: [PATCH] portduino: read environment telemetry from shell commands (Sensors:) Add a Sensors: list to config.yaml. Each entry names an EnvironmentMetrics field and a shell command that prints one number: Sensors: - Type: temperature Command: cat /sys/bus/iio/devices/iio:device0/in_temp_input Scale: 0.001 - CommandSensor plugs into EnvironmentTelemetryModule like the T1000x sensor (no I2C device). Readings are cached for 30s, since getMetrics() also runs for screen refreshes and phone requests. Output that is not exactly one finite number is logged and the field is left unset. Integer fields are rounded and clamped. - Type matches the protobuf field name, case-insensitively. An unknown Type or missing Command stops meshtasticd, as Security.AdminKeys does. Entries append across config.d files. - exec() now has a timeout: commands run in their own process group, stdin is /dev/null, and the whole group is killed at the deadline, so a hung script stalls the main loop for a bounded time. Command sensors get 2s; HostMetrics and other exec() callers get 10s. Output is capped at 4 KiB. Windows keeps popen() without a timeout. - --check validates the section (type, command, scale, unknown keys, duplicate types) and notes that sensors need environment measurement enabled. Boot logs a warning when sensors are configured but environment measurement is off. The section does not enable telemetry itself, because enabling it also broadcasts to the mesh. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011C7D9dE4aUYBrGdpPyHs25 --- bin/config-dist.yaml | 11 + bin/test-config-check.sh | 29 ++ src/modules/Modules.cpp | 7 + .../Telemetry/EnvironmentTelemetry.cpp | 8 + .../Telemetry/Sensor/CommandSensor.cpp | 66 ++++ src/modules/Telemetry/Sensor/CommandSensor.h | 31 ++ .../Telemetry/Sensor/CommandSensorFields.cpp | 154 ++++++++ .../Telemetry/Sensor/CommandSensorFields.h | 28 ++ src/platform/portduino/ConfigCheck.cpp | 70 ++++ src/platform/portduino/PortduinoGlue.cpp | 175 ++++++++- src/platform/portduino/PortduinoGlue.h | 32 +- test/fixtures/portduino-config/README.md | 21 +- .../sensors-append/config.d/pressure.yaml | 4 + .../sensors-append/config.yaml | 13 + .../portduino-config/sensors-bad-type.yaml | 12 + .../portduino-config/sensors-not-a-list.yaml | 11 + .../portduino-config/sensors-warnings.yaml | 16 + test/fixtures/portduino-config/sensors.yaml | 14 + test/test_command_sensor/test_main.cpp | 332 ++++++++++++++++++ 19 files changed, 1019 insertions(+), 15 deletions(-) create mode 100644 src/modules/Telemetry/Sensor/CommandSensor.cpp create mode 100644 src/modules/Telemetry/Sensor/CommandSensor.h create mode 100644 src/modules/Telemetry/Sensor/CommandSensorFields.cpp create mode 100644 src/modules/Telemetry/Sensor/CommandSensorFields.h create mode 100644 test/fixtures/portduino-config/sensors-append/config.d/pressure.yaml create mode 100644 test/fixtures/portduino-config/sensors-append/config.yaml create mode 100644 test/fixtures/portduino-config/sensors-bad-type.yaml create mode 100644 test/fixtures/portduino-config/sensors-not-a-list.yaml create mode 100644 test/fixtures/portduino-config/sensors-warnings.yaml create mode 100644 test/fixtures/portduino-config/sensors.yaml create mode 100644 test/test_command_sensor/test_main.cpp diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index 38fefa96cb..476b388d0a 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -271,6 +271,17 @@ HostMetrics: # Channel: 0 # channel to send Host Metrics over. Defaults to the primary channel. # UserStringCommand: cat /sys/firmware/devicetree/base/serial-number # Command to execute, to send the results as the userString +# Environment telemetry values read by running a command. Each command must print one number, in the +# units of the named EnvironmentMetrics field (temperature, relative_humidity, barometric_pressure, +# lux, wind_speed, ...). Read only while environment measurement is enabled in the Telemetry module +# config, and sent at its update interval. Commands are killed after 2 seconds. +#Sensors: +# - Type: temperature +# Command: cat /sys/bus/iio/devices/iio:device0/in_temp_input +# Scale: 0.001 # Optional multiplier; sysfs usually reports milli-units +# - Type: relative_humidity +# Command: /usr/local/bin/read-humidity + Security: # Public keys authorized to send admin messages to this node, as base64, the same text the apps and # the CLI show for a node's public key. Up to 16, and up to 3 of them are reflected into the node's diff --git a/bin/test-config-check.sh b/bin/test-config-check.sh index 39d4342115..3143a0db51 100755 --- a/bin/test-config-check.sh +++ b/bin/test-config-check.sh @@ -175,6 +175,31 @@ assert "admin keys append across config.d" 0 adminkeys-append/config.yaml yaml \ "NGC+MSAeaf7aoO7ouZl/XHwpmf2v5ZMlPNZUr0361xQ=" \ "P2jeuoXA1sOGSPH9Vm3eHFlITu1EVnohWGKlGF3t8GU=" +echo +echo "command sensors:" +assert "a list of command sensors is clean" 0 sensors.yaml check \ + "Sensors are only read while environment measurement is enabled" \ + "Result: 0 errors, 0 warnings" +assert "an unknown sensor Type is refused and the valid ones named" 1 sensors-bad-type.yaml check \ + "Sensors Type 'humidity' is not an environment metric" \ + "relative_humidity" \ + "Result: 1 error, 0 warnings" +assert "sensors written as a mapping are refused" 1 sensors-not-a-list.yaml check \ + "Sensors must be a list of entries with a Type and a Command" \ + "Result: 1 error, 0 warnings" +assert "bad Scale, unknown key and duplicate Type are warned" 0 sensors-warnings.yaml check \ + "Sensors Scale 'milli' is not a number" \ + "unknown key 'Sensors.Interval'" \ + "Sensors lists temperature 2 times" \ + "Result: 0 errors, 3 warnings" +# Like admin keys, sensors append across files so one file per sensor works. --output-yaml reports +# the merged list, with the Type normalised to the field name. +assert "sensors append across config.d" 0 sensors-append/config.yaml yaml \ + "Type: temperature" \ + "Command: echo 21.5" \ + "Type: barometric_pressure" \ + "Command: echo 1013.2" + echo echo "module names are matched exactly:" assert "unknown module names the valid set" 1 module-unknown.yaml check \ @@ -530,6 +555,10 @@ assert "a malformed admin key is refused on a normal boot" 1 adminkeys-bad.yaml "is not a 32-byte base64 public key" assert "admin keys as a scalar are refused on a normal boot" 1 adminkeys-not-a-list.yaml normal \ "Security.AdminKeys must be a list of base64 public keys" +assert "an unknown sensor Type is refused on a normal boot" 1 sensors-bad-type.yaml normal \ + "Sensors: 'humidity' is not a known Type" +assert "sensors as a mapping are refused on a normal boot" 1 sensors-not-a-list.yaml normal \ + "Sensors must be a list of entries with a Type and a Command" assert "MACAddress conflict is refused" 1 mac-conflict.yaml normal \ "Cannot set both MACAddress and MACAddressSource!" # Only meaningful on a build without rgbmatrix. A build that supports HUB75 accepts diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index dec52ae73d..6821301230 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -67,6 +67,7 @@ #include "modules/DMShellTestModule.h" #endif #if ARCH_PORTDUINO +#include "PortduinoGlue.h" #include "modules/DMShell.h" #include "modules/Telemetry/HostMetrics.h" #if !MESHTASTIC_EXCLUDE_STOREFORWARD @@ -243,6 +244,12 @@ void setupModules() (moduleConfig.telemetry.environment_measurement_enabled || moduleConfig.telemetry.environment_screen_enabled)) { environmentTelemetryModule = new EnvironmentTelemetryModule(); } +#if ARCH_PORTDUINO + if (!portduino_config.command_sensors.empty() && + !(moduleConfig.telemetry.environment_measurement_enabled || ENVIRONMENTAL_TELEMETRY_MODULE_ENABLE)) + LOG_WARN("config.yaml lists %u Sensors, but environment measurement is disabled, so they are not read", + static_cast(portduino_config.command_sensors.size())); +#endif #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR if (moduleConfig.has_telemetry && (moduleConfig.telemetry.air_quality_enabled || moduleConfig.telemetry.air_quality_screen_enabled)) { diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 18323036b4..1b9ca10356 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -124,6 +124,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/T1000xSensor.h" #endif +#if ARCH_PORTDUINO +#include "Sensor/CommandSensor.h" +#endif + #if __has_include() #include "Sensor/SPA06Sensor.h" #endif @@ -285,6 +289,10 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) // order by priority of metrics/values (low top, high bottom) #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR +#if ARCH_PORTDUINO + // Not an I2C device. Added first so it is read last, letting a configured command win a shared field. + addSensor(i2cScanner, ScanI2C::DeviceType::NONE); +#endif #ifdef T1000X_SENSOR_EN // Not a real I2C device addSensor(i2cScanner, ScanI2C::DeviceType::NONE); diff --git a/src/modules/Telemetry/Sensor/CommandSensor.cpp b/src/modules/Telemetry/Sensor/CommandSensor.cpp new file mode 100644 index 0000000000..23567c1db6 --- /dev/null +++ b/src/modules/Telemetry/Sensor/CommandSensor.cpp @@ -0,0 +1,66 @@ +#include "configuration.h" + +#if ARCH_PORTDUINO && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + +#include "CommandSensor.h" +#include "CommandSensorFields.h" +#include "PortduinoGlue.h" +#include "UptimeClock.h" +#include "mesh/Throttle.h" + +// getMetrics() also runs for screen refreshes and phone requests; this keeps those from re-running every command. +static constexpr uint32_t COMMAND_SENSOR_CACHE_MS = 30 * 1000; + +CommandSensor::CommandSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SENSOR_UNSET, "Command") {} + +bool CommandSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + if (portduino_config.command_sensors.empty()) + return false; + + readings.assign(portduino_config.command_sensors.size(), Reading()); + LOG_INFO("Init sensor: %s, %u from config.yaml Sensors", sensorName, + static_cast(portduino_config.command_sensors.size())); + status = 1; + initialized = true; + return true; +} + +void CommandSensor::refresh(size_t index) +{ + const commandSensorConfig &sensor = portduino_config.command_sensors[index]; + Reading &reading = readings[index]; + reading.readAtMs = Time::stampMillis(); + reading.valid = false; + + std::string output; + if (!execCommand(sensor.command.c_str(), COMMAND_SENSOR_TIMEOUT_MS, output)) { + LOG_WARN("Sensor %s: command failed or timed out: %s", sensor.type.c_str(), sensor.command.c_str()); + return; + } + float value; + if (!CommandSensorFields::parseValue(output, value)) { + while (!output.empty() && (output.back() == '\n' || output.back() == '\r')) + output.pop_back(); + LOG_WARN("Sensor %s: command output is not a number: '%s'", sensor.type.c_str(), output.c_str()); + return; + } + reading.value = value * sensor.scale; + reading.valid = true; + LOG_DEBUG("Sensor %s: %f", sensor.type.c_str(), reading.value); +} + +bool CommandSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + bool any = false; + for (size_t i = 0; i < readings.size(); i++) { + if (readings[i].readAtMs == 0 || Throttle::hasElapsed(readings[i].readAtMs, COMMAND_SENSOR_CACHE_MS)) + refresh(i); + if (readings[i].valid && CommandSensorFields::apply(measurement->variant.environment_metrics, + portduino_config.command_sensors[i].type, readings[i].value)) + any = true; + } + return any; +} + +#endif diff --git a/src/modules/Telemetry/Sensor/CommandSensor.h b/src/modules/Telemetry/Sensor/CommandSensor.h new file mode 100644 index 0000000000..3380023681 --- /dev/null +++ b/src/modules/Telemetry/Sensor/CommandSensor.h @@ -0,0 +1,31 @@ +#pragma once + +#include "configuration.h" + +#if ARCH_PORTDUINO && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include + +// Fills EnvironmentMetrics fields from the shell commands listed under Sensors: in config.yaml. +class CommandSensor : public TelemetrySensor +{ + public: + CommandSensor(); + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + + private: + struct Reading { + bool valid = false; + float value = 0; + uint32_t readAtMs = 0; // 0 = never read + }; + // Parallel to portduino_config.command_sensors. + std::vector readings; + + void refresh(size_t index); +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/CommandSensorFields.cpp b/src/modules/Telemetry/Sensor/CommandSensorFields.cpp new file mode 100644 index 0000000000..3809b4bbd2 --- /dev/null +++ b/src/modules/Telemetry/Sensor/CommandSensorFields.cpp @@ -0,0 +1,154 @@ +#include "CommandSensorFields.h" + +#if ARCH_PORTDUINO + +#include +#include +#include +#include +#include + +namespace CommandSensorFields +{ +namespace +{ + +using Setter = void (*)(meshtastic_EnvironmentMetrics &, float); + +struct Field { + const char *name; + Setter set; +}; + +template T roundAndClamp(float value) +{ + const double rounded = std::round(static_cast(value)); + if (rounded <= static_cast(std::numeric_limits::min())) + return std::numeric_limits::min(); + if (rounded >= static_cast(std::numeric_limits::max())) + return std::numeric_limits::max(); + return static_cast(rounded); +} + +#define FLOAT_FIELD(f) \ + { \ + #f, [](meshtastic_EnvironmentMetrics &m, float v) { \ + m.f = v; \ + m.has_##f = true; \ + } \ + } +#define INT_FIELD(f) \ + { \ + #f, [](meshtastic_EnvironmentMetrics &m, float v) { \ + m.f = roundAndClamp(v); \ + m.has_##f = true; \ + } \ + } + +const Field kFields[] = { + FLOAT_FIELD(temperature), + FLOAT_FIELD(relative_humidity), + FLOAT_FIELD(barometric_pressure), + FLOAT_FIELD(gas_resistance), + FLOAT_FIELD(voltage), + FLOAT_FIELD(current), + INT_FIELD(iaq), + FLOAT_FIELD(distance), + FLOAT_FIELD(lux), + FLOAT_FIELD(white_lux), + FLOAT_FIELD(ir_lux), + FLOAT_FIELD(uv_lux), + INT_FIELD(wind_direction), + FLOAT_FIELD(wind_speed), + FLOAT_FIELD(weight), + FLOAT_FIELD(wind_gust), + FLOAT_FIELD(wind_lull), + FLOAT_FIELD(radiation), + FLOAT_FIELD(rainfall_1h), + FLOAT_FIELD(rainfall_24h), + INT_FIELD(soil_moisture), + FLOAT_FIELD(soil_temperature), + FLOAT_FIELD(adc_voltage_ch0), + FLOAT_FIELD(adc_voltage_ch1), + FLOAT_FIELD(adc_voltage_ch2), + FLOAT_FIELD(adc_voltage_ch3), + FLOAT_FIELD(adc_voltage_ch4), + FLOAT_FIELD(adc_voltage_ch5), + FLOAT_FIELD(adc_voltage_ch6), + FLOAT_FIELD(adc_voltage_ch7), + FLOAT_FIELD(one_wire_temperature_ch0), + FLOAT_FIELD(one_wire_temperature_ch1), + FLOAT_FIELD(one_wire_temperature_ch2), + FLOAT_FIELD(one_wire_temperature_ch3), + FLOAT_FIELD(one_wire_temperature_ch4), + FLOAT_FIELD(one_wire_temperature_ch5), + FLOAT_FIELD(one_wire_temperature_ch6), + FLOAT_FIELD(one_wire_temperature_ch7), + INT_FIELD(lightning_strike_count_1h), + FLOAT_FIELD(lightning_distance_km), +}; + +#undef FLOAT_FIELD +#undef INT_FIELD + +const Field *findField(const std::string &type) +{ + std::string lower = type; + for (auto &c : lower) + c = static_cast(std::tolower(static_cast(c))); + for (const auto &field : kFields) + if (lower == field.name) + return &field; + return nullptr; +} + +} // namespace + +std::string canonicalType(const std::string &type) +{ + const Field *field = findField(type); + return field ? field->name : ""; +} + +std::string knownTypes() +{ + std::string out; + for (const auto &field : kFields) + out += (out.empty() ? "" : ", ") + std::string(field.name); + return out; +} + +bool apply(meshtastic_EnvironmentMetrics &metrics, const std::string &type, float value) +{ + const Field *field = findField(type); + if (!field) + return false; + field->set(metrics, value); + return true; +} + +bool parseValue(const std::string &output, float &value) +{ + const char *start = output.c_str(); + while (*start && std::isspace(static_cast(*start))) + start++; + if (!*start) + return false; + + char *end = nullptr; + errno = 0; + const float parsed = std::strtof(start, &end); + if (end == start || errno == ERANGE || !std::isfinite(parsed)) + return false; + while (*end && std::isspace(static_cast(*end))) + end++; + if (*end) + return false; + + value = parsed; + return true; +} + +} // namespace CommandSensorFields + +#endif diff --git a/src/modules/Telemetry/Sensor/CommandSensorFields.h b/src/modules/Telemetry/Sensor/CommandSensorFields.h new file mode 100644 index 0000000000..03a3329962 --- /dev/null +++ b/src/modules/Telemetry/Sensor/CommandSensorFields.h @@ -0,0 +1,28 @@ +#pragma once + +#include "configuration.h" + +#if ARCH_PORTDUINO + +#include "mesh/generated/meshtastic/telemetry.pb.h" +#include + +// The EnvironmentMetrics fields a Sensors: entry in config.yaml can fill, named as in the protobuf. +namespace CommandSensorFields +{ + +// Canonical (lowercase) field name for type, matched case-insensitively; empty if no field matches. +std::string canonicalType(const std::string &type); + +// Every accepted type, comma separated, for error messages. +std::string knownTypes(); + +// Stores value in the named field and sets its has_ flag. Integer fields are rounded and clamped. +bool apply(meshtastic_EnvironmentMetrics &metrics, const std::string &type, float value); + +// Reads one finite number from command output; surrounding whitespace is allowed, anything else is not. +bool parseValue(const std::string &output, float &value); + +} // namespace CommandSensorFields + +#endif diff --git a/src/platform/portduino/ConfigCheck.cpp b/src/platform/portduino/ConfigCheck.cpp index 80ac0d1726..6066ca2f13 100644 --- a/src/platform/portduino/ConfigCheck.cpp +++ b/src/platform/portduino/ConfigCheck.cpp @@ -5,6 +5,7 @@ #include "configuration.h" #include "PortduinoGlue.h" +#include "modules/Telemetry/Sensor/CommandSensorFields.h" #include "yaml-cpp/eventhandler.h" @@ -93,6 +94,8 @@ const std::map> &schema() {"Webserver", {"Port", "RootPath", "SSLCert", "SSLKey"}}, {"Bluetooth", {"Enabled", "AdapterId"}}, {"HostMetrics", {"ReportInterval", "Channel", "UserStringCommand"}}, + // A list, not a mapping: these are the keys of each entry. See checkCommandSensors(). + {"Sensors", {"Type", "Command", "Scale"}}, // Read by packaging/menu tooling rather than by meshtasticd itself. {"Meta", {}}, }; @@ -845,6 +848,56 @@ void checkAdminKeys(const std::string &file, const YAML::Node &node, std::vector } } +// loadConfig() exits on an entry with no usable Type or Command, so those stop meshtasticd. +void checkCommandSensors(const std::string &file, const YAML::Node &node, std::vector &findings) +{ + if (node.IsNull()) + return; + if (!node.IsSequence()) { + findings.push_back({kError, file, lineOf(node), + "Sensors must be a list of entries with a Type and a Command, so meshtasticd refuses to start " + "on this file"}); + return; + } + + for (const auto &entry : node) { + if (!entry.IsMap()) { + findings.push_back({kError, file, lineOf(entry), + "Sensors entry is not a Type/Command mapping, so meshtasticd refuses to start on this file"}); + continue; + } + for (const auto &field : entry) { + const std::string key = field.first.as(""); + if (!schema().at("Sensors").count(key)) + findings.push_back( + {kWarn, file, lineOf(field.first), "unknown key 'Sensors." + key + "', ignored by meshtasticd"}); + } + + const YAML::Node type = entry["Type"]; + const std::string typeText = type ? type.as("") : ""; + if (typeText.empty()) + findings.push_back({kError, file, lineOf(entry), + "Sensors entry has no Type, so meshtasticd refuses to start on this file. Valid: " + + CommandSensorFields::knownTypes()}); + else if (CommandSensorFields::canonicalType(typeText).empty()) + findings.push_back({kError, file, lineOf(type), + "Sensors Type '" + typeText + + "' is not an environment metric, so meshtasticd refuses to start on this file. Valid: " + + CommandSensorFields::knownTypes()}); + + const YAML::Node command = entry["Command"]; + if (!command || command.as("").empty()) + findings.push_back({kError, file, lineOf(command ? command : entry), + "Sensors entry has no Command, so meshtasticd refuses to start on this file"}); + + const YAML::Node scale = entry["Scale"]; + if (scale && !converts(scale, kFloat)) + findings.push_back( + {kWarn, file, lineOf(scale), + "Sensors Scale '" + scale.as("") + "' is not a number, so it is silently replaced by 1"}); + } +} + // 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) @@ -923,6 +976,10 @@ void checkSection(const std::string &file, const std::string §ion, const YAM const auto &allowed = schema().at(section); if (kFreeFormSections.count(section)) return; + if (section == "Sensors") { + checkCommandSensors(file, body, findings); + 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()) @@ -1340,6 +1397,19 @@ void checkMergedConfig(const PathIndex &paths, std::vector &findings) ") 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'"}); + + std::map sensorTypes; + for (const auto &sensor : portduino_config.command_sensors) + sensorTypes[sensor.type]++; + for (const auto &type : sensorTypes) + if (type.second > 1) + findings.push_back({kWarn, merged, 0, + "Sensors lists " + type.first + " " + std::to_string(type.second) + + " times. Every command runs, but only one value can be sent"}); + if (!portduino_config.command_sensors.empty()) + findings.push_back({kInfo, merged, 0, + "Sensors are only read while environment measurement is enabled in the Telemetry module " + "config, and are then broadcast at its update interval"}); } void printSummary() diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index f0a756ad37..3c66b08f3c 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -13,6 +13,7 @@ #include "api/ServerAPI.h" #include "mesh/Throttle.h" #include "meshUtils.h" +#include "modules/Telemetry/Sensor/CommandSensorFields.h" #include #include #include @@ -34,6 +35,18 @@ // for the BlueZ-derived MAC address); Windows has no . #include #endif +#if !defined(_WIN32) && !defined(ARCH_PORTDUINO_WASM) +// execCommand(): spawn a shell with a deadline and kill its process group on timeout. +#include +#include +#include +#include +#include +#include +#include +#include +extern char **environ; +#endif #ifdef PORTDUINO_LINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" @@ -1470,6 +1483,41 @@ bool loadConfig(const char *configPath) portduino_config.hostMetrics_user_command = (yamlConfig["HostMetrics"]["UserStringCommand"]).as(""); } + if (yamlConfig["Sensors"]) { + const YAML::Node sensors = yamlConfig["Sensors"]; + if (!sensors.IsSequence()) { + std::cout << "Sensors must be a list of entries with a Type and a Command!" << std::endl; + if (!configCheck) + exit(EXIT_FAILURE); + } else { + // Appended, like Security.AdminKeys, so each file in config.d can add its own sensors. + for (const auto &entry : sensors) { + if (!entry.IsMap()) { + std::cout << "Sensors: each entry needs a Type and a Command!" << std::endl; + if (!configCheck) + exit(EXIT_FAILURE); + continue; + } + const std::string type = entry["Type"].as(""); + commandSensorConfig sensor; + sensor.type = CommandSensorFields::canonicalType(type); + sensor.command = entry["Command"].as(""); + sensor.scale = entry["Scale"].as(1.0f); + if (sensor.type.empty() || sensor.command.empty()) { + if (sensor.type.empty()) + std::cout << "Sensors: '" << type + << "' is not a known Type. Valid: " << CommandSensorFields::knownTypes() << std::endl; + else + std::cout << "Sensors: the " << sensor.type << " entry has no Command!" << std::endl; + if (!configCheck) + exit(EXIT_FAILURE); + continue; + } + portduino_config.command_sensors.push_back(sensor); + } + } + } + if (yamlConfig["Security"] && yamlConfig["Security"]["AdminKeys"]) { const YAML::Node keys = yamlConfig["Security"]["AdminKeys"]; if (!keys.IsSequence()) { @@ -1668,22 +1716,125 @@ bool MAC_from_string(std::string mac_str, uint8_t *dmac) return true; } -std::string exec(const char *cmd) -{ // https://stackoverflow.com/a/478960 -#ifdef ARCH_PORTDUINO_WASM +// Captured output beyond this is drained and dropped; nothing that reads it wants more. +static constexpr size_t EXEC_MAX_OUTPUT = 4096; + +bool execCommand(const char *cmd, uint32_t timeoutMs, std::string &output) +{ + output.clear(); +#if defined(ARCH_PORTDUINO_WASM) (void)cmd; // no shell/popen in the browser - shell-outs degrade to empty - return ""; -#endif + (void)timeoutMs; + return false; +#elif defined(_WIN32) + // No process group to kill here, so the timeout is not enforced on Windows. + (void)timeoutMs; + FILE *pipe = popen(cmd, "r"); + if (!pipe) + return false; std::array buffer; - std::string result; - std::unique_ptr pipe(popen(cmd, "r"), pclose); - if (!pipe) { - throw std::runtime_error("popen() failed!"); + while (fgets(buffer.data(), static_cast(buffer.size()), pipe) != nullptr) + if (output.size() < EXEC_MAX_OUTPUT) + output += buffer.data(); + return pclose(pipe) == 0; +#else + int fds[2]; + if (pipe(fds) != 0) { + LOG_WARN("Can't run '%s': pipe: %s", cmd, strerror(errno)); + return false; } - while (fgets(buffer.data(), static_cast(buffer.size()), pipe.get()) != nullptr) { - result += buffer.data(); + fcntl(fds[0], F_SETFD, FD_CLOEXEC); + fcntl(fds[1], F_SETFD, FD_CLOEXEC); + + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); + posix_spawn_file_actions_adddup2(&actions, fds[1], STDOUT_FILENO); + posix_spawnattr_t attr; + posix_spawnattr_init(&attr); + // Its own process group, so a timeout can kill the whole pipeline and not just the shell. + posix_spawnattr_setflags(&attr, POSIX_SPAWN_SETPGROUP); + posix_spawnattr_setpgroup(&attr, 0); + + char shell[] = "/bin/sh"; + char dashC[] = "-c"; + std::string command(cmd); + char *argv[] = {shell, dashC, &command[0], nullptr}; + pid_t pid; + const int spawnError = posix_spawn(&pid, shell, &actions, &attr, argv, environ); + posix_spawn_file_actions_destroy(&actions); + posix_spawnattr_destroy(&attr); + close(fds[1]); + if (spawnError != 0) { + close(fds[0]); + LOG_WARN("Can't run '%s': %s", cmd, strerror(spawnError)); + return false; } - return result; + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + auto remainingMs = [&deadline]() { + return std::chrono::duration_cast(deadline - std::chrono::steady_clock::now()).count(); + }; + + bool timedOut = false; + std::array buffer; + while (true) { + const auto remaining = remainingMs(); + if (remaining <= 0) { + timedOut = true; + break; + } + pollfd pfd = {fds[0], POLLIN, 0}; + const int ready = poll(&pfd, 1, static_cast(remaining)); + if (ready < 0 && errno == EINTR) + continue; + if (ready == 0) { + timedOut = true; + break; + } + if (ready < 0) + break; + const ssize_t n = read(fds[0], buffer.data(), buffer.size()); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) + break; // EOF: every writer has closed stdout + if (output.size() < EXEC_MAX_OUTPUT) + output.append(buffer.data(), std::min(static_cast(n), EXEC_MAX_OUTPUT - output.size())); + } + close(fds[0]); + + // A command can close stdout and keep running, so the deadline also bounds the wait for it to exit. + int status = 0; + pid_t waited = 0; + while (!timedOut) { + waited = waitpid(pid, &status, WNOHANG); + if (waited < 0 && errno == EINTR) + continue; + if (waited != 0) + break; + if (remainingMs() <= 0) + timedOut = true; + else + usleep(5000); + } + if (timedOut) { + LOG_WARN("Command timed out after %ums, killed: %s", static_cast(timeoutMs), cmd); + kill(-pid, SIGKILL); + do { + waited = waitpid(pid, &status, 0); + } while (waited < 0 && errno == EINTR); + return false; + } + return waited == pid && WIFEXITED(status) && WEXITSTATUS(status) == 0; +#endif +} + +std::string exec(const char *cmd, uint32_t timeoutMs) +{ + std::string output; + execCommand(cmd, timeoutMs, output); + return output; } #ifndef ARCH_PORTDUINO_WASM diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 5ceade1ea4..f672557e3b 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -140,7 +140,20 @@ bool MAC_from_string(std::string mac_str, uint8_t *dmac); #ifndef ARCH_PORTDUINO_WASM void readGPIOFromYaml(YAML::Node sourceNode, pinMapping &destPin, int pinDefault = RADIOLIB_NC); #endif -std::string exec(const char *cmd); +// A command that outlives its limit is killed, along with anything it started, so a hung script +// stalls the main loop for at most this long. +constexpr uint32_t EXEC_DEFAULT_TIMEOUT_MS = 10000; +constexpr uint32_t COMMAND_SENSOR_TIMEOUT_MS = 2000; +std::string exec(const char *cmd, uint32_t timeoutMs = EXEC_DEFAULT_TIMEOUT_MS); +// Runs cmd under /bin/sh and captures stdout. True only if it exited 0 within timeoutMs. +bool execCommand(const char *cmd, uint32_t timeoutMs, std::string &output); + +// One Sensors: entry. type is an EnvironmentMetrics field name, see CommandSensorFields.h. +struct commandSensorConfig { + std::string type; + std::string command; + float scale = 1.0f; +}; extern struct portduino_config_struct { // Lora @@ -309,6 +322,9 @@ extern struct portduino_config_struct { int hostMetrics_interval = 0; int hostMetrics_channel = 0; + // Sensors + std::vector command_sensors; + // Security std::vector> admin_keys; @@ -725,6 +741,20 @@ extern struct portduino_config_struct { out << YAML::EndMap; // HostMetrics } + // Sensors + if (!command_sensors.empty()) { + out << YAML::Key << "Sensors" << YAML::Value << YAML::BeginSeq; + for (const auto &sensor : command_sensors) { + out << YAML::BeginMap; + out << YAML::Key << "Type" << YAML::Value << sensor.type; + out << YAML::Key << "Command" << YAML::Value << sensor.command; + if (sensor.scale != 1.0f) + out << YAML::Key << "Scale" << YAML::Value << sensor.scale; + out << YAML::EndMap; + } + out << YAML::EndSeq; // Sensors + } + // Security if (!admin_keys.empty()) { out << YAML::Key << "Security" << YAML::Value << YAML::BeginMap; diff --git a/test/fixtures/portduino-config/README.md b/test/fixtures/portduino-config/README.md index cca3ac9606..333629366c 100644 --- a/test/fixtures/portduino-config/README.md +++ b/test/fixtures/portduino-config/README.md @@ -184,6 +184,22 @@ rather than the last file winning, so an operator can drop in one file per admin `config.yaml` is always loaded first, so the effective list is deterministic and `--output-yaml` can assert it by value. +## Command sensors (`Sensors`) + +Each entry runs a shell command and puts the number it prints into the named +`EnvironmentMetrics` field. An entry meshtasticd cannot place stops it, rather than +booting a node that silently never reports that reading. + +| File | Expected | +| ------------------------- | ------------------------------------------------------------------------------------------- | +| `sensors.yaml` | Two sensors, one Type in mixed case. Clean apart from the info line about enabling them. | +| `sensors-bad-type.yaml` | `humidity` is not a field name, so the file is refused and the valid types are listed. | +| `sensors-not-a-list.yaml` | One sensor written as a mapping, not a one-item list: nothing would be read, so refused. | +| `sensors-warnings.yaml` | Non-numeric `Scale` (read as 1), an unknown key, and one Type listed twice. Three warnings. | + +`sensors-append/` mirrors `adminkeys-append/`: sensors append across files, so one +file per sensor in `config.d/` works, and `--output-yaml` asserts both by value. + ## MAC address The MAC no longer determines NodeNum - that comes from the public key - but a MAC @@ -270,8 +286,9 @@ appears as a surviving pin, a surviving mode row, or a `HIGH` that should be `LO ## Running these as a normal boot `malformed-indent.yaml`, `nonmap-section.yaml`, `module-unknown.yaml`, -`mac-conflict.yaml`, `hub75-unknown-key.yaml`, `adminkeys-bad.yaml` and -`adminkeys-not-a-list.yaml` are also run _without_ `--check`, +`mac-conflict.yaml`, `hub75-unknown-key.yaml`, `adminkeys-bad.yaml`, +`adminkeys-not-a-list.yaml`, `sensors-bad-type.yaml` and `sensors-not-a-list.yaml` +are also run _without_ `--check`, where each must be rejected with a non-zero exit. That is the guard on `--check` mode not having quietly made the normal path permissive. No other fixture is run that way: a config meshtasticd accepts makes it boot a node and block. diff --git a/test/fixtures/portduino-config/sensors-append/config.d/pressure.yaml b/test/fixtures/portduino-config/sensors-append/config.d/pressure.yaml new file mode 100644 index 0000000000..7e1f89cfce --- /dev/null +++ b/test/fixtures/portduino-config/sensors-append/config.d/pressure.yaml @@ -0,0 +1,4 @@ +# Loaded after config.yaml. This sensor is added to its list, not substituted for it. +Sensors: + - Type: barometric_pressure + Command: echo 1013.2 diff --git a/test/fixtures/portduino-config/sensors-append/config.yaml b/test/fixtures/portduino-config/sensors-append/config.yaml new file mode 100644 index 0000000000..1d57c3f294 --- /dev/null +++ b/test/fixtures/portduino-config/sensors-append/config.yaml @@ -0,0 +1,13 @@ +# Sensors append across files instead of the last file winning, so an operator can drop one file +# per sensor into config.d/. config.yaml is always loaded first: this sensor plus the one in config.d/. +General: + ConfigDirectory: config.d/ +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Sensors: + - Type: temperature + Command: echo 21.5 diff --git a/test/fixtures/portduino-config/sensors-bad-type.yaml b/test/fixtures/portduino-config/sensors-bad-type.yaml new file mode 100644 index 0000000000..c072c7bf54 --- /dev/null +++ b/test/fixtures/portduino-config/sensors-bad-type.yaml @@ -0,0 +1,12 @@ +# FAULT: "humidity" is not an EnvironmentMetrics field (it is relative_humidity). loadConfig() +# refuses an entry it cannot place rather than dropping it, so this is an ERROR and exit 1, and the +# message lists the valid types. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Sensors: + - Type: humidity + Command: /usr/local/bin/read-humidity diff --git a/test/fixtures/portduino-config/sensors-not-a-list.yaml b/test/fixtures/portduino-config/sensors-not-a-list.yaml new file mode 100644 index 0000000000..55372bb543 --- /dev/null +++ b/test/fixtures/portduino-config/sensors-not-a-list.yaml @@ -0,0 +1,11 @@ +# FAULT: a single sensor written as a mapping rather than a one-item list. Nothing in it would be +# read, so meshtasticd refuses the file. Expect an ERROR and exit 1. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Sensors: + Type: temperature + Command: cat /sys/class/thermal/thermal_zone0/temp diff --git a/test/fixtures/portduino-config/sensors-warnings.yaml b/test/fixtures/portduino-config/sensors-warnings.yaml new file mode 100644 index 0000000000..f3e147a9e7 --- /dev/null +++ b/test/fixtures/portduino-config/sensors-warnings.yaml @@ -0,0 +1,16 @@ +# FAULT: three mistakes that still start meshtasticd. Scale is not a number (silently read as 1), an +# unknown key (ignored), and temperature listed twice (both run, one value is sent). Expect three +# WARNINGs and exit 0. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Sensors: + - Type: temperature + Command: cat /sys/class/thermal/thermal_zone0/temp + Scale: milli + - Type: temperature + Command: /usr/local/bin/outdoor-temp + Interval: 60 diff --git a/test/fixtures/portduino-config/sensors.yaml b/test/fixtures/portduino-config/sensors.yaml new file mode 100644 index 0000000000..5517828d0b --- /dev/null +++ b/test/fixtures/portduino-config/sensors.yaml @@ -0,0 +1,14 @@ +# Two command sensors, one with a Scale and one with a Type in a different case. Clean: the only +# finding is the info line saying they need environment measurement enabled. +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Busy: 20 + Reset: 18 +Sensors: + - Type: temperature + Command: cat /sys/bus/iio/devices/iio:device0/in_temp_input + Scale: 0.001 + - Type: Relative_Humidity + Command: /usr/local/bin/read-humidity diff --git a/test/test_command_sensor/test_main.cpp b/test/test_command_sensor/test_main.cpp new file mode 100644 index 0000000000..2e57193f88 --- /dev/null +++ b/test/test_command_sensor/test_main.cpp @@ -0,0 +1,332 @@ +// Tests for the config.yaml "Sensors:" feature on Linux (meshtasticd): +// +// - execCommand() / exec() in src/platform/portduino/PortduinoGlue.cpp +// - CommandSensorFields::{canonicalType, apply, parseValue} in +// src/modules/Telemetry/Sensor/CommandSensorFields.cpp +// - CommandSensor::getMetrics() in src/modules/Telemetry/Sensor/CommandSensor.cpp +// +// Why these contracts matter: +// +// execCommand() runs on the main loop, the same thread that services the radio. A sensor script that +// hangs - a stuck I2C read in Python, a network call with no timeout, a command that closes stdout and +// keeps running - must cost at most its timeout, and the whole pipeline it started must be killed so it +// cannot keep holding the pipe. exec() keeps its old contract for HostMetrics.UserStringCommand: it +// returns whatever the command printed, whether or not it exited 0. +// +// parseValue() is the only thing between arbitrary command output and a field broadcast over the mesh. +// It must take exactly one finite number: "21.5 C", an empty line, an error message or "nan" must be +// rejected rather than truncated to a plausible-looking value or sent as NaN. +// +// apply() writes into nanopb fields of several widths. A value outside a narrow integer field +// (soil_moisture is uint8_t) must clamp, not wrap: 300 wrapping to 44% moisture is a silent lie. +// +// CommandSensor caches readings because getMetrics() is also called for screen refreshes and phone +// requests. Without the cache every command reruns on each of those calls, multiplying the main-loop +// stall; with a cache that never expires the node broadcasts the first reading forever. +// +// Regressions guarded: an unbounded wait on a hung or stdout-closing command; a timeout that kills +// only /bin/sh and leaves the pipeline running; trailing text accepted as a number; NaN/inf sent; +// integer fields wrapping; the cache either missing or never expiring; Scale not applied. +#include "TestUtil.h" +#include "configuration.h" +#include + +#if ARCH_PORTDUINO && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + +#include "PortduinoGlue.h" +#include "UptimeClock.h" +#include "modules/Telemetry/Sensor/CommandSensor.h" +#include "modules/Telemetry/Sensor/CommandSensorFields.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +long elapsedMs(std::chrono::steady_clock::time_point start) +{ + return static_cast( + std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count()); +} + +std::string scratchDir; + +std::string scratchFile(const char *name) +{ + return scratchDir + "/" + name; +} + +int countLines(const std::string &path) +{ + std::ifstream in(path); + int lines = 0; + std::string line; + while (std::getline(in, line)) + lines++; + return lines; +} + +meshtastic_Telemetry emptyEnvironment() +{ + meshtastic_Telemetry t = meshtastic_Telemetry_init_zero; + t.which_variant = meshtastic_Telemetry_environment_metrics_tag; + t.variant.environment_metrics = meshtastic_EnvironmentMetrics_init_zero; + return t; +} + +} // namespace + +void setUp(void) +{ + portduino_config.command_sensors.clear(); + Time::setTestMillis(1000); +} + +void tearDown(void) +{ + portduino_config.command_sensors.clear(); + Time::useRealClock(); +} + +// --- execCommand() / exec() --- + +void test_execCommand_captures_stdout_and_reports_success() +{ + std::string output; + TEST_ASSERT_TRUE(execCommand("echo 21.5", 2000, output)); + TEST_ASSERT_EQUAL_STRING("21.5\n", output.c_str()); +} + +void test_execCommand_nonzero_exit_is_failure() +{ + std::string output; + TEST_ASSERT_FALSE(execCommand("echo 3; exit 3", 2000, output)); + TEST_ASSERT_EQUAL_STRING("3\n", output.c_str()); +} + +void test_execCommand_stdin_is_not_inherited() +{ + // A command that reads stdin must see EOF at once, not block on meshtasticd's terminal or socket. + std::string output; + TEST_ASSERT_TRUE(execCommand("cat; echo done", 2000, output)); + TEST_ASSERT_EQUAL_STRING("done\n", output.c_str()); +} + +void test_execCommand_hung_command_is_killed_at_timeout() +{ + const auto start = std::chrono::steady_clock::now(); + std::string output; + TEST_ASSERT_FALSE(execCommand("sleep 10", 300, output)); + TEST_ASSERT_LESS_THAN(2000, elapsedMs(start)); +} + +void test_execCommand_timeout_kills_the_whole_pipeline() +{ + // If only /bin/sh were killed, `sleep` would survive holding the pipe's write end. The marker file + // proves the pipeline's last stage never ran. + const std::string marker = scratchFile("pipeline-survived"); + const std::string cmd = "sleep 2 | cat; touch " + marker; + std::string output; + TEST_ASSERT_FALSE(execCommand(cmd.c_str(), 300, output)); + usleep(2500 * 1000); + TEST_ASSERT_EQUAL(-1, access(marker.c_str(), F_OK)); +} + +void test_execCommand_closed_stdout_still_bounded_by_timeout() +{ + // EOF on stdout is not exit: the wait for the process must also respect the deadline. + const auto start = std::chrono::steady_clock::now(); + std::string output; + TEST_ASSERT_FALSE(execCommand("echo early; exec >&-; sleep 10", 300, output)); + TEST_ASSERT_LESS_THAN(2000, elapsedMs(start)); + TEST_ASSERT_EQUAL_STRING("early\n", output.c_str()); +} + +void test_execCommand_output_is_capped() +{ + std::string output; + TEST_ASSERT_TRUE(execCommand("head -c 20000 /dev/zero | tr '\\0' a", 2000, output)); + TEST_ASSERT_EQUAL(4096, output.size()); +} + +void test_exec_returns_output_even_on_nonzero_exit() +{ + // HostMetrics.UserStringCommand has always sent whatever the command printed. + TEST_ASSERT_EQUAL_STRING("hi\n", exec("echo hi; exit 1").c_str()); +} + +// --- CommandSensorFields --- + +void test_parseValue_accepts_one_number_with_whitespace() +{ + float v = 0; + TEST_ASSERT_TRUE(CommandSensorFields::parseValue("21.5\n", v)); + TEST_ASSERT_EQUAL_FLOAT(21.5f, v); + TEST_ASSERT_TRUE(CommandSensorFields::parseValue(" -3\r\n", v)); + TEST_ASSERT_EQUAL_FLOAT(-3.0f, v); + TEST_ASSERT_TRUE(CommandSensorFields::parseValue("1.5e3", v)); + TEST_ASSERT_EQUAL_FLOAT(1500.0f, v); +} + +void test_parseValue_rejects_anything_else() +{ + float v = 42; + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("", v)); + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("\n", v)); + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("error: no device", v)); + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("21.5 C", v)); + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("21.5\n22.0\n", v)); + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("nan", v)); + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("inf", v)); + TEST_ASSERT_FALSE(CommandSensorFields::parseValue("1e999", v)); + TEST_ASSERT_EQUAL_FLOAT(42.0f, v); // untouched on failure +} + +void test_canonicalType_is_case_insensitive_and_rejects_unknown() +{ + TEST_ASSERT_EQUAL_STRING("temperature", CommandSensorFields::canonicalType("Temperature").c_str()); + TEST_ASSERT_EQUAL_STRING("relative_humidity", CommandSensorFields::canonicalType("RELATIVE_HUMIDITY").c_str()); + TEST_ASSERT_EQUAL_STRING("", CommandSensorFields::canonicalType("humidity").c_str()); + TEST_ASSERT_EQUAL_STRING("", CommandSensorFields::canonicalType("").c_str()); +} + +void test_apply_sets_value_and_has_flag() +{ + meshtastic_EnvironmentMetrics m = meshtastic_EnvironmentMetrics_init_zero; + TEST_ASSERT_TRUE(CommandSensorFields::apply(m, "barometric_pressure", 1013.25f)); + TEST_ASSERT_TRUE(m.has_barometric_pressure); + TEST_ASSERT_EQUAL_FLOAT(1013.25f, m.barometric_pressure); + TEST_ASSERT_FALSE(m.has_temperature); +} + +void test_apply_rounds_and_clamps_integer_fields() +{ + meshtastic_EnvironmentMetrics m = meshtastic_EnvironmentMetrics_init_zero; + TEST_ASSERT_TRUE(CommandSensorFields::apply(m, "soil_moisture", 42.6f)); + TEST_ASSERT_EQUAL_UINT8(43, m.soil_moisture); + TEST_ASSERT_TRUE(CommandSensorFields::apply(m, "soil_moisture", 300.0f)); + TEST_ASSERT_EQUAL_UINT8(255, m.soil_moisture); + TEST_ASSERT_TRUE(CommandSensorFields::apply(m, "soil_moisture", -5.0f)); + TEST_ASSERT_EQUAL_UINT8(0, m.soil_moisture); + TEST_ASSERT_TRUE(CommandSensorFields::apply(m, "wind_direction", 359.6f)); + TEST_ASSERT_EQUAL_UINT16(360, m.wind_direction); + TEST_ASSERT_TRUE(m.has_wind_direction); +} + +void test_apply_unknown_type_changes_nothing() +{ + meshtastic_EnvironmentMetrics m = meshtastic_EnvironmentMetrics_init_zero; + TEST_ASSERT_FALSE(CommandSensorFields::apply(m, "humidity", 50.0f)); + meshtastic_EnvironmentMetrics zero = meshtastic_EnvironmentMetrics_init_zero; + TEST_ASSERT_EQUAL_MEMORY(&zero, &m, sizeof(m)); +} + +// --- CommandSensor --- + +void test_CommandSensor_not_added_without_config() +{ + CommandSensor sensor; + TEST_ASSERT_FALSE(sensor.initDevice(nullptr, nullptr)); +} + +void test_CommandSensor_applies_scale_and_skips_bad_output() +{ + portduino_config.command_sensors.push_back({"temperature", "echo 21500", 0.001f}); + portduino_config.command_sensors.push_back({"relative_humidity", "echo 'no sensor'", 1.0f}); + portduino_config.command_sensors.push_back({"lux", "exit 1", 1.0f}); + CommandSensor sensor; + TEST_ASSERT_TRUE(sensor.initDevice(nullptr, nullptr)); + + meshtastic_Telemetry t = emptyEnvironment(); + TEST_ASSERT_TRUE(sensor.getMetrics(&t)); + TEST_ASSERT_TRUE(t.variant.environment_metrics.has_temperature); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 21.5f, t.variant.environment_metrics.temperature); + TEST_ASSERT_FALSE(t.variant.environment_metrics.has_relative_humidity); + TEST_ASSERT_FALSE(t.variant.environment_metrics.has_lux); +} + +void test_CommandSensor_all_failing_reports_no_metrics() +{ + portduino_config.command_sensors.push_back({"temperature", "echo oops", 1.0f}); + CommandSensor sensor; + TEST_ASSERT_TRUE(sensor.initDevice(nullptr, nullptr)); + meshtastic_Telemetry t = emptyEnvironment(); + TEST_ASSERT_FALSE(sensor.getMetrics(&t)); + TEST_ASSERT_FALSE(t.variant.environment_metrics.has_temperature); +} + +void test_CommandSensor_caches_readings_then_refreshes() +{ + const std::string runs = scratchFile("runs"); + std::remove(runs.c_str()); + const std::string cmd = "echo x >> " + runs + "; echo 7"; + portduino_config.command_sensors.push_back({"weight", cmd, 1.0f}); + CommandSensor sensor; + TEST_ASSERT_TRUE(sensor.initDevice(nullptr, nullptr)); + + meshtastic_Telemetry t = emptyEnvironment(); + TEST_ASSERT_TRUE(sensor.getMetrics(&t)); + Time::setTestMillis(1000 + 29 * 1000); + t = emptyEnvironment(); + TEST_ASSERT_TRUE(sensor.getMetrics(&t)); // cached value is still reported + TEST_ASSERT_TRUE(t.variant.environment_metrics.has_weight); + TEST_ASSERT_EQUAL(1, countLines(runs)); + + Time::setTestMillis(1000 + 31 * 1000); + TEST_ASSERT_TRUE(sensor.getMetrics(&t)); + TEST_ASSERT_EQUAL(2, countLines(runs)); +} + +void setup() +{ + initializeTestEnvironment(); + char tmpl[] = "/tmp/meshtastic-command-sensor-XXXXXX"; + scratchDir = mkdtemp(tmpl) ? tmpl : "/tmp"; + + UNITY_BEGIN(); + RUN_TEST(test_execCommand_captures_stdout_and_reports_success); + RUN_TEST(test_execCommand_nonzero_exit_is_failure); + RUN_TEST(test_execCommand_stdin_is_not_inherited); + RUN_TEST(test_execCommand_hung_command_is_killed_at_timeout); + RUN_TEST(test_execCommand_timeout_kills_the_whole_pipeline); + RUN_TEST(test_execCommand_closed_stdout_still_bounded_by_timeout); + RUN_TEST(test_execCommand_output_is_capped); + RUN_TEST(test_exec_returns_output_even_on_nonzero_exit); + RUN_TEST(test_parseValue_accepts_one_number_with_whitespace); + RUN_TEST(test_parseValue_rejects_anything_else); + RUN_TEST(test_canonicalType_is_case_insensitive_and_rejects_unknown); + RUN_TEST(test_apply_sets_value_and_has_flag); + RUN_TEST(test_apply_rounds_and_clamps_integer_fields); + RUN_TEST(test_apply_unknown_type_changes_nothing); + RUN_TEST(test_CommandSensor_not_added_without_config); + RUN_TEST(test_CommandSensor_applies_scale_and_skips_bad_output); + RUN_TEST(test_CommandSensor_all_failing_reports_no_metrics); + RUN_TEST(test_CommandSensor_caches_readings_then_refreshes); + const int failures = UNITY_END(); + + std::remove(scratchFile("runs").c_str()); + std::remove(scratchFile("pipeline-survived").c_str()); + rmdir(scratchDir.c_str()); + exit(failures); +} + +#else + +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +#endif + +void loop() {}