Skip to content

feat(notifications): cleaning-started ntfy + fix done summary race#2

Merged
Leicas merged 2 commits into
mainfrom
feat/ntfy-on-start
Apr 26, 2026
Merged

feat(notifications): cleaning-started ntfy + fix done summary race#2
Leicas merged 2 commits into
mainfrom
feat/ntfy-on-start

Conversation

@Leicas

@Leicas Leicas commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds ntfyOnStart (default on) — sends a ntfy when a cleaning cycle begins.
  • Fixes the "Cleaning done" notification on production where the data summary (battery, distance, area, duration) was missing.
  • Frontend mirrors the new toggle end-to-end.

Why the "done" stats were missing

Race in notification_manager.cpp vs cleaning_history.cpp:

  • NotificationManager::checkTransitions observes the cleaning → idle UI transition every poll cycle.
  • CleaningHistory::stopCollection finalizes lastCleanStats only inside an async getCharger callback (so it can record the end-of-session battery).
  • Result on prod: the notification fires before stats are written, stats.valid is false → no summary. Worse, the discard path never invalidated stats, so a short session could leak the previous run's numbers.

Fix: bumped a sessionId counter in both stop paths (success + discard); notification now defers send via donePending until sessionId increments or 5s timeout (bare-body fallback).

What ships

Firmware

  • cleaning_history.{h,cpp}LastCleanStats.sessionId, sessionCounter, bumped in success + discard paths.
  • notification_manager.{h,cpp}donePending state machine, flushPendingDone(), new "started" trigger gated to non-cleaning → CLEANINGRUNNING (excludes pause/recharge resume).
  • settings_manager.{h,cpp}, config.hntfyOnStart field, NVS key, full serdes.

Frontend

  • types.ts, views/settings/{constants,use-settings-form}.ts, views/settings.tsx, mock/server.js — full mirror, toggle placed before "Cleaning done".

Test plan

  • Build verification: pio run -e c3-debug (RAM 17%, Flash 83.8%), pio check (no new findings), npm run check, npm run build.
  • OTA flash to neato.int.weill-duflos.fr and verify in the field.

🤖 Generated with Claude Code

Antoine Weill--Duflos and others added 2 commits April 26, 2026 15:24
Adds a configurable "cleaning started" notification (ntfyOnStart, default
on) and fixes the bug where the "cleaning done" notification on production
was arriving without the stats summary.

The "done" race: NotificationManager observed the cleaning -> idle UI
transition before CleaningHistory::stopCollection's async getCharger
callback finalized lastCleanStats. Result: stats.valid was false, or
worse, stale data from the previous session leaked through (the discard
path never invalidated lastCleanStats). Fix: bumped sessionId counter in
both stop paths (success + discard), notification defers send via
donePending until sessionId increments or a 5s timeout elapses.

The start trigger fires on idle/non-cleaning -> CLEANINGRUNNING and
explicitly excludes resumes from CLEANINGPAUSED, CLEANINGSUSPENDED, and
DOCKING so pause/recharge resumes don't double-fire.

Frontend mirrors the new ntfyOnStart Settings field end-to-end (types,
defaults, form state, settings UI, mock server). Toggle is placed before
"Cleaning done" to follow the cleaning lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
npm version drift — drops libc fields from optional platform-specific
deps. No runtime impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 26, 2026 19:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new ntfy “Cleaning started” notification toggle (default on) across firmware + frontend, and reworks firmware “Cleaning done” notifications to avoid a race with asynchronous end-of-session stat finalization.

Changes:

  • Firmware: add ntfyOnStart setting and send a “Cleaning started” ntfy on fresh idle → cleaning transitions.
  • Firmware: defer “Cleaning done” notification until CleaningHistory session stats finalize (or timeout), using a sessionId mechanism.
  • Frontend: mirror the new ntfyOnStart setting end-to-end (types, defaults, mock server, settings UI).

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
frontend/src/views/settings/use-settings-form.ts Adds ntfyOnStart to form state, dirty-check, and PATCH building.
frontend/src/views/settings/constants.ts Adds ntfyOnStart default in DEFAULT_SERVER.
frontend/src/views/settings.tsx Adds the “Cleaning started” toggle to the Notifications section.
frontend/src/types.ts Extends SettingsData with ntfyOnStart.
frontend/mock/server.js Mirrors ntfyOnStart in mock state and update whitelist.
frontend/package-lock.json Lockfile normalization (removes some libc entries).
firmware/src/settings_manager.h Adds Settings::ntfyOnStart field.
firmware/src/settings_manager.cpp Persists/loads/applies/serializes ntfyOnStart.
firmware/src/config.h Adds NVS key NVS_KEY_NTFY_ON_START.
firmware/src/notification_manager.h Adds pending “done” state fields + helper methods.
firmware/src/notification_manager.cpp Adds “started” notification and defers “done” send via donePending + timeout.
firmware/src/cleaning_history.h Adds LastCleanStats.sessionId + sessionCounter.
firmware/src/cleaning_history.cpp Increments sessionId on success + discard to support “done” deferral.
Files not reviewed (1)
  • frontend/package-lock.json: Language not supported
Comments suppressed due to low confidence (1)

firmware/src/notification_manager.cpp:39

  • tick() calls flushPendingDone() before checking ntfyEnabled, ntfyTopic, and WiFi connectivity. Since sendNotification() doesn’t re-check those gates, a pending "done" notification can still be sent even after the user disables notifications/clears the topic or WiFi drops. Consider gating flushPendingDone() behind the same conditions (or dropping donePending without sending when notifications are disabled/topic empty/WiFi disconnected).
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;

    // Skip if notifications disabled, no topic configured, or WiFi not connected
    const Settings& s = settings.get();
    if (!s.ntfyEnabled || s.ntfyTopic.isEmpty() || WiFi.status() != WL_CONNECTED)
        return;

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +96 to +105
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;

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.
Comment on lines +145 to +155
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;

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.
@Leicas Leicas merged commit ebc85bb into main Apr 26, 2026
8 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants