Files
firmware/src/modules/Telemetry/Sensor/CommandSensor.cpp
T
Claude a786edffcd portduino Sensors: add air quality types
Sensors: entries can now name AirQualityMetrics fields (pm25_standard,
pm100_standard, particle counts, co2, formaldehyde, VOC/NOx index, ...)
as well as EnvironmentMetrics fields.

- CommandSensorFields gains an AirQualityMetrics table and messageFor(),
  which says which telemetry message a Type belongs to. No field name
  appears in both. pm_status_flags is left out: it is a status bitmask,
  not a measurement.
- Each telemetry module owns one CommandSensor that runs only the entries
  for its message, so no command runs twice. The air-quality module
  registers its instance once (its i2cScanFinished() runs twice) and
  first, so it is read last and wins a shared field.
- Air-quality readings follow the air-quality module's own enable switch
  and interval. The boot warning now counts unread entries per module.
- --check's info line and config-dist.yaml say which setting each kind
  of type needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011C7D9dE4aUYBrGdpPyHs25
2026-11-04 23:01:19 +00:00

145 lines
5.2 KiB
C++

#include "configuration.h"
#if ARCH_PORTDUINO && (!MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR || !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR)
#include "CommandSensor.h"
#include "UptimeClock.h"
#include "mesh/Throttle.h"
#include <chrono>
#include <cmath>
// How often each command is re-run.
static constexpr uint32_t COMMAND_SENSOR_REFRESH_MS = 30 * 1000;
// How often the worker wakes to check for due commands and for shutdown.
static constexpr uint32_t COMMAND_SENSOR_POLL_MS = 100;
static constexpr size_t COMMAND_SENSOR_MAX_QUOTED_OUTPUT = 64;
CommandSensor::CommandSensor(CommandSensorFields::Message message)
: TelemetrySensor(meshtastic_TelemetrySensorType_SENSOR_UNSET, "Command"), message(message)
{
}
CommandSensor::~CommandSensor()
{
stopping = true;
if (worker.joinable())
worker.join();
}
bool CommandSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
{
for (const auto &sensor : portduino_config.command_sensors)
if (CommandSensorFields::messageFor(sensor.type) == message)
configs.push_back(sensor);
if (configs.empty())
return false;
readings.assign(configs.size(), Reading());
loggedGenerations.assign(configs.size(), 0);
LOG_INFO("Init sensor: %s, %u %s entries from config.yaml Sensors", sensorName, static_cast<unsigned>(configs.size()),
message == CommandSensorFields::Message::AirQuality ? "air quality" : "environment");
status = 1;
initialized = true;
worker = std::thread(&CommandSensor::run, this);
return true;
}
// Worker thread. Must not call LOG_*: portduino leaves RedirectablePrint's buffer unlocked, so results
// and problems are handed to getMetrics() to log on the main thread.
void CommandSensor::run()
{
while (!stopping) {
for (size_t i = 0; i < configs.size() && !stopping; i++) {
uint32_t readAtMs;
{
std::lock_guard<std::mutex> lock(mu);
readAtMs = readings[i].readAtMs;
}
if (readAtMs != 0 && !Throttle::hasElapsed(readAtMs, COMMAND_SENSOR_REFRESH_MS))
continue;
Reading fresh = read(configs[i]);
std::lock_guard<std::mutex> lock(mu);
fresh.generation = readings[i].generation + 1;
readings[i] = fresh;
}
std::this_thread::sleep_for(std::chrono::milliseconds(COMMAND_SENSOR_POLL_MS));
}
}
CommandSensor::Reading CommandSensor::read(const commandSensorConfig &sensor)
{
Reading reading;
reading.readAtMs = Time::stampMillis();
std::string output;
switch (execCommand(sensor.command.c_str(), COMMAND_SENSOR_TIMEOUT_MS, output)) {
case ExecResult::Ok:
break;
case ExecResult::TimedOut:
reading.problem = "timed out after " + std::to_string(COMMAND_SENSOR_TIMEOUT_MS) + "ms and was killed";
return reading;
case ExecResult::NotStarted:
reading.problem = "could not be started";
return reading;
default:
reading.problem = "exited with an error";
return reading;
}
float value;
if (!CommandSensorFields::parseValue(output, value)) {
while (!output.empty() && (output.back() == '\n' || output.back() == '\r'))
output.pop_back();
if (output.size() > COMMAND_SENSOR_MAX_QUOTED_OUTPUT)
output = output.substr(0, COMMAND_SENSOR_MAX_QUOTED_OUTPUT) + "...";
reading.problem = "printed '" + output + "', which is not a number";
return reading;
}
const float scaled = value * sensor.scale;
if (!std::isfinite(scaled)) {
reading.problem = "printed a value that is not a finite number once scaled";
return reading;
}
reading.value = scaled;
reading.valid = true;
return reading;
}
bool CommandSensor::getMetrics(meshtastic_Telemetry *measurement)
{
bool any = false;
std::lock_guard<std::mutex> lock(mu);
for (size_t i = 0; i < readings.size(); i++) {
const Reading &reading = readings[i];
const commandSensorConfig &sensor = configs[i];
if (reading.generation != loggedGenerations[i]) {
loggedGenerations[i] = reading.generation;
if (reading.valid)
LOG_DEBUG("Sensor %s: %f", sensor.type.c_str(), reading.value);
else
LOG_WARN("Sensor %s: command %s: %s", sensor.type.c_str(), reading.problem.c_str(), sensor.command.c_str());
}
if (!reading.valid)
continue;
if (message == CommandSensorFields::Message::Environment &&
measurement->which_variant == meshtastic_Telemetry_environment_metrics_tag)
any = CommandSensorFields::apply(measurement->variant.environment_metrics, sensor.type, reading.value) || any;
else if (message == CommandSensorFields::Message::AirQuality &&
measurement->which_variant == meshtastic_Telemetry_air_quality_metrics_tag)
any = CommandSensorFields::apply(measurement->variant.air_quality_metrics, sensor.type, reading.value) || any;
}
return any;
}
uint32_t CommandSensor::finishedReads()
{
std::lock_guard<std::mutex> lock(mu);
uint32_t total = 0;
for (const auto &reading : readings)
total += reading.generation;
return total;
}
#endif