From a958eead1b606e52d1927a2f0e47a8c539a73942 Mon Sep 17 00:00:00 2001 From: Drew McCalmont Date: Tue, 23 Jun 2026 14:28:43 -0400 Subject: [PATCH] feat(cli): add `telemetry` and `sensors` CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two read-only CLI commands to the shared CommonCLI, available on any node with a SensorManager (sensor, repeater, etc.): - `telemetry` — queries current telemetry (battery voltage on the SELF channel plus everything querySensors() reports) and prints each channel with its decoded value and unit, e.g. `ch3 Temperature=22.4C`. - `sensors` — lists the active telemetry channels as an inventory (`ch `) without values. Both decode the CayenneLPP buffer via the shared LPPReader and format floats with StrHelper::ftoa (embedded `%f` is unreliable). Output mirrors the existing `sensor list` convention: an `N items` header, one line per entry, capped to the reply buffer with a `...` truncation marker. This fills the gap where telemetry could previously only be read over the mesh (companion Request Telemetry); it is now inspectable directly over the serial/admin CLI. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/helpers/CommonCLI.cpp | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b78ad6ebd6..cef92f9b71 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3,6 +3,7 @@ #include "TxtDataHelpers.h" #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" +#include "sensors/LPPDataHelpers.h" #include #ifndef BRIDGE_MAX_BAUD @@ -27,6 +28,101 @@ static bool isValidName(const char *n) { return true; } +// Human-readable label for a CayenneLPP data type, used by the +// "telemetry" and "sensors" CLI commands. +static const char* lppTypeName(uint8_t type) { + switch (type) { + case LPP_VOLTAGE: return "Voltage"; + case LPP_TEMPERATURE: return "Temperature"; + case LPP_RELATIVE_HUMIDITY: return "Humidity"; + case LPP_BAROMETRIC_PRESSURE: return "Pressure"; + case LPP_CURRENT: return "Current"; + case LPP_POWER: return "Power"; + case LPP_ALTITUDE: return "Altitude"; + case LPP_LUMINOSITY: return "Luminosity"; + case LPP_CONCENTRATION: return "Concentration"; + case LPP_GPS: return "GPS"; + default: return "Sensor"; + } +} + +// Decodes the scalar value for the LPP types that LPPReader exposes a +// typed reader for, advancing the reader past the data. Returns false +// (without advancing) for types with no scalar reader; the caller is +// then responsible for skipData()/special handling (e.g. GPS). +static bool readLppScalar(LPPReader& r, uint8_t type, float& v, const char*& unit) { + switch (type) { + case LPP_VOLTAGE: r.readVoltage(v); unit = "V"; return true; + case LPP_TEMPERATURE: r.readTemperature(v); unit = "C"; return true; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); unit = "%"; return true; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); unit = "hPa"; return true; + case LPP_CURRENT: r.readCurrent(v); unit = "A"; return true; + case LPP_POWER: r.readPower(v); unit = "W"; return true; + case LPP_ALTITUDE: r.readAltitude(v); unit = "m"; return true; + default: return false; // no scalar reader; does not advance + } +} + +// Queries current telemetry into a CayenneLPP buffer (battery voltage on +// the SELF channel, plus everything querySensors() reports) and decodes +// it to text. With include_values each line is "ch ="; +// otherwise it is an inventory of "ch ". Output is capped to the +// reply buffer the same way "sensor list" is. +static void formatTelemetry(mesh::MainBoard* board, SensorManager* sensors, + char* reply, bool include_values) { + CayenneLPP lpp(200); + lpp.reset(); + lpp.addVoltage(TELEM_CHANNEL_SELF, (float)board->getBattMilliVolts() / 1000.0f); + sensors->querySensors(0xFF, lpp); + + // First pass: count entries for the "%d ..." header. + LPPReader counter(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + int total = 0; + while (counter.readHeader(ch, type)) { counter.skipData(type); total++; } + + if (total == 0) { + strcpy(reply, include_values ? "no telemetry" : "no sensors"); + return; + } + + char* dp = reply; + sprintf(dp, "%d %s\n", total, include_values ? "channels" : "sensors"); + dp = strchr(dp, 0); + + LPPReader reader(lpp.getBuffer(), lpp.getSize()); + bool truncated = false; + while (reader.readHeader(ch, type)) { + if (dp - reply >= 134) { truncated = true; break; } // no room for another line + const char* name = lppTypeName(type); + if (!include_values) { + sprintf(dp, "ch%u %s\n", (unsigned)ch, name); + reader.skipData(type); + } else { + float v; const char* unit; + if (readLppScalar(reader, type, v, unit)) { + sprintf(dp, "ch%u %s=%s%s\n", (unsigned)ch, name, StrHelper::ftoa(v), unit); + } else if (type == LPP_GPS) { + float lat, lon, alt; + reader.readGPS(lat, lon, alt); + char slat[16]; + strcpy(slat, StrHelper::ftoa(lat)); // ftoa reuses a static buffer; copy before 2nd call + sprintf(dp, "ch%u GPS=%s,%s\n", (unsigned)ch, slat, StrHelper::ftoa(lon)); + } else { + reader.skipData(type); // unknown/undisplayable payload + sprintf(dp, "ch%u %s=?\n", (unsigned)ch, name); + } + } + dp = strchr(dp, 0); + } + + if (truncated) { + sprintf(dp, "..."); + } else if (dp > reply) { + *(dp - 1) = 0; // strip trailing newline + } +} + void CommonCLI::loadPrefs(FILESYSTEM* fs) { if (fs->exists("/com_prefs")) { loadPrefsInt(fs, "/com_prefs"); // new filename @@ -350,6 +446,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re *(dp-1) = 0; // remove last CR } } + } else if (memcmp(command, "telemetry", 9) == 0) { + formatTelemetry(_board, _sensors, reply, true); + } else if (memcmp(command, "sensors", 7) == 0) { + formatTelemetry(_board, _sensors, reply, false); } else if (memcmp(command, "region", 6) == 0) { handleRegionCmd(command, reply); #if ENV_INCLUDE_GPS == 1