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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011C7D9dE4aUYBrGdpPyHs25
This commit is contained in:
Claude committed 2026-11-02 13:51:33 +00:00
1 parent 4c89b6937c
commit fc2fc97730
19 files changed
+1019 -15

No files matched your search

@@ -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<unsigned>(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