Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion firmware/src/cleaning_history.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ void CleaningHistory::stopCollection() {
LOG("HIST", "Session discarded (%u snapshots < %d minimum)", snapshotCount, HISTORY_MIN_SNAPSHOTS);
dataLogger.logGenericEvent("history_discard", {{"snapshots", String(snapshotCount), FIELD_INT}});

// Mark stats invalid so NotificationManager doesn't enrich a "done"
// notification with stale data from the previous session.
lastCleanStats.valid = false;
lastCleanStats.sessionId = ++sessionCounter;

collecting = false;
recharging = false;
setInterval(HISTORY_INTERVAL_IDLE_MS);
Expand All @@ -244,7 +249,9 @@ void CleaningHistory::stopCollection() {

float areaCovered = static_cast<float>(visitedCells.size()) * HISTORY_AREA_CELL_M * HISTORY_AREA_CELL_M;

// Snapshot stats for notification enrichment (survives resetSession)
// Snapshot stats for notification enrichment (survives resetSession).
// sessionId increments last so NotificationManager can detect that
// the async charger fetch above has finalized the stats.
time_t endTime = systemManager.now();
lastCleanStats.valid = true;
lastCleanStats.mode = cleanMode;
Expand All @@ -256,6 +263,7 @@ void CleaningHistory::stopCollection() {
lastCleanStats.batteryStart = batteryStart;
lastCleanStats.batteryEnd = batteryEnd;
lastCleanStats.recharges = rechargeCount;
lastCleanStats.sessionId = ++sessionCounter;

LOG("HIST", "Collection stopped (%u snapshots, %.1fm², %d recharges)", snapshotCount, areaCovered,
rechargeCount);
Expand Down
5 changes: 5 additions & 0 deletions firmware/src/cleaning_history.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ struct LastCleanStats {
int batteryStart = -1; // Battery % at session start
int batteryEnd = -1; // Battery % at session end
int recharges = 0; // Mid-clean recharge count
// Bumped every time a session is finalized (success or discard) so that
// NotificationManager can detect when stopCollection's async charger fetch
// has completed and the stats above reflect the just-ended session.
uint32_t sessionId = 0;
};

// Session metadata returned by listSessions() — includes the raw JSON of
Expand Down Expand Up @@ -87,6 +91,7 @@ class CleaningHistory : public LoopTask {

// -- Last session stats (survives reset, updated at end of each session) --
LastCleanStats lastCleanStats;
uint32_t sessionCounter = 0; // Source of truth for lastCleanStats.sessionId

// -- State tracking ------------------------------------------------------
String prevUiState;
Expand Down
1 change: 1 addition & 0 deletions firmware/src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ enum CommandStatus {
#define NVS_KEY_NTFY_ON_ERR "ntfy_on_err"
#define NVS_KEY_NTFY_ON_ALERT "ntfy_on_alrt"
#define NVS_KEY_NTFY_ON_DOCK "ntfy_on_dock"
#define NVS_KEY_NTFY_ON_START "ntfy_on_strt"

// NVS keys — Schedule (ESP32-managed, not robot serial)
#define NVS_KEY_SCHED_ENABLED "sched_on"
Expand Down
70 changes: 57 additions & 13 deletions firmware/src/notification_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
#define NTFY_DEFAULT_HOST "ntfy.sh"
#define NTFY_CONNECT_TIMEOUT_MS 3000

// Max wall-clock to wait for CleaningHistory::stopCollection's async charger
// fetch to finalize stats before sending the "done" notification anyway.
#define NTFY_DONE_PENDING_TIMEOUT_MS 5000

NotificationManager::NotificationManager(NeatoSerial& neato, SettingsManager& settings, DataLogger& logger,
CleaningHistory& history) :
LoopTask(NOTIF_INTERVAL_IDLE_MS), neato(neato), settings(settings), dataLogger(logger), history(history) {
Expand All @@ -21,6 +25,11 @@ void NotificationManager::begin() {
}

void NotificationManager::tick() {
// Drain a pending "done" notification independently of the fetch path —
// it's waiting on CleaningHistory's async stop, not our own state fetch.
if (donePending)
flushPendingDone();

if (fetchPending)
return;

Expand Down Expand Up @@ -52,6 +61,7 @@ void NotificationManager::checkTransitions() {
if (!prevUiState.isEmpty()) {
bool wasCleaning = prevUiState.indexOf("CLEANINGRUNNING") >= 0;
bool wasDocking = prevUiState.indexOf("DOCKING") >= 0;
bool isCleaningRunning = ui.indexOf("CLEANINGRUNNING") >= 0;
bool isDocking = ui.indexOf("DOCKING") >= 0;
bool isSuspended = ui.indexOf("CLEANINGSUSPENDED") >= 0;
bool isIdle = ui == "UIMGR_STATE_IDLE" || ui == "UIMGR_STATE_STANDBY";
Expand All @@ -66,6 +76,14 @@ void NotificationManager::checkTransitions() {
// The UI state transitions DOCKING -> CLEANINGSUSPENDED once on the dock.
bool isRecharging = rs.indexOf("Charging_Cleaning") >= 0;

// Fresh start: idle -> CLEANINGRUNNING (excludes resume from
// pause/suspended and resume after mid-clean recharge dock).
bool prevInCleaningContext = prevUiState.indexOf("CLEANING") >= 0 ||
prevUiState.indexOf("DOCKING") >= 0;
if (isCleaningRunning && !prevInCleaningContext && cfg.ntfyOnStart) {
sendNotification(topic, "arrow_forward", hostname + ": Cleaning started");
}

if (isDocking && !wasDocking && isRecharging && cfg.ntfyOnDocking) {
// Recharge dock — robot will resume cleaning after charging
sendNotification(topic, "electric_plug", hostname + ": Returning to base to recharge");
Expand All @@ -75,19 +93,17 @@ void NotificationManager::checkTransitions() {
// Also handle suspended -> idle (user stops clean while recharging).
bool dockingDone = wasDocking && wasCleaningBeforeDock && !isRecharging;
bool suspendedDone = (prevUiState.indexOf("CLEANINGSUSPENDED") >= 0) && wasCleaningBeforeDock;
if ((wasCleaning || dockingDone || suspendedDone) && isIdle && cfg.ntfyOnDone) {
String msg = hostname + ": Cleaning done";
const LastCleanStats& stats = history.getLastCleanStats();
if (stats.valid) {
long mins = stats.durationSec / 60;
msg += "\n" + String(mins) + "min";
msg += " | " + String(stats.areaCoveredM2, 1) + "m2";
msg += " | " + String(stats.distanceM, 0) + "m";
if (stats.batteryStart >= 0 && stats.batteryEnd >= 0) {
msg += " | " + String(stats.batteryStart) + "% -> " + String(stats.batteryEnd) + "%";
}
}
sendNotification(topic, "white_check_mark", msg);
if ((wasCleaning || dockingDone || suspendedDone) && isIdle && cfg.ntfyOnDone && !donePending) {
// Defer the send: CleaningHistory::stopCollection finalizes stats
// inside an async getCharger callback, so reading getLastCleanStats()
// here can race and pull stale data from the prior session. Capture
// the current sessionId; flushPendingDone() fires once it increments
// (or after NTFY_DONE_PENDING_TIMEOUT_MS).
donePending = true;
doneTriggerSessionId = history.getLastCleanStats().sessionId;
donePendingSinceMs = millis();
doneHostname = hostname;
Comment on lines +96 to +105

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deferred "done" flow relies on flushPendingDone() being called again within NTFY_DONE_PENDING_TIMEOUT_MS (5s), but after the UI becomes idle this same tick sets the LoopTask interval to NOTIF_INTERVAL_IDLE_MS (30s). That means the pending notification may not flush for ~30s (and the 5s timeout is ineffective), causing delayed or bare "done" notifications. Consider temporarily forcing a short interval while donePending is true (or using a separate fast ticker) and restoring the normal active/idle interval once the pending notification is flushed.

Copilot uses AI. Check for mistakes.
doneTopic = topic;
}

// Clear tracking flag when leaving docking — but preserve it
Expand Down Expand Up @@ -126,6 +142,34 @@ bool NotificationManager::isActiveState(const String& uiState) {
uiState.indexOf("CLEANINGSUSPENDED") >= 0 || uiState.indexOf("DOCKING") >= 0;
}

void NotificationManager::flushPendingDone() {
const LastCleanStats& stats = history.getLastCleanStats();
bool finalized = stats.sessionId != doneTriggerSessionId;
bool timedOut = (millis() - donePendingSinceMs) >= NTFY_DONE_PENDING_TIMEOUT_MS;
if (!finalized && !timedOut)
return;

// If stopCollection finalized after we triggered, sessionId moved and stats
// are fresh; if we timed out, fire bare without stats rather than stale.
sendDoneNotification(doneTopic, doneHostname, finalized && stats.valid);
donePending = false;
Comment on lines +145 to +155

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flushPendingDone() assumes the just-finished session is finalized when stats.sessionId != doneTriggerSessionId. However, CleaningHistory::stopCollection() calls neato.getCharger(), and AsyncCache::get() can invoke callbacks synchronously on cache hits—so lastCleanStats.sessionId may already have been incremented before doneTriggerSessionId is captured. In that case finalized never becomes true and the code will eventually time out and send a bare notification even though fresh stats exist. Consider tracking the last observed sessionId in NotificationManager and, at trigger time, treating "already advanced since last poll" as finalized (send immediately with stats), otherwise wait for the increment.

Copilot uses AI. Check for mistakes.
}

void NotificationManager::sendDoneNotification(const String& topic, const String& hostname, bool withStats) {
String msg = hostname + ": Cleaning done";
if (withStats) {
const LastCleanStats& stats = history.getLastCleanStats();
long mins = stats.durationSec / 60;
msg += "\n" + String(mins) + "min";
msg += " | " + String(stats.areaCoveredM2, 1) + "m2";
msg += " | " + String(stats.distanceM, 0) + "m";
if (stats.batteryStart >= 0 && stats.batteryEnd >= 0) {
msg += " | " + String(stats.batteryStart) + "% -> " + String(stats.batteryEnd) + "%";
}
}
sendNotification(topic, "white_check_mark", msg);
}

void NotificationManager::sendNotification(const String& topic, const String& tags, const String& message) {
LOG("NOTIF", "Sending: [%s] %s", tags.c_str(), message.c_str());

Expand Down
11 changes: 11 additions & 0 deletions firmware/src/notification_manager.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@ class NotificationManager : public LoopTask {
// Pending state fetch tracking
bool fetchPending = false;

// Deferred "cleaning done" notification — captured at the cleaning->idle
// transition and held until CleaningHistory::stopCollection finalizes the
// session stats (sessionId increments) or a wall-clock timeout elapses.
bool donePending = false;
uint32_t doneTriggerSessionId = 0; // sessionId observed at trigger time
unsigned long donePendingSinceMs = 0;
String doneHostname; // captured hostname at trigger time
String doneTopic; // captured topic at trigger time

void checkTransitions();
void flushPendingDone();
void sendDoneNotification(const String& topic, const String& hostname, bool withStats);
void sendNotification(const String& topic, const String& tags, const String& message);
static bool isActiveState(const String& uiState);
};
Expand Down
12 changes: 12 additions & 0 deletions firmware/src/settings_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ void SettingsManager::load() {
current.ntfyServer = prefs.getString(NVS_KEY_NTFY_SERVER, "");
current.ntfyToken = prefs.getString(NVS_KEY_NTFY_TOKEN, "");
current.ntfyEnabled = prefs.getBool(NVS_KEY_NTFY_ENABLED, false);
current.ntfyOnStart = prefs.getBool(NVS_KEY_NTFY_ON_START, true);
current.ntfyOnDone = prefs.getBool(NVS_KEY_NTFY_ON_DONE, true);
current.ntfyOnError = prefs.getBool(NVS_KEY_NTFY_ON_ERR, true);
current.ntfyOnAlert = prefs.getBool(NVS_KEY_NTFY_ON_ALERT, true);
Expand Down Expand Up @@ -101,6 +102,7 @@ void SettingsManager::save() {
prefs.putString(NVS_KEY_NTFY_SERVER, current.ntfyServer);
prefs.putString(NVS_KEY_NTFY_TOKEN, current.ntfyToken);
prefs.putBool(NVS_KEY_NTFY_ENABLED, current.ntfyEnabled);
prefs.putBool(NVS_KEY_NTFY_ON_START, current.ntfyOnStart);
prefs.putBool(NVS_KEY_NTFY_ON_DONE, current.ntfyOnDone);
prefs.putBool(NVS_KEY_NTFY_ON_ERR, current.ntfyOnError);
prefs.putBool(NVS_KEY_NTFY_ON_ALERT, current.ntfyOnAlert);
Expand Down Expand Up @@ -286,6 +288,11 @@ ApplyResult SettingsManager::apply(const String& json) {
changed = true;
LOG("SETTINGS", "ntfy enabled -> %s", current.ntfyEnabled ? "on" : "off");
}
if (incoming.ntfyOnStart != current.ntfyOnStart) {
current.ntfyOnStart = incoming.ntfyOnStart;
changed = true;
LOG("SETTINGS", "ntfy on start -> %s", current.ntfyOnStart ? "on" : "off");
}
if (incoming.ntfyOnDone != current.ntfyOnDone) {
current.ntfyOnDone = incoming.ntfyOnDone;
changed = true;
Expand Down Expand Up @@ -363,6 +370,7 @@ std::vector<Field> Settings::toFields() const {
{"ntfyServer", ntfyServer, FIELD_STRING},
{"ntfyToken", ntfyToken, FIELD_STRING},
{"ntfyEnabled", ntfyEnabled ? "true" : "false", FIELD_BOOL},
{"ntfyOnStart", ntfyOnStart ? "true" : "false", FIELD_BOOL},
{"ntfyOnDone", ntfyOnDone ? "true" : "false", FIELD_BOOL},
{"ntfyOnError", ntfyOnError ? "true" : "false", FIELD_BOOL},
{"ntfyOnAlert", ntfyOnAlert ? "true" : "false", FIELD_BOOL},
Expand Down Expand Up @@ -456,6 +464,10 @@ bool Settings::fromFields(const std::vector<Field>& fields) {
ntfyEnabled = (f->value == "true");
applied = true;
}
if ((f = findField(fields, "ntfyOnStart")) && f->type == FIELD_BOOL) {
ntfyOnStart = (f->value == "true");
applied = true;
}
if ((f = findField(fields, "ntfyOnDone")) && f->type == FIELD_BOOL) {
ntfyOnDone = (f->value == "true");
applied = true;
Expand Down
1 change: 1 addition & 0 deletions firmware/src/settings_manager.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ struct Settings : public JsonSerializable {
String ntfyServer; // Custom server hostname (empty = ntfy.sh)
String ntfyToken; // Access token for authenticated servers (empty = no auth)
bool ntfyEnabled = false; // Global switch — must be on for any notification to fire
bool ntfyOnStart = true; // Notify when a cleaning cycle begins
bool ntfyOnDone = true; // Notify when cleaning completes
bool ntfyOnError = true; // Notify on robot error (UI_ERROR_*, code 243+)
bool ntfyOnAlert = true; // Notify on robot alert (UI_ALERT_*, code 201-242)
Expand Down
4 changes: 4 additions & 0 deletions frontend/mock/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ const state = {
ntfyServer: "",
ntfyToken: "",
ntfyEnabled: true,
ntfyOnStart: true,
ntfyOnDone: true,
ntfyOnError: true,
ntfyOnAlert: true,
Expand Down Expand Up @@ -755,6 +756,7 @@ const routes = {
"ntfyServer",
"ntfyToken",
"ntfyEnabled",
"ntfyOnStart",
"ntfyOnDone",
"ntfyOnError",
"ntfyOnAlert",
Expand Down Expand Up @@ -1076,6 +1078,7 @@ const handleRequest = async (req, res) => {
if (data.ntfyServer !== undefined) state.ntfyServer = data.ntfyServer;
if (data.ntfyToken !== undefined) state.ntfyToken = data.ntfyToken;
if (data.ntfyEnabled !== undefined) state.ntfyEnabled = data.ntfyEnabled;
if (data.ntfyOnStart !== undefined) state.ntfyOnStart = data.ntfyOnStart;
if (data.ntfyOnDone !== undefined) state.ntfyOnDone = data.ntfyOnDone;
if (data.ntfyOnError !== undefined) state.ntfyOnError = data.ntfyOnError;
if (data.ntfyOnAlert !== undefined) state.ntfyOnAlert = data.ntfyOnAlert;
Expand Down Expand Up @@ -1114,6 +1117,7 @@ const handleRequest = async (req, res) => {
"ntfyServer",
"ntfyToken",
"ntfyEnabled",
"ntfyOnStart",
"ntfyOnDone",
"ntfyOnError",
"ntfyOnAlert",
Expand Down
Loading
Loading