Skip to content

Commit 9196025

Browse files
committed
Telemetry: put the reading history behind a storage interface
TelemetryStore<T> replaces the fixed in-RAM array inline in the module. at() copies rather than returning a reference, so a backing store need not be memory the caller can point into, and indices stay logical and oldest-first. RamTelemetryStore keeps the ring on the heap; over 2 KB that is PSRAM on ESP32 via heap_caps_malloc_extmem_enable(). Capacity follows MESHTASTIC_MEM_CLASS: 64 readings on PSRAM boards, 8 on nRF52. FileTelemetryStore keeps a fixed-size ring in a preallocated file, so readings outlive a reboot or deep sleep. The file never grows, slots are overwritten in place, and a header records the payload size so a build with a different struct layout rebuilds rather than decoding nonsense. The filesystem is a template parameter defaulting to FSCom. Enabled by defining AIR_QUALITY_TELEMETRY_HISTORY_PATH; falls back to RAM if the file cannot be opened. One contract test drives both backends. Verified on Portduino only: the read-write open mode differs between the string-mode and flag-mode filesystem backends, so nRF52 and STM32 need a hardware check before any variant enables the file store there.
1 parent bf6f359 commit 9196025

6 files changed

Lines changed: 593 additions & 195 deletions

File tree

src/modules/Telemetry/AirQualityTelemetry.cpp

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
#include "Router.h"
1313
#include "TransmitHistory.h"
1414
#include "UnitConversions.h"
15+
#include "UptimeClock.h"
1516
#include "detect/ScanI2CTwoWire.h"
17+
#ifdef AIR_QUALITY_TELEMETRY_HISTORY_PATH
18+
#include "FileTelemetryStore.h"
19+
#endif
1620
#include "gps/RTC.h"
1721
#include "graphics/ScreenFonts.h"
1822
#include "graphics/SharedUIDisplay.h"
@@ -175,8 +179,7 @@ int32_t AirQualityTelemetryModule::runOnce()
175179
return disable();
176180
}
177181

178-
// The local device-to-phone loop runs at its own near-realtime cadence; air_quality_interval
179-
// paces only what goes on air, and keeps the online-node scaling it has always had.
182+
// air_quality_interval paces only what goes on air; the local loop has its own cadence.
180183
const uint32_t meshIntervalMs = Default::getConfiguredOrDefaultMsScaled(
181184
moduleConfig.telemetry.air_quality_interval, default_telemetry_broadcast_interval_secs, numOnlineNodes);
182185

@@ -186,9 +189,9 @@ int32_t AirQualityTelemetryModule::runOnce()
186189
const bool meshAllowed =
187190
airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) &&
188191
airTime->isTxAllowedAirUtil();
189-
const bool meshDue = (lastMeshTelemetry == 0) || !Throttle::isWithinTimespanMs(lastMeshTelemetry, meshIntervalMs);
192+
const bool meshDue = (lastMeshTelemetry == 0) || Throttle::hasElapsed(lastMeshTelemetry, meshIntervalMs);
190193

191-
if (shouldReadSensors(everRead, millis(), lastReadMs, localLoopIntervalMs, service->isToPhoneQueueEmpty(),
194+
if (shouldReadSensors(everRead, Time::getMillis(), lastReadMs, localLoopIntervalMs, service->isToPhoneQueueEmpty(),
192195
meshDue && meshAllowed)) {
193196
const int32_t warmingUpMs = warmUpSensors();
194197
if (warmingUpMs > 0)
@@ -200,8 +203,8 @@ int32_t AirQualityTelemetryModule::runOnce()
200203
// Send to sleep sensors that can be to save power
201204
for (TelemetrySensor *sensor : sensors) {
202205
if (sensor->isActive() && sensor->canSleep()) {
203-
// Measured against the local loop, not the on-air interval: a sensor that cannot warm
204-
// back up within one loop would never produce a reading if it were slept
206+
// Against the local loop, not the on-air interval: a sensor that cannot warm back up
207+
// within one loop would never produce a reading if it were slept
205208
if (sensor->wakeUpTimeMs() < (int32_t)localLoopIntervalMs) {
206209
LOG_DEBUG("Disabling %s until next period", sensor->sensorName);
207210
sensor->sleep();
@@ -211,13 +214,12 @@ int32_t AirQualityTelemetryModule::runOnce()
211214
}
212215
}
213216

214-
if (shouldSendToMesh(history.hasUnpublishedNewest(TELEMETRY_PUBLISHED_MESH), meshDue, meshAllowed,
217+
if (shouldSendToMesh(history->hasUnpublishedNewest(TELEMETRY_PUBLISHED_MESH), meshDue, meshAllowed,
215218
isPowerSavingSensor())) {
216-
// Called even with nothing latched: sendTelemetry() also arms the pre-sleep sequence a
217-
// power-saving SENSOR needs to get back to deep sleep
219+
// Called even with nothing to send: sendTelemetry() arms the power-saving SENSOR's sleep
218220
if (sendTelemetry(NODENUM_BROADCAST, false) && transmitHistory)
219221
transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_AIR_QUALITY_TELEMETRY);
220-
} else if (history.hasUnpublishedNewest(TELEMETRY_PUBLISHED_PHONE) && service->isToPhoneQueueEmpty()) {
222+
} else if (history->hasUnpublishedNewest(TELEMETRY_PUBLISHED_PHONE) && service->isToPhoneQueueEmpty()) {
221223
// Mesh transmission isn't due yet, but we can still update the phone
222224
sendTelemetry(NODENUM_BROADCAST, true);
223225
}
@@ -229,9 +231,8 @@ int32_t AirQualityTelemetryModule::runOnce()
229231
return FIVE_SECONDS_MS;
230232
}
231233

232-
// Cadence is driven by lastReadMs, measured from the read itself, so a sensor warm-up sits
233-
// between two reads rather than being subtracted from the interval. Costs a few seconds of drift
234-
// per cycle and saves tracking it.
234+
// Cadence runs from the read itself, so a warm-up sits between two reads. Costs a few seconds
235+
// of drift per cycle and saves tracking it.
235236
return min(localLoopIntervalMs, result);
236237
}
237238

@@ -240,6 +241,7 @@ bool AirQualityTelemetryModule::shouldReadSensors(bool everRead, uint32_t nowMs,
240241
{
241242
if (!everRead)
242243
return true;
244+
// Subtract-first, as Throttle::hasElapsed() does, so the 32-bit wrap cancels
243245
return (phoneQueueEmpty || meshPublishDue) && (nowMs - lastReadMs) >= localIntervalMs;
244246
}
245247

@@ -254,8 +256,7 @@ int32_t AirQualityTelemetryModule::warmUpSensors()
254256
{
255257
int32_t pendingMs = 0;
256258

257-
// Every sensor is woken in the same pass: one with a long warm-up used to return early and keep
258-
// the others from ever starting theirs
259+
// All in one pass: a long warm-up used to return early and starve the others
259260
for (TelemetrySensor *sensor : sensors) {
260261
if (!sensor->canSleep()) {
261262
LOG_DEBUG("%s: no sleep support, skip", sensor->sensorName);
@@ -276,19 +277,36 @@ int32_t AirQualityTelemetryModule::warmUpSensors()
276277
return pendingMs;
277278
}
278279

280+
void AirQualityTelemetryModule::openHistory()
281+
{
282+
#ifdef AIR_QUALITY_TELEMETRY_HISTORY_PATH
283+
auto *persistent = makeFileTelemetryStore<meshtastic_AirQualityMetrics>(
284+
AIR_QUALITY_TELEMETRY_HISTORY_PATH, AIR_QUALITY_TELEMETRY_HISTORY_SIZE, AIR_QUALITY_TELEMETRY_HISTORY_FS);
285+
if (persistent->isUsable()) {
286+
history = persistent;
287+
return;
288+
}
289+
290+
// No card, full filesystem, whatever: measuring into RAM beats not measuring
291+
delete persistent;
292+
LOG_WARN("AQ history: %s unusable, keeping readings in RAM", AIR_QUALITY_TELEMETRY_HISTORY_PATH);
293+
#endif
294+
295+
history = new RamTelemetryStore<meshtastic_AirQualityMetrics>(AIR_QUALITY_TELEMETRY_HISTORY_SIZE);
296+
}
297+
279298
void AirQualityTelemetryModule::captureReading()
280299
{
281300
meshtastic_Telemetry m = meshtastic_Telemetry_init_zero;
282301

283302
if (getAirQualityTelemetry(&m)) {
284-
history.push(m.variant.air_quality_metrics, m.time);
303+
history->push(m.variant.air_quality_metrics, m.time);
285304
} else {
286305
LOG_WARN("AQ read failed, keep the previous reading");
287306
}
288307

289-
// Stamped either way, so a sensor that keeps failing retries on the read interval instead of on
290-
// every poll
291-
lastReadMs = millis();
308+
// Stamped either way, so a failing sensor retries on the interval rather than every poll
309+
lastReadMs = Time::getMillis();
292310
everRead = true;
293311
}
294312

@@ -434,8 +452,7 @@ bool AirQualityTelemetryModule::getAirQualityTelemetry(meshtastic_Telemetry *m)
434452
// There, if any sensor fails to read - valid = false.
435453
bool valid = false;
436454
bool hasSensor = false;
437-
// getTime() falls back to uptime-as-epoch when no clock has been set, which would stamp the
438-
// reading with a bogus 1970 date. 0 lets the receiver substitute its own receive time.
455+
// getTime() falls back to uptime-as-epoch when unset; 0 lets the receiver use its rx time
439456
m->time = getValidTime(RTCQualityDevice);
440457
m->which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
441458
m->variant.air_quality_metrics = meshtastic_AirQualityMetrics_init_zero;
@@ -472,17 +489,18 @@ meshtastic_MeshPacket *AirQualityTelemetryModule::allocReply()
472489
}
473490
// Check for a request for air quality metrics
474491
if (decoded->which_variant == meshtastic_Telemetry_air_quality_metrics_tag) {
475-
// Answer from what the local loop already measured. Reading the sensors here instead
476-
// would block on I2C and return nothing useful while they are asleep or warming up.
477-
if (history.isEmpty()) {
492+
// From the loop's last reading: reading here would block on I2C and return nothing
493+
// useful while the sensors are asleep or warming up.
494+
TelemetryReading<meshtastic_AirQualityMetrics> reading;
495+
if (!history->newest(reading)) {
478496
LOG_INFO("No air quality reading yet, no reply to request");
479497
return NULL;
480498
}
481499

482500
meshtastic_Telemetry m = meshtastic_Telemetry_init_zero;
483501
m.which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
484-
m.time = history.newest().time;
485-
m.variant.air_quality_metrics = history.newest().metrics;
502+
m.time = reading.time;
503+
m.variant.air_quality_metrics = reading.metrics;
486504
LOG_INFO("Air quality telemetry reply to request");
487505
return allocDataProtobuf(m);
488506
}
@@ -514,13 +532,14 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
514532
{
515533
bool sent = false;
516534
const TelemetryPublishChannel channel = phoneOnly ? TELEMETRY_PUBLISHED_PHONE : TELEMETRY_PUBLISHED_MESH;
535+
TelemetryReading<meshtastic_AirQualityMetrics> reading;
517536

518-
// Never send the same reading twice to the same destination
519-
if (history.hasUnpublishedNewest(channel)) {
537+
// One fetch then check the mask: on a file-backed store each read costs an open
538+
if (history->newest(reading) && !(reading.publishedMask & channel)) {
520539
meshtastic_Telemetry m = meshtastic_Telemetry_init_zero;
521540
m.which_variant = meshtastic_Telemetry_air_quality_metrics_tag;
522-
m.time = history.newest().time;
523-
m.variant.air_quality_metrics = history.newest().metrics;
541+
m.time = reading.time;
542+
m.variant.air_quality_metrics = reading.metrics;
524543

525544
logTelemetry(m);
526545

@@ -543,13 +562,13 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
543562
if (phoneOnly) {
544563
LOG_INFO("Sending packet to phone");
545564
service->sendToPhone(p);
546-
history.markNewestPublished(TELEMETRY_PUBLISHED_PHONE);
565+
history->markNewestPublished(TELEMETRY_PUBLISHED_PHONE);
547566
} else {
548567
LOG_INFO("Sending packet to mesh");
549568
service->sendToMesh(p, RX_SRC_LOCAL, true);
550-
history.markNewestPublished(TELEMETRY_PUBLISHED_MESH);
569+
history->markNewestPublished(TELEMETRY_PUBLISHED_MESH);
551570
// ccToPhone above hands the phone this very reading, no separate phone send needed
552-
history.markNewestPublished(TELEMETRY_PUBLISHED_PHONE);
571+
history->markNewestPublished(TELEMETRY_PUBLISHED_PHONE);
553572

554573
if (isPowerSavingSensor()) {
555574
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();

src/modules/Telemetry/AirQualityTelemetry.h

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,28 @@
1010
#define AIR_QUALITY_TELEMETRY_MODULE_ENABLE 0
1111
#endif
1212

13-
// How many local-loop readings to retain between offloads. Sized to roughly one LoRa frame's worth:
14-
// a populated AirQualityMetrics encodes to ~35 bytes inside a TelemetryRecord, so about six fit the
15-
// 233-byte Data.payload limit. Each slot costs sizeof(TelemetryReading<meshtastic_AirQualityMetrics>),
16-
// ~216 bytes; a variant short on RAM can lower this, one with room to spare can raise it.
13+
// Readings retained between offloads, ~216 bytes each. Floor is one LoRa frame's worth (~6 fit
14+
// Data.payload); PSRAM boards go past that for free, the ring is heap-allocated.
1715
#ifndef AIR_QUALITY_TELEMETRY_HISTORY_SIZE
16+
#if MESHTASTIC_MEM_CLASS >= MEM_CLASS_LARGE
17+
#define AIR_QUALITY_TELEMETRY_HISTORY_SIZE 64
18+
#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM
19+
#define AIR_QUALITY_TELEMETRY_HISTORY_SIZE 16
20+
#else
1821
#define AIR_QUALITY_TELEMETRY_HISTORY_SIZE 8
1922
#endif
23+
#endif
24+
25+
// Define AIR_QUALITY_TELEMETRY_HISTORY_PATH to persist readings instead of keeping them in RAM;
26+
// see the filesystem-choice note in FileTelemetryStore.h. Falls back to RAM if it cannot be opened.
27+
#ifndef AIR_QUALITY_TELEMETRY_HISTORY_FS
28+
#define AIR_QUALITY_TELEMETRY_HISTORY_FS FSCom
29+
#endif
2030

2131
#include "../mesh/generated/meshtastic/telemetry.pb.h"
2232
#include "NodeDB.h"
2333
#include "ProtobufModule.h"
24-
#include "TelemetryHistory.h"
34+
#include "TelemetryStore.h"
2535
#include "detect/ScanI2C.h"
2636
#include "detect/ScanI2CConsumer.h"
2737
#include <OLEDDisplay.h>
@@ -44,25 +54,24 @@ class AirQualityTelemetryModule : private concurrency::OSThread,
4454
lastMeasurementPacket = nullptr;
4555
nodeStatusObserver.observe(&nodeStatus->onNewStatus);
4656
setIntervalFromNow(10 * 1000);
57+
openHistory(); // safe here: fsInit() and setupSDCard() both run before setupModules()
4758
}
59+
60+
~AirQualityTelemetryModule() { delete history; }
4861
virtual bool wantUIFrame() override;
4962
#if !HAS_SCREEN
5063
void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
5164
#else
5265
virtual void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) override;
5366
#endif
5467

55-
// Pure local-loop policy: the device reads at its own near-realtime cadence so an attached phone
56-
// sees live values, independent of the on-air interval. Either consumer can drive the read - the
57-
// offload keeps the loop alive when a backed-up phone queue would otherwise stall it. everRead
58-
// (rather than a lastReadMs sentinel) marks the never-read state, so a read stamped at
59-
// millis()==0 still honors the cadence.
68+
// Local loop runs at its own cadence, independent of the on-air interval; either consumer can
69+
// drive it. everRead, not a lastReadMs sentinel, marks the never-read state.
6070
static bool shouldReadSensors(bool everRead, uint32_t nowMs, uint32_t lastReadMs, uint32_t localIntervalMs,
6171
bool phoneQueueEmpty, bool meshPublishDue);
6272

63-
// Pure offload policy: the mesh takes a sample of the local loop's readings on its own interval,
64-
// not every one of them. A power-saving SENSOR takes this path even with nothing left to send,
65-
// because sendTelemetry() is also what arms its deep sleep.
73+
// The mesh samples the loop on its own interval. A power-saving SENSOR takes this path even with
74+
// nothing to send, because sendTelemetry() is what arms its deep sleep.
6675
static bool shouldSendToMesh(bool haveUnsentReading, bool meshDue, bool meshAllowed, bool powerSavingSensor);
6776

6877
protected:
@@ -76,11 +85,7 @@ class AirQualityTelemetryModule : private concurrency::OSThread,
7685
*/
7786
bool getAirQualityTelemetry(meshtastic_Telemetry *m);
7887
virtual meshtastic_MeshPacket *allocReply() override;
79-
/**
80-
* Publish the newest reading in history to the mesh or the phone. Does nothing if nothing has
81-
* been read yet, or if this destination already had that reading.
82-
* @return true if a packet was handed off to send
83-
*/
88+
/// Publish the newest reading to the mesh or the phone, unless that destination already had it.
8489
bool sendTelemetry(NodeNum dest = NODENUM_BROADCAST, bool phoneOnly = false);
8590

8691
virtual AdminMessageHandleResult handleAdminMessageForModule(const meshtastic_MeshPacket &mp,
@@ -89,6 +94,7 @@ class AirQualityTelemetryModule : private concurrency::OSThread,
8994
void i2cScanFinished(ScanI2C *i2cScanner);
9095

9196
private:
97+
void openHistory();
9298
/// Read every sensor once and push the result into history with the time it was taken.
9399
void captureReading();
94100
/// Wake any sleeping sensor. @return ms to wait for the slowest one, 0 if all are ready.
@@ -98,14 +104,13 @@ class AirQualityTelemetryModule : private concurrency::OSThread,
98104
bool firstTime = true;
99105
meshtastic_MeshPacket *lastMeasurementPacket;
100106

101-
// Cadence of the local device-to-phone loop, and the thread's own tick. Deliberately unrelated
102-
// to air_quality_interval: that one paces what goes on air.
107+
// Local device-to-phone cadence and the thread's tick; unrelated to air_quality_interval,
108+
// which paces what goes on air.
103109
uint32_t localLoopIntervalMs = SECONDS_IN_MINUTE * 1000;
104110

105-
// What the local loop has measured, newest last. The offload publishes one of these readings
106-
// rather than triggering a read of its own, so one sensor warm-up feeds the phone, the mesh, the
107-
// screen and on-demand requests, and a packet is never restamped as fresher than it is.
108-
TelemetryHistory<meshtastic_AirQualityMetrics, AIR_QUALITY_TELEMETRY_HISTORY_SIZE> history;
111+
// The offload publishes one of these rather than reading again, so one warm-up feeds the phone,
112+
// the mesh, the screen and on-demand requests. Never null once openHistory() has run.
113+
TelemetryStore<meshtastic_AirQualityMetrics> *history = nullptr;
109114

110115
uint32_t lastReadMs = 0; // monotonic millis() of the last read attempt
111116
bool everRead = false; // false until the first read attempt, whatever its outcome

0 commit comments

Comments
 (0)