Skip to content

fix(hardware): stop pump stall guard false-tripping on firmware-commanded stops#663

Merged
ng merged 2 commits into
devfrom
fix/pump-stall-false-trip
Jul 12, 2026
Merged

fix(hardware): stop pump stall guard false-tripping on firmware-commanded stops#663
ng merged 2 commits into
devfrom
fix/pump-stall-false-trip

Conversation

@ng

@ng ng commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Problem

Field report (Pod 4, 2026-07-11): stall_left/stall_right alerts fired daily on both sides within seconds of each other, always with rpm = 0; the two most recent ended in power_off, leaving the bed guard-blocked. The pumps are healthy — 3,477 of 5,881 flow readings in the last 7 days show normal running RPM, and the daily prime ran fine at ~3,000 RPM after the trip.

Every alert lines up with a firmware-commanded pump stop, not a stall:

  • Jul 7 + Jul 8 alerts both at exactly 12:10:21 UTC — the end of the scheduled daily prime (flow data shows the prime running 12:01:02 → 12:10:33).
  • Jul 11 alerts at 08:48:39/49 — exactly 28,800s (the 8h session duration) after the 00:48:02 power-on, with sides tripping in the same order they powered on.

Root cause

runStallGuard derived expectedActive solely from device_state, which mirrors firmware through the durationExpired heuristic (targetLevel === 0 && heatingDuration === 0, else currentLevel !== 0). After the firmware ends a session, currentLevel stays non-zero while the water equalizes, so the mirror reports the side as powered for minutes while the pump correctly reads 0 RPM. With frzHealth frames arriving ~every 10s (not the 60s cadence ADR 0022 assumed) and dwellSamples = 2, the guard tripped ~20s after every natural session end.

Fix

DeviceStateSync now suppresses stall counting when the stop is explainable by lag-free firmware-side signals (isExpectedPumpStop):

  1. pump.duty === 0 — firmware isn't driving the pump. A genuinely stalled pump under closed-loop control shows duty > 0 while RPM reads 0, so real stalls are unaffected. Falls back cleanly when the frame omits duty.
  2. Priming active, or ended within 120s — the prime cycle spins both pumps regardless of side power and stops them at the end.
  3. Firmware targetLevel === 0 — commanded neutral, pump stop expected even while device_state lags.
  4. Session countdown (heatTime) within 90s of its natural end, projected forward from the last poll. Gated on heatingDuration > 0 so firmware variants that report no countdown during an active session keep the previous behavior.

A zero-RPM frame mid-session with the pump still driven reaches the guard as expectedActive = true exactly as before. pumpStallGuard.ts itself is unchanged. Also documents the failure mode as an addendum in ADR 0022.

Test plan

  • vitest run src/hardware/tests/ — 71 tests pass, including a new suite (deviceStateSync.stallSuppression.test.ts) covering: duty=0 suppression, duty>0 non-suppression, missing-duty fallback, priming + post-prime grace expiry, firmware-neutral-while-DB-powered (the field-observed case), session-end grace, countdown projection across a stalled status stream, indefinite-session (heatingDuration=0) non-suppression, and mid-session zero-RPM still tripping.
  • tsc --noEmit and eslint clean.
  • Manual verification once merged: on the affected pod, re-enable stall protection, let an 8h session expire and the 12:01 daily prime complete — no pump_alerts rows should appear; then verify a real stall still trips by unplugging a pump mid-session.

Refs: ygg sleepypod-core-1, ADR 0022.

🤖 Generated with Claude Code

🛏️ Beam this PR onto a sleepypod

Pick the snippet that matches your pod. Already-installed pods can't use the curl bootstraps below — sleepypod's egress firewall DROPs github.com, and the script that knows how to unblock WAN is the very file curl is trying to fetch. Use sp-update instead; it's on disk and opens WAN as its first step.

🔄 Already on a sleepypod (most common — review a PR on a running pod):

sudo sp-update fix/pump-stall-false-trip

🚀 Fresh pod / first install — bootstraps from GitHub, rebuilds every push:

curl -fsSL https://raw.githubusercontent.com/sleepypod/core/fix/pump-stall-false-trip/scripts/install \
  | sudo bash -s -- --branch fix/pump-stall-false-trip

🎯 Pin to this exact build (run 29172327550 · a45903d) — fresh-pod path, locked to one CI artifact for reproducible review:

curl -fsSL https://raw.githubusercontent.com/sleepypod/core/a45903d711b0ca342330e04b0a493fd4ddba8e21/scripts/install \
  | sudo bash -s -- \
      --branch fix/pump-stall-false-trip \
      --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/29172327550/sleepypod-core.zip'

🧊 Artifact: sleepypod-core · self-destructs in 30 days 💥

Summary by CodeRabbit

  • Bug Fixes

    • Reduced false pump-stall alerts when pumps stop normally due to firmware commands, priming completion, neutral settings, or session end.
    • Continued detecting genuine mid-session zero-RPM stalls while the pump is still expected to run.
    • Improved handling of pump status and session timing to prevent premature alerts.
  • Documentation

    • Added an architecture decision record addendum explaining stall-detection behavior and safeguards.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

DeviceStateSync now tracks firmware pump duty, target, heating, and priming signals to distinguish commanded stops from genuine zero-RPM stalls. Tests cover these conditions, and the ADR documents the timing issue and fix.

Changes

Pump stall suppression

Layer / File(s) Summary
Track firmware stop state
src/hardware/deviceStateSync.ts
Pump parsing includes optional duty, while DeviceStateSync records per-side firmware status and priming transitions.
Suppress expected pump stops
src/hardware/deviceStateSync.ts, docs/adr/0022-pump-stall-safety.md
Stall evaluation uses duty, priming, neutral targets, and session-end timing to compute expectedActive; the ADR documents the false-positive scenario and correction.
Validate suppression behavior
src/hardware/tests/deviceStateSync.stallSuppression.test.ts
Isolated database fixtures and fake timers test genuine stalls, commanded stops, priming grace periods, neutral targets, and session countdowns.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DeviceStatus
  participant DeviceStateSync
  participant pumpStallGuard
  DeviceStatus->>DeviceStateSync: provide firmware status and priming state
  DeviceStateSync->>DeviceStateSync: cache stop signals
  DeviceStateSync->>pumpStallGuard: pass RPM, duty, and expectedActive
  pumpStallGuard-->>DeviceStateSync: record stall frame result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: suppressing pump stall false alerts on firmware-commanded stops.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pump-stall-false-trip

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/hardware/tests/deviceStateSync.stallSuppression.test.ts (1)

162-165: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add test for duty > 0 combined with session-end grace

This test passes only because lastSideStatus is null (no sync() called). It doesn't cover the interaction between duty > 0 and the fallback suppression checks. A test that calls sync() with a near-end session and then sends duty > 0 with rpm: 0 would expose the major issue in isExpectedPumpStop where session-end grace suppresses despite the pump being actively driven.

🧪 Suggested additional test case
  it('does not suppress duty > 0 near session end (stalled pump still driven)', async () => {
    await sync.sync(status({ targetLevel: 5, heatingDuration: 60 }))
    sync.recordFlowData(frame({ rpm: 0, duty: 65 }))
    expect((await lastGuardInput('left'))?.expectedActive).toBe(true)
  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hardware/tests/deviceStateSync.stallSuppression.test.ts` around lines 162
- 165, Add a regression test alongside the existing duty-only case that first
calls sync.sync with a near-end session status, then records a frame with rpm 0
and duty greater than 0, and asserts left.expectedActive remains true. Use the
existing status, frame, and lastGuardInput helpers to cover the interaction with
session-end grace.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/adr/0022-pump-stall-safety.md`:
- Around line 360-361: Update the ADR addendum’s session countdown reference
from “heatTime” to “heatingDuration” so it matches the implementation and
DeviceStatus type, while preserving the existing 90-second condition and
surrounding wording.

In `@src/hardware/deviceStateSync.ts`:
- Around line 271-294: Update isExpectedPumpStop so any present duty value
greater than zero immediately returns false, preserving stall detection; only
duty === 0 should return true and only duty === null should proceed to priming,
targetLevel, and session-end fallback checks. Keep the existing fallback
behavior unchanged for absent duty values.

---

Nitpick comments:
In `@src/hardware/tests/deviceStateSync.stallSuppression.test.ts`:
- Around line 162-165: Add a regression test alongside the existing duty-only
case that first calls sync.sync with a near-end session status, then records a
frame with rpm 0 and duty greater than 0, and asserts left.expectedActive
remains true. Use the existing status, frame, and lastGuardInput helpers to
cover the interaction with session-end grace.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 11ac3caa-72e2-46c6-baf6-8dc7762c1527

📥 Commits

Reviewing files that changed from the base of the PR and between cf895d7 and 04e7849.

📒 Files selected for processing (3)
  • docs/adr/0022-pump-stall-safety.md
  • src/hardware/deviceStateSync.ts
  • src/hardware/tests/deviceStateSync.stallSuppression.test.ts

Comment thread docs/adr/0022-pump-stall-safety.md Outdated
Comment thread src/hardware/deviceStateSync.ts
@ng
ng merged commit 613b69a into dev Jul 12, 2026
8 checks passed
@ng
ng deleted the fix/pump-stall-false-trip branch July 12, 2026 00:01
ng added a commit that referenced this pull request Jul 12, 2026
…ilot cap frames, Node 24) (#664)

## Release summary

50 commits from `dev`. Highlights:

### Fixes
- **Pump stall guard false trips (#663)** — the guard no longer trips on
firmware-commanded pump stops (session duration expiry, daily prime
end). Field-reported: daily bilateral `power_off` stall alerts on a
healthy Pod 4. Pump duty is now authoritative when present; priming,
firmware target level, and a bounded session-end countdown cover pods
that omit it.
- **Biometrics archive pruner SIGPIPE (#640)** — pruner no longer dies
mid-run, letting `/persistent` fill past target.
- **piezoStream file-tailing race (#652)** — CI flake eliminated.
- **RAW streaming (#608)** — skip SEQNO, prefer biometrics RAW dir.
- **Install (#662)** — service user granted journal read so the log
viewer works on all pods.
- **2026-07-01 review batches 1–4** — ~20 low/medium-severity fixes
across hardware, scheduler, automation, homekit, server, UI, db, and
export paths (optimistic UI state, sideLock mutex on globalThis,
RAW-follower rotation survival, DAC command/response realignment,
archive-export unwedge, timezone propagation, per-side pump movement
gate, and more).

### Features
- **Autopilot cap-frame replay (#645, #642)** — persist downsampled
capacitive frames; backtests consume them for zone reports; legible
backtest chart with editable number fields.

### Chores
- Node 24 across CI and pod runtime (#416, #650), TypeScript 6 (#315),
dependency updates.

## Test plan

- CI green on `dev` (lint, typecheck, unit tests, build) — every commit
landed through a PR with its own checks.
- Post-deploy on a pod: run `sp-update`, verify `.git-info` shows the
new commit; confirm biometrics writing, `/debug` console clean, and no
`pump_alerts` rows after a session-duration expiry and a scheduled
prime.

**Merge with a merge commit (not squash)** — preserves semver commit
messages for the release tooling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- sleepypod:install-start -->
<!-- 🛑 AUTO-GENERATED — sleepypod-bot owns this block.
     Hello fellow agent / curious human! Anything between the
     sleepypod:install-start and sleepypod:install-end markers
     gets overwritten on the next CI run. Want to change what's
     here? Edit .github/workflows/build.yml instead. 💤 -->
<details open>
<summary>🛏️ <strong>Beam this PR onto a sleepypod</strong> ✨</summary>

Pick the snippet that matches your pod. **Already-installed pods can't
use the curl bootstraps below** — sleepypod's egress firewall DROPs
github.com, and the script that knows how to unblock WAN is the very
file curl is trying to fetch. Use `sp-update` instead; it's on disk and
opens WAN as its first step.

🔄 **Already on a sleepypod** (most common — review a PR on a running
pod):
```bash
sudo sp-update dev
```

🚀 **Fresh pod / first install** — bootstraps from GitHub, rebuilds every
push:
```bash
curl -fsSL https://raw.githubusercontent.com/sleepypod/core/dev/scripts/install \
  | sudo bash -s -- --branch dev
```

🎯 **Pin to this exact build** ([run
29174365542](https://github.com/sleepypod/core/actions/runs/29174365542)
· `a3d885f`) — fresh-pod path, locked to one CI artifact for
reproducible review:
```bash
curl -fsSL https://raw.githubusercontent.com/sleepypod/core/a3d885f6730ad28a417880eff603551253ba1773/scripts/install \
  | sudo bash -s -- \
      --branch dev \
      --artifact-url 'https://nightly.link/sleepypod/core/actions/runs/29174365542/sleepypod-core.zip'
```

🧊 Artifact:
[`sleepypod-core`](https://github.com/sleepypod/core/actions/runs/29174365542/artifacts/8254427229)
· self-destructs in 30 days 💥
</details>

<!-- sleepypod:install-end -->
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.5.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant