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() {}