6b15571e14
* Start of MCP server and test suite * Add MCP server for interacting with meshtastic devices and testing framework / TUI * Update mcp-server/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix mcp-server review feedback from thread Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control * Semgrep fixes * Trunk and semgrep fixes * optimize pio streaming tee file writes Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * chore: remove redundant log handle assignment Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Consolidate type imports and remove placeholder test files * Add tests for config persistence and more exchange messages * Refactor position test to validate on-demand request/reply behavior * Remove position request/reply test and update README for telemetry behavior * Fix transmit history file to get removed on factory reset --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
315 lines
9.5 KiB
C++
315 lines
9.5 KiB
C++
#include "TransmitHistory.h"
|
|
#include "FSCommon.h"
|
|
#include "RTC.h"
|
|
#include "SPILock.h"
|
|
#include <Throttle.h>
|
|
|
|
#ifdef FSCom
|
|
|
|
TransmitHistory *transmitHistory = nullptr;
|
|
|
|
TransmitHistory *TransmitHistory::getInstance()
|
|
{
|
|
if (!transmitHistory) {
|
|
transmitHistory = new TransmitHistory();
|
|
}
|
|
return transmitHistory;
|
|
}
|
|
|
|
TransmitHistory::StoredTimestamp TransmitHistory::makeStoredTimestamp(uint32_t seconds, uint8_t flags)
|
|
{
|
|
StoredTimestamp stored;
|
|
stored.seconds = seconds;
|
|
stored.flags = flags;
|
|
return stored;
|
|
}
|
|
|
|
TransmitHistory::StoredTimestamp TransmitHistory::decodeLegacyTimestamp(uint32_t seconds)
|
|
{
|
|
const bool isProbablyBootRelative = seconds > 0 && seconds <= LEGACY_BOOT_RELATIVE_MAX_SEC;
|
|
return makeStoredTimestamp(seconds, isProbablyBootRelative ? ENTRY_FLAG_BOOT_RELATIVE : ENTRY_FLAG_NONE);
|
|
}
|
|
|
|
void TransmitHistory::loadFromDisk()
|
|
{
|
|
spiLock->lock();
|
|
auto file = FSCom.open(FILENAME, FILE_O_READ);
|
|
if (file) {
|
|
FileHeader header{};
|
|
if (file.read((uint8_t *)&header, sizeof(header)) == sizeof(header) && header.magic == MAGIC &&
|
|
(header.version == 1 || header.version == VERSION) && header.count <= MAX_ENTRIES) {
|
|
for (uint8_t i = 0; i < header.count; i++) {
|
|
if (header.version == 1) {
|
|
LegacyEntry entry{};
|
|
if (file.read((uint8_t *)&entry, sizeof(entry)) == sizeof(entry) && entry.epochSeconds > 0) {
|
|
history[entry.key] = decodeLegacyTimestamp(entry.epochSeconds);
|
|
}
|
|
} else {
|
|
Entry entry{};
|
|
if (file.read((uint8_t *)&entry, sizeof(entry)) == sizeof(entry) && entry.epochSeconds > 0) {
|
|
history[entry.key] = makeStoredTimestamp(entry.epochSeconds, entry.flags);
|
|
// Do NOT seed lastMillis here.
|
|
//
|
|
// getLastSentToMeshMillis() reconstructs a millis()-relative value
|
|
// from the stored epoch, and Throttle::isWithinTimespanMs() uses
|
|
// the same unsigned subtraction pattern. Once getTime() has a valid
|
|
// wall-clock epoch comparable to stored values, recent reboots still
|
|
// throttle correctly while long power-off periods no longer look like
|
|
// "just sent" and incorrectly suppress the first send.
|
|
//
|
|
// Before RTC/NTP/GPS time is valid, persisted absolute epochs do not
|
|
// contribute, but boot-relative entries still suppress near-term reboot
|
|
// chatter via a narrow recovery window.
|
|
//
|
|
// If we seeded lastMillis to millis() here, every loaded entry would
|
|
// appear to have been sent at boot time, regardless of the true age
|
|
// of the last transmission. That was the regression behind #9901.
|
|
}
|
|
}
|
|
}
|
|
LOG_INFO("TransmitHistory: loaded %u entries from disk", header.count);
|
|
} else {
|
|
LOG_WARN("TransmitHistory: invalid file header, starting fresh");
|
|
}
|
|
file.close();
|
|
} else {
|
|
LOG_INFO("TransmitHistory: no history file found, starting fresh");
|
|
}
|
|
spiLock->unlock();
|
|
dirty = false;
|
|
}
|
|
|
|
void TransmitHistory::setLastSentToMesh(uint16_t key)
|
|
{
|
|
lastMillis[key] = millis();
|
|
uint32_t now = getTime();
|
|
if (now >= 2) {
|
|
const uint8_t flags = (getRTCQuality() == RTCQualityNone) ? ENTRY_FLAG_BOOT_RELATIVE : ENTRY_FLAG_NONE;
|
|
history[key] = makeStoredTimestamp(now, flags);
|
|
dirty = true;
|
|
// Don't flush to disk on every transmit — flash has limited write endurance.
|
|
// The in-memory lastMillis map handles throttle during normal operation.
|
|
// Disk is flushed: before deep sleep (sleep.cpp) and periodically here,
|
|
// throttled to at most once per 5 minutes. Always save the first time
|
|
// after boot so a crash-reboot loop can't avoid persisting.
|
|
if (lastDiskSave == 0 || !Throttle::isWithinTimespanMs(lastDiskSave, SAVE_INTERVAL_MS)) {
|
|
if (saveToDisk()) {
|
|
lastDiskSave = millis();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#ifdef PIO_UNIT_TESTING
|
|
void TransmitHistory::setLastSentAtEpoch(uint16_t key, uint32_t epochSeconds)
|
|
{
|
|
if (epochSeconds > 0) {
|
|
history[key] = makeStoredTimestamp(epochSeconds, ENTRY_FLAG_NONE);
|
|
dirty = true;
|
|
} else {
|
|
history.erase(key);
|
|
lastMillis.erase(key);
|
|
}
|
|
}
|
|
|
|
void TransmitHistory::setLastSentAtBootRelative(uint16_t key, uint32_t secondsSinceBoot)
|
|
{
|
|
if (secondsSinceBoot > 0) {
|
|
history[key] = makeStoredTimestamp(secondsSinceBoot, ENTRY_FLAG_BOOT_RELATIVE);
|
|
dirty = true;
|
|
} else {
|
|
history.erase(key);
|
|
lastMillis.erase(key);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
uint32_t TransmitHistory::getLastSentToMeshEpoch(uint16_t key) const
|
|
{
|
|
auto it = history.find(key);
|
|
if (it != history.end()) {
|
|
return it->second.seconds;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
uint32_t TransmitHistory::getLastSentAbsoluteMillis(uint32_t storedEpoch) const
|
|
{
|
|
uint32_t now = getTime();
|
|
if (now < 2) {
|
|
return 0;
|
|
}
|
|
|
|
if (storedEpoch > now) {
|
|
return 0;
|
|
}
|
|
|
|
uint32_t secondsAgo = now - storedEpoch;
|
|
uint32_t msAgo = secondsAgo * 1000;
|
|
|
|
if (secondsAgo > 86400 || msAgo / 1000 != secondsAgo) {
|
|
return 0;
|
|
}
|
|
|
|
return millis() - msAgo;
|
|
}
|
|
|
|
uint32_t TransmitHistory::getLastSentBootRelativeMillis(uint32_t storedSeconds) const
|
|
{
|
|
if (getRTCQuality() != RTCQualityNone) {
|
|
return 0;
|
|
}
|
|
|
|
uint32_t now = getTime();
|
|
|
|
if (storedSeconds <= now) {
|
|
uint32_t secondsAgo = now - storedSeconds;
|
|
if (secondsAgo > BOOT_RELATIVE_RECOVERY_WINDOW_SEC) {
|
|
return 0;
|
|
}
|
|
return millis() - (secondsAgo * 1000);
|
|
}
|
|
|
|
uint32_t secondsAhead = storedSeconds - now;
|
|
if (secondsAhead > BOOT_RELATIVE_RECOVERY_WINDOW_SEC) {
|
|
return 0;
|
|
}
|
|
|
|
return millis();
|
|
}
|
|
|
|
uint32_t TransmitHistory::getLastSentToMeshMillis(uint16_t key) const
|
|
{
|
|
// Prefer runtime millis value (accurate within this boot)
|
|
auto mit = lastMillis.find(key);
|
|
if (mit != lastMillis.end()) {
|
|
return mit->second;
|
|
}
|
|
|
|
// Fall back to epoch conversion (loaded from disk after reboot)
|
|
auto it = history.find(key);
|
|
if (it == history.end() || it->second.seconds == 0) {
|
|
return 0; // No stored time — module has never sent
|
|
}
|
|
|
|
// Convert to a millis()-relative timestamp: millis() - msAgo.
|
|
//
|
|
// The result may wrap if msAgo is larger than the current uptime, and that is
|
|
// intentional. Throttle::isWithinTimespanMs() also uses unsigned subtraction,
|
|
// so the reconstructed age is preserved across wraparound:
|
|
// - recent reboot, 5 min ago -> (millis() - lastMs) == 300000, still throttled
|
|
// - long reboot, 30 min ago -> (millis() - lastMs) == 1800000, allowed
|
|
if ((it->second.flags & ENTRY_FLAG_BOOT_RELATIVE) != 0) {
|
|
return getLastSentBootRelativeMillis(it->second.seconds);
|
|
}
|
|
|
|
return getLastSentAbsoluteMillis(it->second.seconds);
|
|
}
|
|
|
|
bool TransmitHistory::saveToDisk()
|
|
{
|
|
if (!dirty) {
|
|
return true;
|
|
}
|
|
|
|
spiLock->lock();
|
|
|
|
FSCom.mkdir("/prefs");
|
|
|
|
// Remove old file first
|
|
if (FSCom.exists(FILENAME)) {
|
|
FSCom.remove(FILENAME);
|
|
}
|
|
|
|
auto file = FSCom.open(FILENAME, FILE_O_WRITE);
|
|
if (file) {
|
|
FileHeader header{};
|
|
header.magic = MAGIC;
|
|
header.version = VERSION;
|
|
header.count = (uint8_t)min((size_t)MAX_ENTRIES, history.size());
|
|
|
|
file.write((uint8_t *)&header, sizeof(header));
|
|
|
|
uint8_t written = 0;
|
|
for (const auto &[key, stored] : history) {
|
|
if (written >= MAX_ENTRIES)
|
|
break;
|
|
Entry entry{};
|
|
entry.key = key;
|
|
entry.epochSeconds = stored.seconds;
|
|
entry.flags = stored.flags;
|
|
file.write((uint8_t *)&entry, sizeof(entry));
|
|
written++;
|
|
}
|
|
file.flush();
|
|
file.close();
|
|
LOG_DEBUG("TransmitHistory: saved %u entries to disk", written);
|
|
dirty = false;
|
|
spiLock->unlock();
|
|
return true;
|
|
} else {
|
|
LOG_WARN("TransmitHistory: failed to open file for writing");
|
|
}
|
|
|
|
spiLock->unlock();
|
|
return false;
|
|
}
|
|
|
|
void TransmitHistory::clear()
|
|
{
|
|
history.clear();
|
|
lastMillis.clear();
|
|
dirty = false;
|
|
lastDiskSave = 0; // so the next legit broadcast persists immediately
|
|
|
|
spiLock->lock();
|
|
if (FSCom.exists(FILENAME)) {
|
|
FSCom.remove(FILENAME);
|
|
}
|
|
spiLock->unlock();
|
|
LOG_INFO("TransmitHistory: cleared in-memory state + on-disk file");
|
|
}
|
|
|
|
#else
|
|
// No filesystem available — provide stub with in-memory tracking
|
|
TransmitHistory *transmitHistory = nullptr;
|
|
|
|
TransmitHistory *TransmitHistory::getInstance()
|
|
{
|
|
if (!transmitHistory) {
|
|
transmitHistory = new TransmitHistory();
|
|
}
|
|
return transmitHistory;
|
|
}
|
|
|
|
void TransmitHistory::loadFromDisk() {}
|
|
|
|
void TransmitHistory::setLastSentToMesh(uint16_t key)
|
|
{
|
|
lastMillis[key] = millis();
|
|
}
|
|
|
|
uint32_t TransmitHistory::getLastSentToMeshEpoch(uint16_t key) const
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
uint32_t TransmitHistory::getLastSentToMeshMillis(uint16_t key) const
|
|
{
|
|
auto mit = lastMillis.find(key);
|
|
return (mit != lastMillis.end()) ? mit->second : 0;
|
|
}
|
|
|
|
bool TransmitHistory::saveToDisk()
|
|
{
|
|
return true;
|
|
}
|
|
|
|
void TransmitHistory::clear()
|
|
{
|
|
history.clear();
|
|
lastMillis.clear();
|
|
}
|
|
|
|
#endif
|