Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
169 commits
Select commit Hold shift + click to select a range
dfd8dbc
test: audit coverage and fix calibration trigger ordering (#697)
ng Sep 6, 2026
7351643
chore(deps): Update @base-ui/react all non-major dependencies (#699)
renovate[bot] Sep 7, 2026
64c70e6
feat(streaming): expose LPS diagnostic subscriptions (#701)
ng Sep 7, 2026
e8c742f
fix(deploy): require prebuilt updates and stage local builds over SSH…
ng Sep 9, 2026
101bbbc
fix(deploy): support explicit GitHub forks for branch builds (#705)
ng Sep 9, 2026
45a3ddd
chore(deps): Update @types/node all non-major dependencies (#710)
renovate[bot] Sep 14, 2026
6b661b2
chore(deps): Update @formatjs/intl-localematcher all non-major depend…
renovate[bot] Sep 21, 2026
d07adbb
fix(homekit): preserve pairings on shutdown and remove redundant powe…
ng Sep 25, 2026
3ca513b
fix(install): make SSH setup work on a stock Pod 4 (#717)
jasonlarue Sep 25, 2026
f2d1ec1
UI: single-sleeper view in away mode, ambient labels, alarm editor ov…
jasonlarue Sep 25, 2026
93998a7
fix(biometrics): pin RAW frames so the cold archive actually fills (#…
jasonlarue Sep 26, 2026
839deea
fix(sleep-detector): raw-unit, rise-only presence for Pod 3/4 capSense
jasonlarue Sep 24, 2026
ae89830
feat(sleep-detector): persist in-progress sessions across restarts
jasonlarue Sep 24, 2026
5b474cd
feat(sleep-detector): self-adjusting presence baseline, no scheduled …
jasonlarue Sep 24, 2026
a5625c6
feat(biometrics): single-sleeper mode when one side is in away mode
jasonlarue Sep 24, 2026
df5874b
fix(sleep-detector): restore every side's replay watermark after restart
jasonlarue Sep 25, 2026
65a43ed
fix(sleep-detector): persist the presence hysteresis latch
jasonlarue Sep 25, 2026
a81777c
fix(piezo-processor): keep single-sleeper vitals until they are written
jasonlarue Sep 25, 2026
d4344a3
fix(sleep-detector): seed from the published baseline when state is lost
jasonlarue Sep 25, 2026
61ce9ba
fix(biometrics): preserve vitals retries and flush during idle input
ng Sep 26, 2026
cfbe5b8
Fix Pod 3/4 presence: raw-unit rise, self-adjusting baseline, reboot-…
ng Sep 26, 2026
08c55fa
test(biometrics): cover single-sleeper page routing (#721)
ng Sep 26, 2026
4dac123
test(biometrics): cover side-toggle navigation branches (#722)
ng Sep 26, 2026
3d92989
Run CI on merge_group so the dev merge queue can gate on required che…
ng Sep 27, 2026
52f6979
ci: stop failing the test gate on Codecov upload errors (#725)
ng Sep 27, 2026
a7e33ab
docs: consolidate project guidance in AGENTS.md (#723)
ng Sep 27, 2026
3916398
chore(deps): Update @tanstack/react-query all non-major dependencies …
renovate[bot] Sep 28, 2026
97df464
feat: coordinate temperature ownership and persistent manual holds (#…
ng Sep 28, 2026
3a5d9be
perf: cache weekly temperature schedule occurrences (#729)
ng Sep 28, 2026
234ecb8
Merge main into dev to reconcile squash-merged release #720
ng Sep 28, 2026
980a33b
Merge main into dev to reconcile squash-merged release #730
ng Sep 28, 2026
399d9bd
feat: redesign the web UI around what needs attention on each screen,…
ng Sep 29, 2026
d39ed1a
feat(ui): split the sidebar footer into health and full-length issues…
ng Sep 29, 2026
a7e1b65
chore(deps): Update @eslint/json dependency @eslint/json to v2 (#651)
renovate[bot] Sep 29, 2026
c176812
chore(deps): Update @vitejs/plugin-react dependency @vitejs/plugin-re…
renovate[bot] Sep 29, 2026
17d5369
feat: add an Eight Sleep-style Now/Night/Dawn temperature stepper (#740)
ng Sep 29, 2026
6d1b64f
docs: refresh README stepper capture and guide links (#743)
ng Sep 29, 2026
fad75e6
feat(ui): fold System → Pipeline into Health's data path (#742)
ng Sep 29, 2026
2c9669a
fix(ui): make the Tonight timeline readable by merging schedule steps…
ng Sep 29, 2026
7139437
fix(ui): keep the temperature-control picker labels on one line on ph…
ng Sep 29, 2026
3831405
feat(ui): pod health dot on the phone System tab (#746)
ng Sep 29, 2026
e99323d
chore Lock file maintenance (#747)
renovate[bot] Sep 29, 2026
e4951f7
docs: link first-time Pod root-access walkthrough (#748)
ng Sep 30, 2026
a0f41b5
ci: back-merge main into dev after each release (#750)
ng Sep 30, 2026
d6788a4
Merge main into dev to reconcile squash-merged release #749
github-actions[bot] Sep 30, 2026
f7287cf
fix(piezo): repair the firmware's lost-sample markers (#736)
jasonlarue Sep 30, 2026
a488e46
fix(sleep-detector): Pod 3/4 movement above sensor noise, breathing f…
jasonlarue Sep 30, 2026
4ac7105
fix(health): pump reads unknown, not stalled, on pods without pump da…
jasonlarue Sep 30, 2026
203705f
fix(gestures): a temperature tap replaces the firmware's own adjustme…
jasonlarue Sep 30, 2026
ae38a4a
feat(ui): skeleton cards that load data before first paint (#751)
ng Sep 30, 2026
fbfcabd
fix(hardware): read a neutral firmware target as off while its countd…
ng Oct 1, 2026
d727e23
fix(homekit): round the requested temperature to whole °F so 28°C can…
ng Oct 1, 2026
0835c29
fix(api): keep a freshly mutated side's powered state on the status f…
ng Oct 1, 2026
069af10
fix(temperature): wait for the first firmware status before the recon…
ng Oct 1, 2026
a9e4602
test(hardware): assert stall-guard inputs and complete the stall-supp…
ng Oct 1, 2026
cc2e073
fix(hardware): derive a side's power from its commanded target, not t…
ng Oct 1, 2026
7eecbed
fix(temperature): treat device_state as unpowered until the firmware …
ng Oct 1, 2026
c44e17c
feat(mcp): intent-shaped MCP server over the tRPC API (#753)
ng Oct 1, 2026
f377d2a
feat(schedule): draw curves as steps over the measured bed temperatur…
ng Oct 1, 2026
7efcdc0
Merge main into dev to reconcile squash-merged release #761
github-actions[bot] Oct 1, 2026
0f69aff
docs: slim the README now that sleepypod.github.io hosts the guides (…
ng Oct 1, 2026
d4510c3
fix(ci): publish branch builds to one prerelease so feature branches …
ng Oct 2, 2026
959f12b
fix(ci): install feature branches from the Build & Package artifact a…
ng Oct 2, 2026
5a96282
feat(demo): hosted demo mode backed by in-browser fake pod data (#765)
ng Oct 2, 2026
74e05e0
test(automation): cover BiometricsSignalReader and #664 codecov gaps …
ng Oct 2, 2026
210beb4
fix(hardware): clear Pod timer when powering off (#713)
arubacao Oct 2, 2026
3cbbc1b
Merge main into dev to reconcile squash-merged release #769
github-actions[bot] Oct 3, 2026
0348650
Merge main into dev to reconcile squash-merged release #771
github-actions[bot] Oct 4, 2026
8b9ea0b
fix(alarms): alarm fixes — snooze, wake window and warm-up (#766)
jasonlarue Oct 5, 2026
698a579
fix(demo): add system.getSensorSource demo handler (#781)
ng Oct 5, 2026
0e5d4c4
test(alarms): cover missing branches from alarm fixes (#779)
ng Oct 5, 2026
571d558
fix(hardware): recover DAC connections after firmware restarts (#782)
ng Oct 6, 2026
fb76d05
feat(health): report V8 heap and native memory counters alongside RSS…
ng Oct 6, 2026
29bc210
chore(deps): Update @modelcontextprotocol/sdk all non-major dependenc…
renovate[bot] Oct 6, 2026
4f3991a
perf(system): probe firmware once per process for the sensor source c…
ng Oct 6, 2026
c29fc0b
test(hooks): kill useSensorStream surviving mutants (#591 hit list) (…
ng Oct 6, 2026
ff43df9
perf(temperature): take cron-parser and per-call ORM reads off the co…
ng Oct 6, 2026
0aeb906
fix(piezo-processor): no vitals while the bed reads empty (#768)
jasonlarue Oct 6, 2026
4d15c84
feat(base): add experimental adjustable-base controls, schedules and …
ng Oct 6, 2026
0e8e51e
fix(base): prevent 2D fallback flicker during 3D loading (#784)
ng Oct 6, 2026
2adc177
fix(hardware): prevent false pump stalls during startup (#786)
ng Oct 7, 2026
969d352
fix(installer): separate socket discovery from hardware identity (#785)
ng Oct 7, 2026
58ac83f
feat(settings): separate solo bed setup from partner away mode (#767)
jasonlarue Oct 7, 2026
13f0735
feat(temp): 3D thermal bed, temperature Stage and sweep-mode piezo (#…
ng Oct 8, 2026
d6e8806
feat(schedules): maintain temperature after a curve ends (#789)
ng Oct 8, 2026
c7fbe0c
test: cover release #783 gaps in Stage, Base and piezo views (#790)
ng Oct 8, 2026
19bb185
feat(settings): 24-hour time format, stored on the pod (#788)
ng Oct 8, 2026
4d8d19d
Merge main into dev to reconcile squash-merged release #783
github-actions[bot] Oct 8, 2026
c790e94
chore(deps): Update @lingui/babel-plugin-lingui-macro linguijs monore…
renovate[bot] Oct 8, 2026
624e625
chore(deps): Update @eslint/css dependency @eslint/css to v2 (#775)
renovate[bot] Oct 8, 2026
5eee4d5
chore(deps): drop redundant @babel/preset-typescript that blocks Babe…
ng Oct 8, 2026
a8a0ae3
Merge main into dev to reconcile squash-merged release #792
github-actions[bot] Oct 8, 2026
2c28d2e
feat(stage): make the Stage the Temperature screen default, with tone…
ng Oct 8, 2026
0d8ae98
fix(stage): say Off once in the side panel instead of twice (#795)
ng Oct 8, 2026
9f3d5ba
feat(schedule): curve deep links, smooth curves and leaner set points…
ng Oct 9, 2026
8b52e84
feat(temp): shared notice banners and toasts, and an away card state …
ng Oct 9, 2026
8077afa
Merge main into dev to reconcile squash-merged release #797
github-actions[bot] Oct 9, 2026
6d457ef
fix: fail clearly when the Pod has no build instead of crash-looping …
ng Oct 10, 2026
74110f7
fix: prevent priming from triggering false pump leak alerts (#798)
ng Oct 10, 2026
76a9110
Merge main into dev to reconcile squash-merged release #800
github-actions[bot] Oct 10, 2026
025ceae
fix: stop flashing the stalled-pipeline banner before the latest vita…
ng Oct 10, 2026
fa2f339
feat: ingest Apple Watch reference sleep stages for classifier calibr…
ng Oct 11, 2026
d091a11
feat: sleep-stage dataset builder joining Watch stages with pod vital…
ng Oct 11, 2026
0fd1a85
fix: end sessions when the bed empties to a new level and score movem…
ng Oct 11, 2026
9441c62
fix: make the Stage's SCHEDULED label readable at 80° (#806)
ng Oct 11, 2026
84293dd
feat: per-side tabs inside Settings sections replace the global side …
ng Oct 11, 2026
e7c3b2f
feat: away mode toggle in the side card and Stage panel (#808)
ng Oct 11, 2026
cf46b5d
fix(sleep): classify stages with the side's piezo calibration quality…
ng Oct 12, 2026
83394a3
chore(deps): Update @lingui/swc-plugin dependency @lingui/swc-plugin …
renovate[bot] Oct 12, 2026
339925c
chore(deps): Update @semantic-release/git dependency @semantic-releas…
renovate[bot] Oct 12, 2026
84c09b7
fix: let cap-sense occupancy relax piezo presence re-entry so quiet s…
ng Oct 12, 2026
a50eb71
feat(sleep): let users define the sleep stage classification rules (#…
ng Oct 12, 2026
16fecf7
chore(deps): Update @base-ui/react all non-major dependencies (#818)
renovate[bot] Oct 12, 2026
ea3a598
fix(build): pin @babel/runtime to 7.29.7 so Turbopack resolves mqtt's…
ng Oct 12, 2026
add1507
fix(sleep): do not classify the week view at full quality while calib…
ng Oct 12, 2026
3e9027e
feat: import Apple Watch heart rate into the pod and overlay it on th…
ng Oct 12, 2026
6253b97
feat(biometrics): explore vitals and import Apple Watch data (#821)
ng Oct 12, 2026
0cd3151
feat(sleep): sleep stage rules as sentences with quick adjust, previe…
ng Oct 13, 2026
0922bc0
feat(mcp): sleep-stage rule inspection, dry-run preview and apply aga…
ng Oct 13, 2026
2a42ce9
feat(mcp): build and save a sleep curve from pod data (#825)
ng Oct 14, 2026
7e58793
feat(curves): save named curves on the pod (#827)
ng Oct 14, 2026
4ea6aa5
feat(schedule): optional notes on curves (#832)
ng Oct 14, 2026
d4df234
fix(biometrics): accept same-origin Watch uploads and use a button fo…
ng Oct 14, 2026
e681908
fix(security): reject unknown Host headers so DNS-rebinding pages can…
ng Oct 14, 2026
b18e100
fix(stream): gate the sensor WebSocket handshake on the Host/Origin a…
ng Oct 14, 2026
0e7e865
feat(biometrics): add versioned calibration profiles, references and …
ng Oct 14, 2026
35e6141
feat(biometrics): add retained backtest replay engine and isolated pa…
ng Oct 14, 2026
d5dd2dd
feat(biometrics): add prospective shadow runtime, coordinator host an…
ng Oct 14, 2026
f905a77
feat(biometrics): add calibration UI, browser tests and docs (#831)
ng Oct 14, 2026
cbc5387
feat: experimental presence auto-on with person-vs-pet occupancy clas…
ng Oct 14, 2026
9a806e5
Merge main into dev to reconcile squash-merged release #820
github-actions[bot] Oct 14, 2026
a9d10d4
feat(schedule): schedule library with switchable curves (#835)
ng Oct 14, 2026
87e4c36
fix(scripts): grant journal read access to sleepypod service user (#801)
onemec Oct 15, 2026
c2eb532
fix(sensors): prevent false high TEC and slow fan warnings on idling …
onemec Oct 15, 2026
2a062e3
feat(calibration): one-page biometric calibration (design 2a) (#836)
ng Oct 18, 2026
593bb89
fix(biometrics): stop Next truncating Health export uploads at 10 MB …
ng Oct 18, 2026
c8a4243
feat(biometrics): export frozen calibration datasets and fit profiles…
ng Oct 18, 2026
36f1dc3
fix(biometrics): reject oversized bundle entries before writing and b…
ng Oct 18, 2026
928d7ec
feat(biometrics): let the HR estimator report nothing instead of a we…
ng Oct 18, 2026
1dc8a07
feat(storage): eMMC endurance card and RAM-only journal (#838)
ng Oct 18, 2026
02b1287
feat(biometrics): import Watch breathing rate and HRV from Health upl…
ng Oct 18, 2026
a11a599
feat(biometrics): read-only analysis snapshot export (DB backup + RAW…
ng Oct 18, 2026
53ec586
feat(biometrics): pod-vs-Apple-Watch deviation report (API + MCP comp…
ng Oct 18, 2026
d5a1a26
feat(biometrics): archive pod biometrics off the pod for long-term Wa…
ng Oct 18, 2026
1365e5a
fix(biometrics): archive same-second calibration runs as separate row…
ng Oct 18, 2026
82f23d4
feat(biometrics): night-by-night deviation from Apple Watch on the ca…
ng Oct 18, 2026
f7e3a1e
test(hooks): kill useCurveTemplates.ts surviving mutants (#591 hit li…
ng Oct 19, 2026
3adf8e9
feat(storage): add flash write insights and recording controls (#854)
ng Oct 19, 2026
6afc8a4
fix(gestures): set a tap's normal and while-ringing actions independe…
jasonlarue Oct 19, 2026
9a0b43b
Personal stage models: train off the pod on the phone's feature contr…
ng Oct 20, 2026
8317589
chore: delete the pod snapshot after a personal-model training run (#…
ng Oct 20, 2026
14a7cda
feat: pin the personal forest at depth 8 from a held-out sweep (#858)
ng Oct 20, 2026
bae14c2
feat: stage a side's nights with its personal model when switched on …
ng Oct 21, 2026
8a385aa
feat(piezo-processor): beat-to-beat heartbeat detection (#848)
jasonlarue Oct 21, 2026
e251556
feat(sleep): manage personal models and compare Watch sleep data (#860)
ng Oct 22, 2026
09e1001
feat(mcp): personal stage model tools (#864)
ng Oct 23, 2026
50216dd
fix: internet access toggle after a reboot, and no false 'firewall ru…
jasonlarue Oct 23, 2026
5cb54f2
feat(alarms): snooze length on the alarm, with Snooze taper (#861)
jasonlarue Oct 23, 2026
60f6093
fix(training): preview candidates safely and preserve Watch label gap…
ng Oct 24, 2026
16daf32
fix(biometrics): render Watch-only nights and stable reference sample…
ng Oct 24, 2026
502457d
fix(piezo): bound heartbeat retries with ordered backoff (#865)
ng Oct 25, 2026
47034d4
fix(deps): upgrade Next.js to 16.4.3 for three security advisories (#…
jasonlarue Oct 25, 2026
56e61fc
fix(biometrics): register sensHealth, frzWater, and lps as known reco…
onemec Oct 28, 2026
733068b
feat(settings): add Europe/Bucharest to the timezone options (#874)
MihaiSprinceana Oct 28, 2026
82105c6
fix: enforce patched Node.js in development and pod installs (#868)
ng Oct 29, 2026
bed309b
chore(deps): Update @babel/runtime all non-major dependencies (#853)
renovate[bot] Oct 29, 2026
00be07e
fix(renovate): remove invalid 'fix' from matchUpdateTypes (#875)
onemec Oct 29, 2026
6e736b9
chore(deps): Update @stryker-mutator/core stryker-js monorepo to v10 …
renovate[bot] Oct 29, 2026
1fbb953
feat(ui): surface frzWater tank level and sensHealth cover disconnect…
ng Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 4 additions & 2 deletions .github/workflows/python-modules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ jobs:
target: .
cov_source: .
deps: ""
# Stdlib-only repo scripts; tests load them by path from
# scripts/tests/.
# Repo scripts, including the NumPy-backed personal-model label helper.
# Reuse the locked numerical environment for reproducible tests.
- name: scripts
workdir: scripts
target: tests
Expand Down Expand Up @@ -113,6 +113,8 @@ jobs:
if [ "${{ matrix.module.name }}" = "common" ]; then
# Keep common's cwd/import layout while using the numerical lock.
UV_PROJECT_ENVIRONMENT="$PWD/.venv" uv sync --project piezo-processor --locked --no-install-project
elif [ "${{ matrix.module.name }}" = "scripts" ]; then
UV_PROJECT_ENVIRONMENT="$PWD/.venv" uv sync --project ../modules/piezo-processor --locked --no-install-project
elif [ -f uv.lock ]; then
uv sync --locked --no-install-project
else
Expand Down
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24
24.22.1
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ pump-stall diagnosis. The `/debug` console exposes live diagnostics.

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know
## This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

Expand Down
5 changes: 2 additions & 3 deletions app/[lang]/schedule/[curve]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ const SIDES: SideSelection[] = ['left', 'right', 'both']

export default async function ScheduleCurve({ params, searchParams }: {
params: Promise<{ curve: string }>
searchParams: Promise<{ side?: string | string[], from?: string | string[], saved?: string | string[] }>
searchParams: Promise<{ side?: string | string[], from?: string | string[] }>
}) {
const { curve } = await params
const { side, from, saved } = await searchParams
const { side, from } = await searchParams
return (
<SchedulePage
curve={curve}
curveSide={SIDES.find(s => s === side)}
fromBoth={from === 'both'}
savedCurve={typeof saved === 'string' ? saved : undefined}
/>
)
}
88 changes: 88 additions & 0 deletions app/api/biometric-tuning/datasets/[id]/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { biometricsDb } from '@/src/db'
import { coordinatorRequest } from '@/src/biometrics/backtest/coordinator-service'
import type { CoordinatorResults } from '@/src/biometrics/backtest/coordinator-tasks'
import { datasetBundleChunks, datasetBundleTarBytes, retainedRawDirectory } from '@/src/biometrics/backtest/dataset-export'

// One export at a time: each one reads every retained archive of the dataset
// twice (verification, then streaming). A stalled client must not latch it.
let inflight = false
const EXPORT_WATCHDOG_MS = (() => {
const raw = Number(process.env.DATASET_EXPORT_WATCHDOG_MS ?? '')
return Number.isFinite(raw) && raw > 0 ? raw : 30 * 60 * 1000
})()

function errorResponse(error: unknown) {
// Match on the code, not the class: the coordinator's reply is rebuilt as a
// ReferenceInputError that need not be the same class instance this route
// imports once Next has bundled both.
const code = (error as { code?: unknown } | null)?.code
if (code === 'NOT_FOUND' || code === 'DATASET_RELEASED') return NextResponse.json({ error: 'Dataset not found or released' }, { status: 404 })
if (code === 'DATASET_CORRUPT') return NextResponse.json({ error: `DATASET_CORRUPT: ${(error as Error).message}` }, { status: 409 })
if (code === 'WORKER_UNAVAILABLE') return NextResponse.json({ error: (error as Error).message }, { status: 503 })
return NextResponse.json({ error: 'Dataset export failed' }, { status: 500 })
}

/**
* Stream one frozen calibration dataset as an uncompressed tar: bundle.json
* (index with every file's SHA-256), manifest.json, snapshot.json and the
* retained raw/<sha256>.RAW.gz archives it references. Nothing else is read.
*/
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
if (!z.string().uuid().safeParse(id).success) return NextResponse.json({ error: 'Invalid dataset id' }, { status: 400 })
if (inflight) return NextResponse.json({ error: 'Another dataset export is in progress, retry later' }, { status: 429, headers: { 'Retry-After': '60' } })
inflight = true
let prepared: CoordinatorResults['exportDataset']
let directory: string
try {
prepared = await coordinatorRequest(biometricsDb, { type: 'exportDataset', input: { id } })
directory = retainedRawDirectory(biometricsDb)
}
catch (error) {
inflight = false
return errorResponse(error)
}

const chunks = datasetBundleChunks(prepared.index, { manifest: prepared.manifestText, snapshot: prepared.snapshotText }, directory)
let watchdog: ReturnType<typeof setTimeout> | undefined
const release = () => {
if (watchdog) clearTimeout(watchdog)
inflight = false
}
const body = new ReadableStream<Uint8Array>({
start(controller) {
watchdog = setTimeout(() => {
controller.error(new Error('Dataset export exceeded its time limit'))
void chunks.return(undefined).finally(release)
}, EXPORT_WATCHDOG_MS)
},
async pull(controller) {
try {
const next = await chunks.next()
if (next.done) {
release()
controller.close()
}
else controller.enqueue(next.value)
}
catch (error) {
release()
controller.error(error)
}
},
async cancel() {
await chunks.return(undefined).finally(release)
},
})
return new Response(body, {
headers: {
'Content-Type': 'application/x-tar',
'Content-Disposition': `attachment; filename="calibration-dataset-${id}.tar"`,
'Content-Length': String(datasetBundleTarBytes(prepared.index)),
'Cache-Control': 'no-store',
'X-Dataset-Content-Hash': prepared.index.dataset.contentHash,
},
})
}
248 changes: 248 additions & 0 deletions app/api/biometrics/analysis-export/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// @vitest-environment node
import { createHash, hkdfSync } from 'node:crypto'
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
import { hostname, tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseTarHeader } from '@/src/biometrics/backtest/bundle-tar'

const state = vi.hoisted(() => ({ archive: '', source: '', seed: { source: 'mmc-cid', value: 'cid-0123' } as { source: string, value: string } }))
vi.mock('@/src/lib/podStorage', () => ({
get ARCHIVE_DIR() {
return state.archive
},
}))
vi.mock('@/src/db', () => ({ biometricsDb: { $client: { get name() {
return state.source
} } } }))
vi.mock('@/src/homekit/storage', () => ({ readSeed: () => state.seed }))
vi.mock('@/src/hardware/dacMonitor.instance', () => ({
getDacMonitorIfRunning: () => ({ getLastStatus: () => ({ podVersion: 'J00', sensorLabel: '20600-0003-J55-B0708DE3' }) }),
}))

const START = Date.UTC(2026, 9, 10, 6)
const END = Date.UTC(2026, 9, 10, 14)
const MINUTE = 60_000
const journal = JSON.parse(readFileSync(join(process.cwd(), 'src/db/biometrics-migrations/meta/_journal.json'), 'utf-8')) as { entries: Array<{ when: number, tag: string }> }
const latest = journal.entries[journal.entries.length - 1]

let root: string
let snapshots: string

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'analysis-export-test-'))
state.archive = join(root, 'biometrics-archive')
snapshots = join(root, 'tmpfs')
mkdirSync(state.archive)
mkdirSync(snapshots)
vi.stubEnv('TMPDIR', snapshots)
state.source = join(root, 'biometrics.db')
const database = new Database(state.source)
database.pragma('journal_mode = WAL')
database.exec('CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT NOT NULL, created_at NUMERIC); CREATE TABLE vitals (id INTEGER PRIMARY KEY, heart_rate REAL)')
database.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?), (?, ?)').run('old-hash', 1, 'latest-hash', latest.when)
// Left in the WAL (no checkpoint) so the snapshot must include uncheckpointed pages.
database.prepare('INSERT INTO vitals (heart_rate) VALUES (?), (?)').run(58, 61)
sources.push(database)
})
const sources: Database.Database[] = []
afterEach(() => {
for (const database of sources.splice(0)) database.close()
vi.unstubAllEnvs()
rmSync(root, { recursive: true, force: true })
})

function archive(name: string, mtimeMs: number, body: string | Buffer = `frame ${name}`) {
const path = join(state.archive, name)
writeFileSync(path, body)
utimesSync(path, mtimeMs / 1000, mtimeMs / 1000)
return path
}

async function loadRoute() {
vi.resetModules()
return await import('./route')
}

const get = (GET: (request: Request) => Promise<Response>, query: string, init?: RequestInit) =>
GET(new Request(`http://pod/api/biometrics/analysis-export?${query}`, init))

function untar(buffer: Buffer) {
const entries: Array<{ name: string, body: Buffer }> = []
let position = 0
for (;;) {
const header = parseTarHeader(buffer.subarray(position, position + 512))
position += 512
if (!header) break
entries.push({ name: header.name, body: buffer.subarray(position, position + header.bytes) })
position += Math.ceil(header.bytes / 512) * 512
}
return entries
}
const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')

describe('GET /api/biometrics/analysis-export', () => {
it('selects archives by the widened mtime window and streams a hashed snapshot', async () => {
archive('A0000001.RAW.gz', START - 21 * MINUTE)
archive('A0000002.RAW.gz', START - 19 * MINUTE)
archive('A0000003.RAW.gz', START + 4 * 60 * MINUTE, Buffer.alloc(1300, 7))
archive('A0000004.RAW.gz', END + 44 * MINUTE)
archive('A0000005.RAW.gz', END + 46 * MINUTE)
archive('A0000006.RAW.gz.tmp.4242', START + MINUTE)
archive('notes.txt', START + MINUTE)
symlinkSync(archive('linked-target', START), join(state.archive, 'A0000007.RAW.gz'))
const before = readdirSync(state.archive).sort()

const { GET } = await loadRoute()
const response = await get(GET, `start=${new Date(START).toISOString()}&end=${END}`)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('application/x-tar')
expect(response.headers.get('Cache-Control')).toBe('no-store, no-transform')
const buffer = Buffer.from(await response.arrayBuffer())
expect(buffer.length).toBe(Number(response.headers.get('Content-Length')))

const entries = untar(buffer)
expect(entries.map(entry => entry.name)).toEqual(['biometrics.db', 'raw/A0000002.RAW.gz', 'raw/A0000003.RAW.gz', 'raw/A0000004.RAW.gz', 'manifest.json'])
const manifest = JSON.parse(entries[4].body.toString())
expect(manifest.formatVersion).toBe('analysis-export-v1')
expect(manifest.files).toEqual(entries.slice(0, 4).map(entry => expect.objectContaining({ path: entry.name, bytes: entry.body.length, sha256: sha256(entry.body) })))
expect(manifest.files[2]).toEqual({ role: 'raw', path: 'raw/A0000003.RAW.gz', name: 'A0000003.RAW.gz', mtimeMs: START + 4 * 60 * MINUTE, bytes: 1300, sha256: sha256(Buffer.alloc(1300, 7)) })
expect(manifest.window).toEqual({
requested: { startMs: START, endMs: END, start: new Date(START).toISOString(), end: new Date(END).toISOString() },
rawSelection: { includeRaw: true, mtimeFromMs: START - 20 * MINUTE, mtimeToMs: END + 45 * MINUTE },
served: { archiveCount: 3, rawMtimeMinMs: START - 19 * MINUTE, rawMtimeMaxMs: END + 44 * MINUTE },
})
expect(manifest.pod).toEqual({ id: Buffer.from(hkdfSync('sha256', 'cid-0123', 'sleepypod-pod-id-v1', 'pod-id', 16)).toString('hex'),
idSource: 'mmc-cid', hostname: hostname(), model: { podVersion: 'J00', name: 'Pod 5', hardwareRevision: 'J55' } })
expect(manifest.schema.biometrics).toEqual({ migrationHash: 'latest-hash', migrationCreatedAt: latest.when, migrationTag: latest.tag, appliedMigrations: 2 })
expect(manifest.build).toEqual(expect.objectContaining({ branch: expect.any(String), commitHash: expect.any(String) }))

const extracted = join(root, 'extracted.db')
writeFileSync(extracted, entries[0].body)
const copy = new Database(extracted, { readonly: true })
expect(copy.prepare('SELECT heart_rate FROM vitals ORDER BY id').pluck().all()).toEqual([58, 61])
copy.close()

expect(readdirSync(snapshots)).toEqual([])
expect(readdirSync(state.archive).sort()).toEqual(before)
})

it('omits RAW archives with raw=0', async () => {
archive('A0000001.RAW.gz', START)
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}&raw=0`)
const entries = untar(Buffer.from(await response.arrayBuffer()))
expect(entries.map(entry => entry.name)).toEqual(['biometrics.db', 'manifest.json'])
const manifest = JSON.parse(entries[1].body.toString())
expect(manifest.window.served).toEqual({ archiveCount: 0, rawMtimeMinMs: null, rawMtimeMaxMs: null })
expect(manifest.window.rawSelection.includeRaw).toBe(false)
})

it('reports a null pod id when only a random dev seed exists', async () => {
state.seed = { source: 'random-dev', value: 'abc' }
try {
const { GET } = await loadRoute()
const entries = untar(Buffer.from(await (await get(GET, `start=${START}&end=${END}`)).arrayBuffer()))
expect(JSON.parse(String(entries.at(-1)?.body)).pod).toMatchObject({ id: null, idSource: 'random-dev' })
}
finally {
state.seed = { source: 'mmc-cid', value: 'cid-0123' }
}
})

it.each([
['', 'missing start and end'],
[`start=${START}`, 'missing end'],
[`start=${END}&end=${START}`, 'start after end'],
[`start=${START}&end=${START}`, 'empty window'],
[`start=${START}&end=${START + 14 * 24 * 60 * MINUTE + 1}`, 'over 14 days'],
[`start=yesterday&end=${END}`, 'unparseable start'],
[`start=${START}&end=${END}&raw=yes`, 'bad raw flag'],
])('rejects %s (%s) with 400', async (query) => {
const { GET } = await loadRoute()
const response = await get(GET, query)
expect(response.status).toBe(400)
expect((await response.json()).error).toMatch(/start and end/)
expect(readdirSync(snapshots)).toEqual([])
})

it('accepts exactly 14 days', async () => {
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${START + 14 * 24 * 60 * MINUTE}&raw=0`)
expect(response.status).toBe(200)
await response.arrayBuffer()
})

it('rejects windows over 2 GiB of RAW with 413 and no snapshot', async () => {
const sparse = archive('A0000001.RAW.gz', START, '')
truncateSync(sparse, 2 * 1024 ** 3 - 10)
utimesSync(sparse, START / 1000, START / 1000)
archive('A0000002.RAW.gz', START + MINUTE, Buffer.alloc(11))
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}`)
expect(response.status).toBe(413)
expect((await response.json()).error).toMatch(/2 RAW archives totalling 2147483649 bytes.*raw=0/)
expect(readdirSync(snapshots)).toEqual([])
const without = await get(GET, `start=${START}&end=${END}&raw=0`)
expect(without.status).toBe(200)
await without.arrayBuffer()
})

it('allows one export at a time and removes the snapshot when the client cancels', async () => {
archive('A0000001.RAW.gz', START, Buffer.alloc(600_000, 1))
const { GET } = await loadRoute()
const first = await get(GET, `start=${START}&end=${END}`)
expect(readdirSync(snapshots)).toHaveLength(1)
expect((await get(GET, `start=${START}&end=${END}`)).status).toBe(429)
const reader = (first.body as ReadableStream<Uint8Array>).getReader()
await reader.read()
await reader.cancel()
await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([]))
const next = await get(GET, `start=${START}&end=${END}&raw=0`)
expect(next.status).toBe(200)
await next.arrayBuffer()
})

it('removes the snapshot when the request is aborted mid-stream', async () => {
archive('A0000001.RAW.gz', START, Buffer.alloc(600_000, 1))
const { GET } = await loadRoute()
const controller = new AbortController()
const response = await get(GET, `start=${START}&end=${END}`, { signal: controller.signal })
await (response.body as ReadableStream<Uint8Array>).getReader().read()
controller.abort()
await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([]))
expect((await get(GET, `start=${START}&end=${END}&raw=0`)).status).toBe(200)
})

it('cleans up and streams nothing when the client left while the snapshot was taken', async () => {
archive('A0000001.RAW.gz', START)
const { GET } = await loadRoute()
const controller = new AbortController()
controller.abort()
const response = await get(GET, `start=${START}&end=${END}`, { signal: controller.signal })
expect(response.status).toBe(499)
expect(readdirSync(snapshots)).toEqual([])
expect((await get(GET, `start=${START}&end=${END}&raw=0`)).status).toBe(200)
})

it('aborts the stream and removes the snapshot when an archive vanishes', async () => {
const path = archive('A0000001.RAW.gz', START)
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}`)
unlinkSync(path)
await expect(response.arrayBuffer()).rejects.toThrow()
await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([]))
})

it('returns 500 and cleans up when the database cannot be read', async () => {
state.source = join(root, 'missing.db')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}`)
expect(response.status).toBe(500)
expect(error).toHaveBeenCalled()
expect(readdirSync(snapshots)).toEqual([])
error.mockRestore()
})
})
Loading
Loading