Skip to content

Releases: LTimothy/nightstand

v3.4.0

v3.4.0 Pre-release
Pre-release

Choose a tag to compare

@LTimothy LTimothy released this 11 Jul 21:03

Presence-detection accuracy and honest status reporting.

Fixed

  • Correctness and UI fixes from a frontend audit. controlTempStore merged
    incoming device-status into the existing state object in place instead of
    building a fresh one, so the optimistic and server-derived state ended up
    sharing the same reference; it now merges into a new object. The Status page
    could briefly show "Updated" at the Unix epoch before the first fetch
    resolved (dataUpdatedAt starts at 0); it now waits for real data. The
    Clock component rebuilt both its alignment and tick timers on every render
    because the effect was keyed on the clock value itself, occasionally firing
    two ticks at the minute boundary; it now aligns once and ticks on a stable
    interval. The water-level notification showed the raw internal
    waterLevel debug value to users, had a spelling error in "included", and
    linked its report button to the wrong repo's issues; all three are fixed.
    ControlTempPage no longer throws if a loaded settings object is missing a
    side's awayMode key, and the device settings reboot/wifi row's alignment
    and stray margin are corrected.
  • An explicit 0°F target temperature was silently dropped.
    targetTemperatureF is a number, and updateDeviceStatus.ts checked it
    with a plain truthiness test, so setting a side's target to exactly 0
    never reached the hardware. The check now tests for undefined instead.
  • Turning biometrics off in Settings never actually stopped the stream
    service.
    The toggle only wrote biometrics.enabled: false to the
    database; scripts/disable_biometrics.sh already did the right
    systemctl stop/disable but was never wired up. POST /services now
    triggers it whenever biometrics is explicitly turned off, with a sudoers
    rule for fresh installs and a self-heal path for pods that installed
    before this change.
  • Status page could get stuck showing "needs attention" for two biometrics
    jobs that weren't actually broken.
    The daily calibration job reported a
    correct, intentional skip (bed occupied at the scheduled time) as a
    failure, which could hold "Calibration job" on "needs attention" for up to
    24 hours even though the previous calibration was still valid; it no
    longer touches job status on a skip. Separately, update_pump_health
    could latch a pump-stall warning and then freeze in that state once a side
    powered off and stopped sending live pump readings, since the code
    skipped frames with missing data instead of letting the warning clear;
    missing readings are now treated the same as "not actively heating or
    cooling," so the warning clears once a side goes quiet.
  • The calibration job's occupancy guard could see "occupied" when nobody
    was in bed, silently skipping calibration.
    The live presence detector's
    exit timer reset to zero on any single frame where a side read above the
    noise threshold. A piezo permanently loaded by pillows or a mattress
    topper idles close to that threshold and spikes above it every few
    minutes, so an empty-but-loaded side could stay latched "present" for
    hours and repeatedly block the nightly calibration run. A single spike no
    longer resets the exit timer; only a short sustained run of dominant
    frames counts as a real return. A separate, deeper fix (per-side baseline
    learning) is tracked for later.
  • A departed or empty side of the bed could keep having heart rate, HRV,
    and breathing rate written to the database.
    BiometricProcessor.next()
    inserted vitals purely on a tick counter, with no check of whether that
    side was actually occupied. On one incident night, the left side kept
    inserting vitals for about 20 minutes after the left occupant got up: its
    stored heart rate first converged on the right occupant's real heart rate
    (picked up mechanically through the shared mattress frame), then, once
    both occupants had left, settled into logging noise on a genuinely empty
    bed. Vitals insertion is now gated on the existing per-side self.present
    flag. This closes the empty-bed case completely. It does not fully close
    the cross-mattress case: if live presence detection is itself still
    (incorrectly) reporting a side as present because it is picking up the
    other side's signal, this gate has nothing to catch, since the flag it
    reads is wrong in that moment. That crosstalk problem is a separate,
    already-tracked presence-detection issue and needs its own fix.
  • The Logs viewer's pause buffer had no cap. The visible logs list was
    already trimmed to the last 1000 lines, but pendingLogs, which buffers
    incoming lines while the viewer is paused, was not. Left paused against a
    busy log file (the biometrics stream writes several times a second) with
    the tab open, that buffer could grow without limit. This is a defensive
    fix, not a confirmed cause of any specific crash: pendingLogs is now
    capped the same way logs already was.
  • Offline sleep detection: capacitance-sensor presence never fired, making
    the piezo+cap fusion piezo-only in practice.
    create_cap_baseline_from_cap_df
    floors each sensor's calibration std at a minimum of 5 before it is used as
    a z-score denominator in detect_presence_cap. That floor was sized for
    older capSense hardware, whose raw values run in the hundreds to low
    thousands; Pod 5's capSense2 records get pair-averaged down to a much
    smaller scale (roughly 9-25), where the real empty-bed std measures under 1.
    The floor of 5 dominated every sensor's z-score by two to three orders of
    magnitude and suppressed real occupied-vs-empty signal enough that cap
    presence fired on 0.0-0.1% of confirmed-occupied samples on a known-occupied
    night, on both sides. The default floor is now 1, comfortably above the
    measured empty-bed noise ceiling on this hardware; replayed against the same
    incident night, cap presence now fires on 90.8-99.6% of confirmed-occupied
    samples with no false positives across three independent confirmed-empty
    stretches.
  • A single garbage sensor reading could blow up presence detection's signal
    range and latch a false "present" state for hours.
    _range_p98_p2 had no
    sanity check on raw piezo values, so an occasional sensor glitch (values
    around 2^31, consistent with signed 32-bit overflow) flowed straight into
    the percentile calculation and produced a range many times larger than any
    real occupied reading. Raw samples are now masked out if their magnitude
    exceeds a sane ceiling, checked symmetrically since int32 overflow wraps to
    large negative values just as readily as large positive ones. The ceiling
    sits well above the highest confirmed legitimate reading and well below the
    known garbage magnitude, so real signal, including full-scale sensor
    readings during vigorous motion, is untouched.
  • A fresh install, or a pod that just had biometrics turned on, could show
    calibration or sleep analysis as "failed" on the Status page for no real
    reason.
    Both jobs need archived RAW sensor data to run; on a fresh
    install there isn't any yet, so they raised and that raise was reported the
    same way as a genuine failure. They now report a distinct "collecting
    data" status instead, shown as an informational chip rather than a
    needs-attention one, and left alone by the page's unhealthy-status
    checks. Also fixes a real bug found while making this change: calibration
    could silently save a sensor baseline built over occupied bed time when no
    clean empty-bed window had been recorded yet, which skews presence
    detection toward reading the bed as occupied. That case is now treated the
    same way as the missing-data case instead of being saved.
  • Strong crosstalk between the two sides of the bed could keep an empty
    side reading as "present" for hours.
    When both sides read above the
    noise floor and neither is clearly the louder one, live presence detection
    could not tell genuine two-person occupancy apart from one side picking up
    the other's movement through the bed frame, so it froze the exit timer
    entirely rather than risk a wrong call. Under strong-enough crosstalk that
    freeze never resolved. Each side now also tracks a rolling low-percentile
    "floor" of its own recent signal and compares it against its own learned
    occupied level; once that floor collapses toward what a genuinely empty,
    crosstalk-only side looks like, the exit timer resumes counting instead of
    staying frozen indefinitely. A capped fallback also guarantees the freeze
    can never last forever even in an inconclusive case. The fix is
    deliberately conservative: it is biased to keep a real, quiet sleeper
    present rather than risk cutting a session short, so it narrows this
    problem rather than fully closing it.

v3.3.0

v3.3.0 Pre-release
Pre-release

Choose a tag to compare

@LTimothy LTimothy released this 10 Jul 18:43

[3.3.0] - 2026-07-10

Trust in the numbers, honest provenance, and a hardening pass.

Changed

  • Offline sleep-presence detection now measures within-second signal
    amplitude instead of a DC drift proxy.
    The nightly analyzer decided bed
    occupancy by thresholding the second-to-second drift of each piezo second's
    mean, a slowly-moving DC offset that carries no information about the
    vibration amplitude a body actually produces. It false-fired on an empty bed
    whenever the water pump cycled. Detection now uses the same per-second p98-p2
    waveform range the live stream relies on, with the production-tuned 150k noise
    floor separating "bed empty" from "someone in the bed", so pump spikes no
    longer cross it. Phantom empty-bed presence disappears; on a normal night
    session boundaries move by at most a few minutes, and the final exit is
    detected the moment the signal drops below the floor. Known limit, documented
    and not yet fixed: the piezo sensors pick up the other side of the bed through
    the frame, so when two people leave at different times the earlier riser's
    exit is still reported near the later one's. Correct per-side attribution is a
    separate upcoming change.

Fixed

  • Replaced the adjustable-base visualization's inherited bed frames with
    original artwork.
    The 67 photorealistic per-degree PNG frames came from the
    inherited tree with no attribution in their history and were renders of the
    Pod's mattress. Because their provenance was unclear and this repo is public,
    they were replaced rather than shipped. The visualization is now a single
    parametric SVG (two hinged panels either side of a fixed centre platform)
    drawn in the same rounded-stroke language as the preset icons, with the same
    head and feet inputs and no external image assets. A follow-up polish pass
    added a gradient mattress, quilting texture, eased angle tweens, and preset
    icons generated from the same geometry the bed itself uses, so a button's icon
    always matches what pressing it draws. The rest of the icon set was audited
    alongside and is original work.
  • Security hardening across the API surface. A path-traversal arbitrary file
    read on GET /api/logs/:filename (a request like /api/logs/../../etc/passwd
    could read any file the server process can reach, CWE-22) is closed by
    rejecting any filename whose basename doesn't match itself or that doesn't end
    in .log. The CORS origin allow-list no longer trusts entire RFC1918 ranges;
    it derives the pod's actual subnets from its network interfaces, correctly
    handling a Tailscale interface alongside the LAN one. POST /api/execute now
    range-checks numeric hardware arguments against the same bounds
    /api/deviceStatus already enforces. POST /api/settings merges validated
    data instead of the raw body, so extra properties can't write arbitrary keys
    into settings storage. The install-time clock sync moved from plaintext HTTP to
    HTTPS, closing a LAN-in-the-middle clock-spoofing path. The documented
    LAN-only, no-auth trust model is unchanged and deliberate; these close holes
    that sit underneath it.
  • Patched known dependency advisories in both server/ and app/ via
    npm audit fix, with no forced major bumps and both package.json files
    untouched. Test suites, typecheck, and lint are unchanged before and after.

v3.2.0

Choose a tag to compare

@LTimothy LTimothy released this 10 Jul 18:43

[3.2.0] - 2026-07-10

The in-app update system: pick a version, roll back instantly, migrate in.

Added

  • A release manifest and in-app changelog. releases.json at the repo root
    lists every release, its channel (stable or beta), its date, and sha256
    hashes of the committed build artifacts, gated by a release-ritual test that
    fails the build if the manifest and serverInfo.json ever drift apart. The
    update alert shows a "What's new" summary between the running and latest
    builds, and a Changelog page shows the full history parsed from the pod's own
    copy, with not-yet-installed entries prepended and marked.
  • Update channels, a version picker, and instant rollback. A "Software &
    updates" Settings page lets you choose the stable or beta channel, browse
    every release with its notes and install any specific version including
    downgrades (data is kept, migrations stay additive by standing rule), and roll
    back to the previous install in seconds with no download. update.sh
    understands a target-version request, resolves it against the manifest,
    downloads the right tag archive, refuses a downgrade unless explicitly
    allowed, and skips the migration step on a downgrade. rollback_pod.sh and its
    service swap the live install back to the tree the updater already keeps at
    free-sleep-prev, health-check, and swap back on failure.
  • Fork-switch tool. A safety-obsessed migration script for anyone running
    another fork who wants to move to this one. It locates and identifies the pod
    read-only, reports what it found and what it would do, requires a typed
    confirmation, then backs up the pod's code and data both on the pod and pulled
    to your laptop (integrity-verified in both places) before touching anything. A
    data-compatibility dry run loads your existing settings and schedules through
    this fork's schemas and aborts on a real incompatibility. A dead-man sentinel
    auto-restores your original fork within minutes if the installer is killed,
    OOM'd, or the pod loses power mid-swap, with no laptop or user action needed.
    It refuses Pod 1/2 outright and requires a typed acknowledgment for Pod 3/4.

v3.1.0

Choose a tag to compare

@LTimothy LTimothy released this 10 Jul 18:43

[3.1.0] - 2026-07-09

Reliability under real hardware, honest logs, and biometrics accuracy.

Added

  • Live biometrics from the pod's own message bus. The stream service
    consumes NATS JetStream directly (durable consumer, sequence deduplication)
    instead of tailing RAW files, eliminating file-truncation races and lag, and
    falls back to the RAW watcher automatically if NATS is unavailable. Adapted
    from SFenton/free-sleep.
  • Multiple alarms per day, with a chip row to add, select, and remove them,
    durations up to five minutes, and the legacy single-alarm field mirrored for
    compatibility. Server model adapted from SFenton/free-sleep.
  • Pump-stall detection. When the pump stalls while the heating/cooling
    element keeps drawing current, the hub water sensor reads stagnant water next
    to a powered heater and reports a runaway temperature that isn't the bed's.
    The pod already writes pump RPM, flow state, and element current into the RAW
    stream; those are now parsed, watched per side with a dwell window in both
    directions, and surfaced on the Status page. Detection and visibility only, no
    automatic power-off yet.
  • Storage, memory, and a redesigned Status page. Indicators for /persistent
    usage (with a breakdown of the archive and logs) and for RAM headroom on a pod
    with no swap. The 17 backend service checks are grouped into Schedules,
    Biometrics and sensors, and a collapsible Core services section that only
    expands when something needs attention, with plain-English descriptions and a
    clear meaning for each status.
  • A clock on the main page that ticks on the browser's own time, so it stays
    correct regardless of pod clock drift.

Fixed

  • A wedged Franken command can no longer hang its caller forever. The
    per-command timeout only covered reading the reply; time queued or blocked on
    the socket write was unguarded, so one stuck command silently pinned every
    later command and every /api/deviceStatus coalesced onto it. Commands now
    carry a total deadline that logs where they got stuck, tears down the
    connection, and reconnects, so the server self-heals instead of hanging.
  • /api/deviceStatus no longer blocks for 25-30s on a cold start. It returns
    a fast 503 while Franken is still connecting instead of queueing behind the
    handshake, so deploy and update health checks (and browsers) get a fast,
    accurate answer and can retry.
  • Concurrent job-status writes no longer clobber each other. The biometrics
    jobs POST status independently and landed within milliseconds; without
    serialization the second write silently overwrote the first, leaving a job
    stuck showing "Started" for hours after it had failed. Writes are serialized,
    a temp-file watcher event that triggered a full reschedule on every write is
    fixed, and a status still reading "started" across a restart is reconciled to
    "failed".
  • Shutdown could crash on a concurrent teardown when the connect-retry loop
    and SIGTERM tore down the Franken singletons at the same moment. They are now
    captured into locals and cleared synchronously before any await.
  • Honest Python logging. The Node wrapper logged all Python stderr (including
    routine DEBUG output) as errors, burying real problems; only a non-zero exit
    is a failure now, and a failing script's stderr is included instead of dropped.
    Unbounded log growth in the biometrics jobs (a backupCount=0 that disabled
    rotation entirely) and the Node server's silent truncation at 7MB are both
    fixed with real rotation history.
  • Log viewer reads only the bytes appended since the last read instead of
    re-sending the whole file on every write, with a filter box, pause/resume,
    clear, download, and level-aware coloring.
  • Pod 5 capacitance data parses now: capSense2 records (8 values per side)
    are normalized onto the legacy channels at load time, so calibration and sleep
    analysis stop finding zero rows and failing.
  • Presence detection raised its empty-bed noise floor to 150k after an empty side
    idling near the old threshold kept presence latched for hours.

Changed

  • New original app icon (a crescent moon over a temperature bar); the previous
    icon closely resembled the official app's.
  • Added docs/EIGHT_SLEEP_PROTOCOL.md, a consolidated dac.sock command and RAW
    telemetry reference written from our own research and cross-checked against
    other reverse-engineering projects, with every entry tagged by verification
    status.

v3.0.0

Choose a tag to compare

@LTimothy LTimothy released this 10 Jul 18:43

[3.0.0] - 2026-07-08

Initial release of Nightstand's own version stream, a hard fork descending from
jmew/free-sleep (presence detection, sleep stages, adjustable-base BLE control,
WebSocket live updates) and throwaway31265/free-sleep before it.

Added

  • Own semver stream independent of upstream, with upstreamBase tracked in
    serverInfo.json for deliberate, reviewed cherry-picking instead of
    auto-merging.
  • In-app updater: the Settings Update button pulls new builds directly from this
    project's GitHub, with backup, health check, and automatic rollback on failure.
  • RAW-file archive so overnight biometrics survive the firmware's ~75-minute
    rolling-buffer truncation, and LAN deploy tooling (ops/deploy.sh,
    ops/rollback.sh) with backup and rollback for development and emergencies.
  • Manual power-on now starts at the scheduled temperature, applying the
    temperature the schedule would have reached by now. Adapted from
    EpicPi/free-sleep. Temperature display respects the stored Fahrenheit or level
    format instead of hardcoding one.
  • App and Python test runners, and a hosted mocked demo deployed to GitHub Pages
    on every push so the app can be tried in a browser without a Pod.

Fixed

  • Pod 5 biometrics: RAW records are no longer skipped. cbor2's C extension
    read in 4096-byte chunks, so nearly every ~2700-byte Pod 5 record was silently
    dropped and empty placeholder records ended the read loop early. Records are
    now parsed with a byte-accurate reader, adapted from the original project's PRs
    #46 and #50.
  • Adjustable-base page goes dormant instead of logging retry errors forever when
    no base is configured, and the sleep-stage classifier requires HR and HRV
    evidence for REM instead of defaulting to it.
  • Presence auto-off respects the user's power schedule instead of powering the
    bed down mid-sleep.

Removed

  • Sentry error-reporting integration; its DSNs pointed at an account this fork
    can't read, so reports were write-only.