diff --git a/firmware/src/cleaning_history.cpp b/firmware/src/cleaning_history.cpp index ac3a0cc..b68e7ed 100644 --- a/firmware/src/cleaning_history.cpp +++ b/firmware/src/cleaning_history.cpp @@ -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); @@ -244,7 +249,9 @@ void CleaningHistory::stopCollection() { float areaCovered = static_cast(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; @@ -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); diff --git a/firmware/src/cleaning_history.h b/firmware/src/cleaning_history.h index c8da08e..12c987e 100644 --- a/firmware/src/cleaning_history.h +++ b/firmware/src/cleaning_history.h @@ -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 @@ -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; diff --git a/firmware/src/config.h b/firmware/src/config.h index a2d1465..22fb3b9 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -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" diff --git a/firmware/src/notification_manager.cpp b/firmware/src/notification_manager.cpp index e58f04a..f30c180 100644 --- a/firmware/src/notification_manager.cpp +++ b/firmware/src/notification_manager.cpp @@ -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) { @@ -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; @@ -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"; @@ -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"); @@ -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; + doneTopic = topic; } // Clear tracking flag when leaving docking — but preserve it @@ -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; +} + +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()); diff --git a/firmware/src/notification_manager.h b/firmware/src/notification_manager.h index b73ed9a..1420874 100644 --- a/firmware/src/notification_manager.h +++ b/firmware/src/notification_manager.h @@ -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); }; diff --git a/firmware/src/settings_manager.cpp b/firmware/src/settings_manager.cpp index 34fe897..ff61303 100644 --- a/firmware/src/settings_manager.cpp +++ b/firmware/src/settings_manager.cpp @@ -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); @@ -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); @@ -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; @@ -363,6 +370,7 @@ std::vector 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}, @@ -456,6 +464,10 @@ bool Settings::fromFields(const std::vector& 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; diff --git a/firmware/src/settings_manager.h b/firmware/src/settings_manager.h index 32086dc..6f9dec5 100644 --- a/firmware/src/settings_manager.h +++ b/firmware/src/settings_manager.h @@ -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) diff --git a/frontend/mock/server.js b/frontend/mock/server.js index cc6182c..5cbc171 100644 --- a/frontend/mock/server.js +++ b/frontend/mock/server.js @@ -256,6 +256,7 @@ const state = { ntfyServer: "", ntfyToken: "", ntfyEnabled: true, + ntfyOnStart: true, ntfyOnDone: true, ntfyOnError: true, ntfyOnAlert: true, @@ -755,6 +756,7 @@ const routes = { "ntfyServer", "ntfyToken", "ntfyEnabled", + "ntfyOnStart", "ntfyOnDone", "ntfyOnError", "ntfyOnAlert", @@ -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; @@ -1114,6 +1117,7 @@ const handleRequest = async (req, res) => { "ntfyServer", "ntfyToken", "ntfyEnabled", + "ntfyOnStart", "ntfyOnDone", "ntfyOnError", "ntfyOnAlert", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c65caf3..447e2c6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -401,9 +401,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -421,9 +418,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -441,9 +435,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -461,9 +452,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -1204,9 +1192,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1221,9 +1206,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1238,9 +1220,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1255,9 +1234,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1272,9 +1248,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1289,9 +1262,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1306,9 +1276,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1323,9 +1290,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1340,9 +1304,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1357,9 +1318,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1374,9 +1332,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1391,9 +1346,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1408,9 +1360,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 548a299..d33ddcf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -64,6 +64,7 @@ export interface SettingsData { ntfyServer: string; // Custom ntfy server hostname (empty = ntfy.sh) ntfyToken: string; // Access token for authenticated ntfy servers (empty = no auth) ntfyEnabled: boolean; // Global switch — must be on for any notification to fire + ntfyOnStart: boolean; // Notify when a cleaning cycle begins ntfyOnDone: boolean; // Notify when cleaning completes ntfyOnError: boolean; // Notify on robot error (UI_ERROR_*, code 243+) ntfyOnAlert: boolean; // Notify on robot alert (UI_ALERT_*, code 201-242) diff --git a/frontend/src/views/settings.tsx b/frontend/src/views/settings.tsx index a32e8d7..9b7eaa3 100644 --- a/frontend/src/views/settings.tsx +++ b/frontend/src/views/settings.tsx @@ -110,6 +110,8 @@ export function SettingsView({ theme, onThemeChange, firmware }: SettingsViewPro setNtfyToken, ntfyEnabled, setNtfyEnabled, + ntfyOnStart, + setNtfyOnStart, ntfyOnDone, setNtfyOnDone, ntfyOnError, @@ -550,6 +552,19 @@ export function SettingsView({ theme, onThemeChange, firmware }: SettingsViewPro disabled={saving} placeholder="Access token (blank = no auth)" /> +
+
+ Cleaning started + When a cleaning cycle begins +
+
Cleaning done diff --git a/frontend/src/views/settings/constants.ts b/frontend/src/views/settings/constants.ts index 1941d60..58d0cfb 100644 --- a/frontend/src/views/settings/constants.ts +++ b/frontend/src/views/settings/constants.ts @@ -131,6 +131,7 @@ export const DEFAULT_SERVER = { ntfyServer: "", ntfyToken: "", ntfyEnabled: false, + ntfyOnStart: true, ntfyOnDone: true, ntfyOnError: true, ntfyOnAlert: true, diff --git a/frontend/src/views/settings/use-settings-form.ts b/frontend/src/views/settings/use-settings-form.ts index 7aebe34..6bd2907 100644 --- a/frontend/src/views/settings/use-settings-form.ts +++ b/frontend/src/views/settings/use-settings-form.ts @@ -26,6 +26,7 @@ export function useSettingsForm(errorStack: ErrorStackHandle, startRebootFlow: ( const [ntfyServer, setNtfyServer] = useState(""); const [ntfyToken, setNtfyToken] = useState(""); const [ntfyEnabled, setNtfyEnabled] = useState(false); + const [ntfyOnStart, setNtfyOnStart] = useState(true); const [ntfyOnDone, setNtfyOnDone] = useState(true); const [ntfyOnError, setNtfyOnError] = useState(true); const [ntfyOnAlert, setNtfyOnAlert] = useState(true); @@ -63,6 +64,7 @@ export function useSettingsForm(errorStack: ErrorStackHandle, startRebootFlow: ( setNtfyServer(fetched.ntfyServer ?? ""); setNtfyToken(fetched.ntfyToken ?? ""); setNtfyEnabled(fetched.ntfyEnabled ?? false); + setNtfyOnStart(fetched.ntfyOnStart ?? true); setNtfyOnDone(fetched.ntfyOnDone ?? true); setNtfyOnError(fetched.ntfyOnError ?? true); setNtfyOnAlert(fetched.ntfyOnAlert ?? true); @@ -92,6 +94,7 @@ export function useSettingsForm(errorStack: ErrorStackHandle, startRebootFlow: ( ntfyServer !== (server.current.ntfyServer ?? "") || ntfyToken !== (server.current.ntfyToken ?? "") || ntfyEnabled !== (server.current.ntfyEnabled ?? false) || + ntfyOnStart !== (server.current.ntfyOnStart ?? true) || ntfyOnDone !== (server.current.ntfyOnDone ?? true) || ntfyOnError !== (server.current.ntfyOnError ?? true) || ntfyOnAlert !== (server.current.ntfyOnAlert ?? true) || @@ -151,6 +154,7 @@ export function useSettingsForm(errorStack: ErrorStackHandle, startRebootFlow: ( if (ntfyServer !== (server.current.ntfyServer ?? "")) patch.ntfyServer = ntfyServer; if (ntfyToken !== (server.current.ntfyToken ?? "")) patch.ntfyToken = ntfyToken; if (ntfyEnabled !== (server.current.ntfyEnabled ?? false)) patch.ntfyEnabled = ntfyEnabled; + if (ntfyOnStart !== (server.current.ntfyOnStart ?? true)) patch.ntfyOnStart = ntfyOnStart; if (ntfyOnDone !== (server.current.ntfyOnDone ?? true)) patch.ntfyOnDone = ntfyOnDone; if (ntfyOnError !== (server.current.ntfyOnError ?? true)) patch.ntfyOnError = ntfyOnError; if (ntfyOnAlert !== (server.current.ntfyOnAlert ?? true)) patch.ntfyOnAlert = ntfyOnAlert; @@ -174,6 +178,7 @@ export function useSettingsForm(errorStack: ErrorStackHandle, startRebootFlow: ( ntfyServer, ntfyToken, ntfyEnabled, + ntfyOnStart, ntfyOnDone, ntfyOnError, ntfyOnAlert, @@ -250,6 +255,8 @@ export function useSettingsForm(errorStack: ErrorStackHandle, startRebootFlow: ( setNtfyToken, ntfyEnabled, setNtfyEnabled, + ntfyOnStart, + setNtfyOnStart, ntfyOnDone, setNtfyOnDone, ntfyOnError,