diff --git a/.gitattributes b/.gitattributes index f6460ff38..720ebe24a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,8 @@ docs/images/*.gif filter=lfs diff=lfs merge=lfs -text # Web assets ship in source tarballs (sp-update, release), which never resolve LFS pointers. public/** !filter !diff !merge binary + +# Hand-authored household UI must retain readable reviews and three-way merges. +public/household/*.html text diff merge +public/household/*.js text diff merge +public/household/*.css text diff merge diff --git a/.githooks/pre-push b/.githooks/pre-push index 6978f5233..a5724756e 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -11,6 +11,12 @@ echo "→ Drizzle schema validation..." pnpm drizzle-kit check pnpm drizzle-kit check --config=drizzle.biometrics.config.ts +echo "→ Offline migration tests..." +uv run --project scripts/migration --frozen python -m unittest discover -s scripts/migration -p 'test_*.py' +python3 -m unittest discover -s scripts/deployment -p 'test_*.py' +python3 -m unittest discover -s scripts/bedroom -p 'test_*.py' +node --test scripts/bedroom/homebridge-cache.test.mjs + CHANGED=$(git diff --name-only @{push}.. -- '*.ts' '*.tsx' 2>/dev/null || git diff --name-only origin/dev...HEAD -- '*.ts' '*.tsx' 2>/dev/null || true) if [ -n "$CHANGED" ]; then echo "→ Tests (changed files)..." diff --git a/.github/workflows/bedroom-windows.yml b/.github/workflows/bedroom-windows.yml new file mode 100644 index 000000000..41a0d4827 --- /dev/null +++ b/.github/workflows/bedroom-windows.yml @@ -0,0 +1,28 @@ +name: Bedroom Windows installer + +on: + pull_request: + paths: ['scripts/bedroom/**', '.github/workflows/bedroom-windows.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + installer: + runs-on: windows-2025 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + - name: Validate the Windows PowerShell 5.1 installation + shell: powershell + run: ./scripts/bedroom/Test-WindowsInstaller.ps1 + - name: Validate the isolated sensor-reader installation + shell: powershell + run: ./scripts/bedroom/Test-GoveeInstaller.ps1 + - name: Test appliance power installation and handoff + shell: powershell + run: ./scripts/bedroom/Test-GoveeInstaller.ps1 -ReaderProfile AppliancePower diff --git a/.github/workflows/home-assistant.yml b/.github/workflows/home-assistant.yml new file mode 100644 index 000000000..eb012f5dd --- /dev/null +++ b/.github/workflows/home-assistant.yml @@ -0,0 +1,30 @@ +name: Home Assistant Templates + +on: + pull_request: + paths: + - 'integrations/home-assistant/**' + - 'scripts/home-assistant/**' + - '.github/workflows/home-assistant.yml' + push: + branches: [main, dev] + paths: + - 'integrations/home-assistant/**' + - 'scripts/home-assistant/**' + - '.github/workflows/home-assistant.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Home Assistant schemas and actions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Validate isolated Home Assistant templates + run: uv run --project scripts/home-assistant --frozen python -m unittest discover -s scripts/home-assistant -p 'test_*.py' -v diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index 6a22dca98..60474961b 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -3,6 +3,13 @@ name: Publish OpenAPI Spec on: push: branches: [main, dev] + pull_request: + paths: + - '.github/workflows/openapi.yml' + - 'app/api/openapi.json/**' + - 'src/server/openapi.ts' + - 'src/auth/**' + workflow_dispatch: permissions: contents: read @@ -29,10 +36,18 @@ jobs: - name: Build run: pnpm build + # Exercise the same authenticated route as a real client without using + # any owner's credentials or weakening production authentication. + - name: Create temporary export credential + run: | + export_token=$(openssl rand -hex 32) + echo "::add-mask::$export_token" + printf 'SLEEPYPOD_AUTH_TOKEN=%s\n' "$export_token" >> "$GITHUB_ENV" + - name: Start server run: | mkdir -p /tmp/sleepypod-raw - pnpm start > /tmp/server.log 2>&1 & + pnpm start --hostname 127.0.0.1 > /tmp/server.log 2>&1 & echo "SERVER_PID=$!" >> "$GITHUB_ENV" env: DATABASE_URL: file:./ci-sleepypod.db @@ -43,7 +58,8 @@ jobs: - name: Wait for server run: | for i in $(seq 1 90); do - if curl -sf http://localhost:3000/api/openapi.json > /dev/null 2>&1; then + if curl -sf --max-time 5 -H "Authorization: Bearer $SLEEPYPOD_AUTH_TOKEN" \ + http://127.0.0.1:3000/api/openapi.json > /dev/null 2>&1; then echo "Server ready after ${i}s" exit 0 fi @@ -55,7 +71,16 @@ jobs: exit 1 - name: Download OpenAPI spec - run: curl -sf http://localhost:3000/api/openapi.json | jq . > openapi.json + run: | + curl -sf --max-time 30 -H "Authorization: Bearer $SLEEPYPOD_AUTH_TOKEN" \ + http://127.0.0.1:3000/api/openapi.json | jq . > openapi.json + jq -e '.openapi and .paths and .security' openapi.json > /dev/null + + - name: Verify anonymous access stays blocked + run: | + status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ + http://127.0.0.1:3000/api/openapi.json) + test "$status" = 401 - name: Stop server if: always() diff --git a/.github/workflows/python-modules.yml b/.github/workflows/python-modules.yml index d8997abfe..0f71bf14a 100644 --- a/.github/workflows/python-modules.yml +++ b/.github/workflows/python-modules.yml @@ -121,6 +121,11 @@ jobs: uv pip install --python .venv/bin/python ${{ matrix.module.deps }} fi fi + # Probe the production environment before pytest or other test-only deps can mask missing imports. + if [ -f uv.lock ]; then + python "${{ github.workspace }}/scripts/deployment/probe_modules.py" \ + --modules-root "${{ github.workspace }}/modules" --module "${{ matrix.module.name }}" + fi # shellcheck source=/dev/null . .venv/bin/activate uv pip install pytest pytest-cov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ca03f28e..5a1bbbbca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,18 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # semantic-release pushes tags through the repository's pre-push checks. + # Give those checks the same locked Python runtime as the main CI jobs. + - name: Setup Python for release validation + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Setup uv for release validation + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + - name: Build run: pnpm build @@ -69,6 +81,12 @@ jobs: . mv /tmp/sleepypod-core.tar.gz . + # Create the host-only test environment after packaging, so Linux runner + # wheels and interpreter paths never become part of the Pod artifact. + - name: Install locked runtime integration dependencies + working-directory: modules/piezo-processor + run: uv sync --locked --no-install-project + - name: Refresh tags (force, prune) # semantic-release runs `git fetch --tags` unconditionally and aborts # on any "would clobber existing tag" conflict. Force-prune up front so @@ -78,4 +96,4 @@ jobs: - name: Semantic Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: pnpm dlx semantic-release \ No newline at end of file + run: pnpm dlx semantic-release diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 728688b48..028a65768 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,8 @@ concurrency: jobs: ci: name: ${{ matrix.task.name }} - runs-on: ubuntu-latest + # Locked Playwright 1.58.2 supports Ubuntu 24.04, not the newer latest image. + runs-on: ${{ matrix.task.name == 'Browser Tests' && 'ubuntu-24.04' || 'ubuntu-latest' }} permissions: contents: read packages: read @@ -28,8 +29,11 @@ jobs: task: - { name: "Lint", cmd: "CHANGED=$(git diff --name-only --diff-filter=d origin/${BASE_REF#refs/heads/}...HEAD -- '*.ts' '*.tsx' | head -200); if [ -n \"$CHANGED\" ]; then echo \"$CHANGED\" | xargs -d '\\n' pnpm eslint --; else echo 'No TS/TSX files changed'; fi" } - { name: "Typecheck", cmd: "pnpm tsc" } - - { name: "Unit Tests", cmd: "pnpm test run --coverage --passWithNoTests" } - - { name: "Browser Tests", cmd: "NODE_ENV=production CI=1 pnpm build && pnpm test:browser" } + # Leave capacity for real Python subprocesses and avoid nested BLAS pools. + - { name: "Unit Tests", cmd: "VITEST_MAX_WORKERS=2 OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 pnpm test run --coverage --passWithNoTests" } + # Build workers must not open the same on-disk SQLite file. The browser + # runner creates and migrates its own isolated databases after the build. + - { name: "Browser Tests", cmd: "NODE_ENV=production CI=1 DATABASE_URL=:memory: BIOMETRICS_DATABASE_URL=:memory: pnpm build && pnpm test:browser" } steps: - name: Checkout @@ -89,6 +93,22 @@ jobs: # A PR carries base.ref; a merge-group run carries refs/heads/. BASE_REF: ${{ github.event.pull_request.base.ref || github.event.merge_group.base_ref }} + - name: Verify offline Free Sleep migration + if: matrix.task.name == 'Unit Tests' + run: uv run --project scripts/migration --frozen python -m unittest discover -s scripts/migration -p 'test_*.py' -v + + - name: Verify offline deployment packaging + if: matrix.task.name == 'Unit Tests' + run: | + python3 -m unittest discover -s scripts/deployment -p 'test_*.py' -v + python3 -m unittest discover -s scripts/bedroom -p 'test_*.py' -v + node --test scripts/bedroom/*.test.mjs + + - name: Verify isolated IPv4 and IPv6 failure and recovery + if: matrix.task.name == 'Unit Tests' + timeout-minutes: 2 + run: sudo env SLEEPYPOD_NETWORK_NAMESPACE_TEST=1 python3 -m unittest discover -s scripts/deployment -p 'test_network_trial_netns.py' -v + - name: Upload coverage reports to Codecov if: github.repository == 'sleepypod/core' && matrix.task.name == 'Unit Tests' && !cancelled() uses: codecov/codecov-action@v5 diff --git a/.gitignore b/.gitignore index d6762454e..7d87556b1 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ sleepypod.local.db-wal *.db .reviews/ __pycache__/ + +# Generated isolated personal browser bundle +/public/household/personal-runtime.js diff --git a/AGENTS.md b/AGENTS.md index 8e5870fad..6bb5aa525 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,9 @@ Next.js App Router runs on the pod and controls its hardware: - `src/hardware/`: DAC Unix-socket transport to frankenfirmware, status polling, gestures, pump-stall protection, and snooze. -- `src/server/`: tRPC routers and REST endpoints at `/api/*`. The API uses a - LAN-only trust model without authentication; see `src/server/openapi.ts`. +- `src/server/`: tRPC routers and REST endpoints at `/api/*`. Controller APIs + require owner authentication through a Bearer connection key or an expiring + browser session; see `src/server/openapi.ts`. Use LAN or private Tailscale access. - `src/scheduler/`: JobManager for temperature, power, alarms, and system jobs. - `src/automation/`: Autopilot rules evaluated in the device timezone. - `src/streaming/`: WebSocket sensor stream on port 3001, frame normalization, diff --git a/app/[lang]/home/page.tsx b/app/[lang]/home/page.tsx new file mode 100644 index 000000000..d4073252b --- /dev/null +++ b/app/[lang]/home/page.tsx @@ -0,0 +1,39 @@ +import Link from 'next/link' +import { House, MoonStar, Sunrise, Wind } from 'lucide-react' +import { PageHeader } from '@/src/components/ds' +import { RoomContext } from '@/src/components/Home/RoomContext' +import { BedroomSensors } from '@/src/components/Home/BedroomSensors' + +export default async function HomePage({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params + return ( + <> + + +
+ +

Connect your room

+

Bring your bedroom measurements into sleep insights and reviewable Autopilot rules. Existing Home Assistant entities provide sensor input; house scenes remain separately controlled.

+
+ MQTT connection + Apple Home bridge +
+
+
+ Existing single-source MQTT mappings +
+
+
+ {[{ icon: MoonStar, title: 'Wind down', text: 'Lighting and shades follow your evening.' }, { icon: Sunrise, title: 'Wake together', text: 'Coordinate your alarm with a Home scene.' }, { icon: Wind, title: 'Room comfort', text: 'Understand humidity, temperature and air quality.' }].map(item => ( +
+ +

{item.title}

+

{item.text}

+

Template · inactive

+
+ ))} +
+

Existing Apple Home scenes can be run from the native app after you authorize Home access. Missing room readings never trigger bed adjustments.

+ + ) +} diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx index 86e235b44..7f86f81cc 100644 --- a/app/[lang]/layout.tsx +++ b/app/[lang]/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from 'next' import { IBM_Plex_Mono, IBM_Plex_Sans } from 'next/font/google' import dynamic from 'next/dynamic' import Script from 'next/script' +import { ConnectionGate } from '@/src/components/AppShell/ConnectionGate' import { AppShell } from '@/src/components/AppShell/AppShell' import { allMessages, getI18nInstance } from '@/src/lib/i18n/appRouterI18n' import { LinguiClientProvider } from '@/src/providers/LinguiClientProvider' @@ -62,7 +63,7 @@ export default async function LangLayout({ return ( - + @@ -70,19 +71,21 @@ export default async function LangLayout({ - - - - - - - {children} - - - - - - + + + + + + + + {children} + + + + + + + diff --git a/app/[lang]/more/page.tsx b/app/[lang]/more/page.tsx new file mode 100644 index 000000000..75da8bc78 --- /dev/null +++ b/app/[lang]/more/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation' + +/** Preserve bookmarks from the former five-tab layout. */ +export default async function MorePage({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params + redirect(`/${lang}/settings`) +} diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index d964e7170..aae4e13a5 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from 'next' import { TempScreen } from '@/src/components/TempScreen/TempScreen' +import { BedroomSummary } from '@/src/components/Home/BedroomSummary' import { getI18nInstance } from '@/src/lib/i18n/appRouterI18n' import { initLingui } from '@/src/lib/i18n/initLingui' import { setI18n } from '@lingui/react/server' // The layout's title template only applies to child segments, not this page. -export const metadata: Metadata = { title: { absolute: 'Temp · sleepypod' } } +export const metadata: Metadata = { title: { absolute: 'Temp · Sleepypod' } } export default async function Page({ params, @@ -17,5 +18,10 @@ export default async function Page({ initLingui(lang) setI18n(i18n) - return + return ( + <> + + + + ) } diff --git a/app/[lang]/schedule/[curve]/page.tsx b/app/[lang]/schedule/[curve]/page.tsx index 6027b3864..1351fe1a8 100644 --- a/app/[lang]/schedule/[curve]/page.tsx +++ b/app/[lang]/schedule/[curve]/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import { SchedulePage } from '@/src/components/Schedule/SchedulePage' +import { BedroomSummary } from '@/src/components/Home/BedroomSummary' import type { SideSelection } from '@/src/providers/SideProvider' export const metadata: Metadata = { title: 'Curve · Schedule' } @@ -8,16 +9,18 @@ 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 ( - s === side)} - fromBoth={from === 'both'} - savedCurve={typeof saved === 'string' ? saved : undefined} - /> + <> + + s === side)} + fromBoth={from === 'both'} + /> + ) } diff --git a/app/[lang]/schedule/page.tsx b/app/[lang]/schedule/page.tsx index 1053ac49f..3eebc0d86 100644 --- a/app/[lang]/schedule/page.tsx +++ b/app/[lang]/schedule/page.tsx @@ -1,8 +1,14 @@ import type { Metadata } from 'next' import { SchedulePage } from '@/src/components/Schedule/SchedulePage' +import { BedroomSummary } from '@/src/components/Home/BedroomSummary' export const metadata: Metadata = { title: 'Schedule' } export default function Schedule() { - return + return ( + <> + + + + ) } diff --git a/app/[lang]/tests/redesignRoutes.test.tsx b/app/[lang]/tests/redesignRoutes.test.tsx index a7c7f374d..60afd825d 100644 --- a/app/[lang]/tests/redesignRoutes.test.tsx +++ b/app/[lang]/tests/redesignRoutes.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/src/components/Schedule/SchedulePage', () => ({ SchedulePage: (props: } })) vi.mock('@/src/components/Sleep/SleepSections', () => ({ SleepSections: () =>
Sleep page
})) vi.mock('@/src/components/System/SystemScreen', () => ({ SystemScreen: () =>
System page
})) +vi.mock('@/src/components/Home/BedroomSummary', () => ({ BedroomSummary: () => })) it('renders redesigned entry pages and passes rule route parameters', async () => { const { default: Autopilot } = await import('../autopilot/page') @@ -69,7 +70,9 @@ it('sets fonts, locale and pre-paint theme initialization in the app layout', as const html = renderToStaticMarkup(await Layout({ params: Promise.resolve({ lang: 'en' }), children:

Pod

})) expect(html).toContain('class="dark sans mono"') expect(html).toContain('sleepypod-pref-theme') - expect(html).toContain('

Pod

') + // Server markup must not mount device queries or expose children before authentication. + expect(html).toContain('Checking your connection') + expect(html).not.toContain('

Pod

') }) it('retries a failed System page and includes an optional error digest', async () => { diff --git a/app/api/[...rest]/route.ts b/app/api/[...rest]/route.ts index fd07c8cc4..8a01ccc47 100644 --- a/app/api/[...rest]/route.ts +++ b/app/api/[...rest]/route.ts @@ -1,12 +1,17 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' import { appRouter } from '@/src/server/routers/app' import { createOpenApiFetchHandler } from 'trpc-to-openapi' -const handler = (req: Request) => - createOpenApiFetchHandler({ +const handler = async (req: Request) => { + const denied = requireAccess(req) + if (denied) return denied + return privateApiResponse(req, await createOpenApiFetchHandler({ endpoint: '/api', req, router: appRouter, createContext: () => ({}), - }) + })) +} export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 000000000..f8d952269 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,75 @@ +import { authConfigured, authorized, issueSession, sameOrigin, SESSION_COOKIE, SESSION_SECONDS, PERSONAL_SESSION_COOKIE, validOwnerToken } from '@/src/auth/access' +import { bindBrowserScope, hasBrowserScope, personalBrowserScopeActive, browserScopeRevision, BrowserScopeConflict } from '@/src/auth/browserScope' +import { legacyPrivacyDenial, legacyPrivacyState } from '@/src/household/legacyPrivacy' + +export const dynamic = 'force-dynamic' + +export function GET(request: Request) { + const personalSessionActive = hasBrowserScope(request.headers) ? personalBrowserScopeActive(request.headers) : (request.headers.get('cookie') ?? '').split(';').some(value => value.trim().startsWith(`${PERSONAL_SESSION_COOKIE}=`)) + const privacy = legacyPrivacyState() + return Response.json({ configured: authConfigured(), authenticated: authorized(request.headers) && privacy === 'legacy', personalSessionActive, + personalConnectionRequired: privacy === 'personal_required', privacyAvailable: privacy !== 'unavailable', browserScopeRevision: browserScopeRevision(request.headers) }, { + headers: { 'Cache-Control': 'no-store' }, + }) +} + +export async function POST(request: Request) { + if (!sameOrigin(request.headers)) return Response.json({ error: 'Same-origin connection required' }, { status: 403 }) + if (!authConfigured()) return Response.json({ error: 'Configure SLEEPYPOD_AUTH_TOKEN on the controller first' }, { status: 503 }) + if (!request.headers.get('content-type')?.startsWith('application/json')) return new Response(null, { status: 415 }) + // Stream a bounded login body; an absent Content-Length must not allow unlimited buffering. + const reader = request.body?.getReader() + if (!reader) return new Response(null, { status: 400 }) + let body = '' + const decoder = new TextDecoder() + let exitPersonalSession = false + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) { + body += decoder.decode() + break + } + body += decoder.decode(chunk.value, { stream: true }) + if (body.length > 8192) { + await reader.cancel() + return new Response(null, { status: 413 }) + } + } + const value = JSON.parse(body) as { token?: unknown, exitPersonalSession?: unknown } + if (!validOwnerToken(value?.token)) return Response.json({ error: 'Invalid connection key' }, { status: 401 }) + exitPersonalSession = value.exitPersonalSession === true + } + catch { return new Response(null, { status: 400 }) } + const denied = legacyPrivacyDenial() + if (denied) return denied + const headers = new Headers({ 'Cache-Control': 'no-store' }) + const preparation = process.env.SLEEPYPOD_HOUSEHOLD_PREPARATION === '1' + const session = issueSession(Date.now(), preparation || exitPersonalSession || hasBrowserScope(request.headers)) + if (exitPersonalSession || (!personalBrowserScopeActive(request.headers) && (preparation || hasBrowserScope(request.headers)))) { + try { + headers.append('Set-Cookie', bindBrowserScope(request, 'owner', session)) + } + catch (error) { return Response.json({ error: 'Browser account state changed or is unavailable. Reconnect explicitly.' }, { status: error instanceof BrowserScopeConflict ? 409 : 503 }) } + } + headers.append('Set-Cookie', sessionCookie(request, session, SESSION_SECONDS)) + // Deliberately reconnecting the owner also leaves the personal browser scope. + if (exitPersonalSession) headers.append('Set-Cookie', `${PERSONAL_SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`) + return Response.json({ authenticated: true }, { headers }) +} + +export function DELETE(request: Request) { + if (!sameOrigin(request.headers)) return new Response(null, { status: 403 }) + if (hasBrowserScope(request.headers)) { + try { + bindBrowserScope(request, 'signed_out', '') + } + catch (error) { return new Response(null, { status: error instanceof BrowserScopeConflict ? 409 : 503 }) } + } + return new Response(null, { status: 204, headers: { 'Set-Cookie': sessionCookie(request, '', 0), 'Cache-Control': 'no-store' } }) +} + +function sessionCookie(request: Request, value: string, maxAge: number) { + const secure = new URL(request.url).protocol === 'https:' || process.env.SLEEPYPOD_HTTPS_ONLY === '1' + return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? '; Secure' : ''}` +} diff --git a/app/api/biometrics/watch-upload/route.test.ts b/app/api/biometrics/watch-upload/route.test.ts index 888b8b889..bedc5eafe 100644 --- a/app/api/biometrics/watch-upload/route.test.ts +++ b/app/api/biometrics/watch-upload/route.test.ts @@ -1,4 +1,6 @@ // @vitest-environment node +import Database from 'better-sqlite3' +import { ownerRequest } from '@/src/auth/tests/support' import { createServer, type Server } from 'node:http' import { mkdtemp, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -8,8 +10,8 @@ import { POST } from './route' let directory: string let server: Server -const seen = new Set() -const requests: Array<{ side: string, source: string }> = [] +const seen = new Set() +const requests: Array<{ side: string, source: string, metric?: string, samples?: Array<{ value?: number, heartRate?: number }> }> = [] beforeAll(async () => { directory = await mkdtemp(join(tmpdir(), 'watch-upload-test-')) vi.stubEnv('WATCH_UPLOAD_DIR', directory) @@ -20,8 +22,9 @@ beforeAll(async () => { requests.push(data) let written = 0 for (const sample of data.samples ?? []) { - if (!seen.has(sample.timestamp)) { - seen.add(sample.timestamp) + const key = `${data.side}:${data.metric ?? 'heart_rate'}:${sample.timestamp}` + if (!seen.has(key)) { + seen.add(key) written++ } } @@ -42,7 +45,7 @@ afterAll(async () => { it('streams XML through the real importer, routes to the selected side, dedupes, and removes the file', async () => { const date = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' +0000') const xml = `` - const request = () => new Request('http://pod/api/biometrics/watch-upload?side=right&days=7', { method: 'POST', body: xml }) + const request = () => ownerRequest('http://pod/api/biometrics/watch-upload?side=right&days=7', { method: 'POST', body: xml }) const response = await POST(request()) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ vitals: 1, stages: 0, matchedVitals: 1 }) @@ -51,8 +54,31 @@ it('streams XML through the real importer, routes to the selected side, dedupes, expect(await readdir(directory)).toEqual([]) }) +it('imports Watch breathing rate and SDNN separately, excludes Pod sources, and dedupes each metric', async () => { + requests.length = 0 + const date = new Date(Date.now() - 60_000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' +0000') + const record = (type: string, source: string, value: number, unit: string) => + `` + const xml = `${[ + record('RespiratoryRate', 'Apple Watch', 14.5, 'count/min'), + record('HeartRateVariabilitySDNN', 'Apple Watch', 48, 'ms'), + record('RespiratoryRate', 'sleepypod', 20, 'count/min'), + record('HeartRateVariabilitySDNN', 'sleepypod design', 99, 'ms'), + record('HeartRate', 'sleepypod Watch', 99, 'count/min'), + record('RespiratoryRate', 'Free Sleep Watch', 20, 'count/min'), + ].join('')}` + const request = () => ownerRequest('http://pod/api/biometrics/watch-upload?side=left&days=7', { method: 'POST', body: xml }) + const response = await POST(request()) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ stages: 0, vitals: 0, breathing: 1, hrv: 1, rejectedStageBatches: 0, + matchedStages: 0, matchedVitals: 0, matchedBreathing: 1, matchedHrv: 1 }) + expect(requests.map(entry => [entry.metric, entry.side, entry.samples?.map(sample => sample.value)])).toEqual([['breathing_rate', 'left', [14.5]], ['hrv_sdnn', 'left', [48]]]) + expect(await (await POST(request())).json()).toMatchObject({ breathing: 0, hrv: 0, matchedBreathing: 1, matchedHrv: 1 }) + expect(await readdir(directory)).toEqual([]) +}) + it('rejects malformed files and cleans up after parser failure', async () => { - const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: ' call[0])).toEqual([30 * 60_000, 10 * 60_000]) @@ -86,7 +112,7 @@ it('gives import a fresh budget after staging and ignores the expired upload bud }) it('accepts a same-origin browser upload even though Next reports the 0.0.0.0 bind address in request.url', async () => { - const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { + const response = await POST(ownerRequest('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { origin: 'http://192.168.1.88:3000', host: '192.168.1.88:3000' }, body: '', })) expect(response.status).toBe(200) @@ -95,11 +121,11 @@ it('accepts a same-origin browser upload even though Next reports the 0.0.0.0 bi it('rejects cross-origin requests, missing side, and oversized bodies before import', async () => { for (const origin of ['http://evil', 'http://192.168.1.88:8080', 'null']) { - const response = await POST(new Request('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { origin, host: '192.168.1.88:3000' }, body: 'x' })) - expect(response.status).toBe(403) + const response = await POST(ownerRequest('http://0.0.0.0:3000/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { origin, host: '192.168.1.88:3000' }, body: 'x' })) + expect(response.status).toBe(401) } - expect((await POST(new Request('http://pod/api/biometrics/watch-upload', { method: 'POST', body: 'x' }))).status).toBe(400) - expect((await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { 'content-length': String(513 * 1024 * 1024) }, body: 'x' }))).status).toBe(413) + expect((await POST(ownerRequest('http://pod/api/biometrics/watch-upload', { method: 'POST', body: 'x' }))).status).toBe(400) + expect((await POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', headers: { 'content-length': String(513 * 1024 * 1024) }, body: 'x' }))).status).toBe(413) }) it('still honors a client disconnect when switching to the import budget', async () => { @@ -111,7 +137,7 @@ it('still honors a client disconnect when switching to the import budget', async return new AbortController().signal }) try { - const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { + const response = await POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: '', signal: client.signal, })) expect(response.status).toBe(408) @@ -122,7 +148,7 @@ it('still honors a client disconnect when switching to the import budget', async it('rejects missing and empty bodies without retaining staged files', async () => { for (const body of [undefined, '']) { - const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body })) + const response = await POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body })) expect(response.status).toBe(400) expect(await readdir(directory)).toEqual([]) } @@ -137,7 +163,7 @@ it('enforces the streaming size limit even without a content-length header', asy controller.close() }, }) - const response = await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body, duplex: 'half' } as RequestInit)) + const response = await POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body, duplex: 'half' } as RequestInit)) expect(response.status).toBe(413) expect(await response.json()).toEqual({ error: 'Upload exceeds 512 MiB' }) expect(await readdir(directory)).toEqual([]) @@ -145,13 +171,33 @@ it('enforces the streaming size limit even without a content-length header', asy it('rejects concurrent uploads and releases the slot after staging is cancelled', async () => { const client = new AbortController() - const response = POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { + const response = POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: new ReadableStream(), duplex: 'half', signal: client.signal, } as RequestInit)) - const competing = await POST(new Request('http://pod/api/biometrics/watch-upload?side=right', { method: 'POST', body: '' })) + const competing = await POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=right', { method: 'POST', body: '' })) expect(competing.status).toBe(409) client.abort() expect((await response).status).toBe(408) expect(await readdir(directory)).toEqual([]) - expect((await POST(new Request('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: '' }))).status).toBe(200) + expect((await POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body: '' }))).status).toBe(200) +}) + +it('stops a paused legacy Health upload when a personal arrangement activates', async () => { + const previousBatches = requests.length + let source: ReadableStreamDefaultController | undefined + const body = new ReadableStream({ start(controller) { + source = controller + } }) + const pending = POST(ownerRequest('http://pod/api/biometrics/watch-upload?side=left', { method: 'POST', body, duplex: 'half' } as RequestInit)) + source?.enqueue(new TextEncoder().encode('')) + const database = new Database(process.env.DATABASE_URL) + try { + database.prepare('INSERT INTO household_privacy_boundary VALUES (1, ?, \'test-arrangement\')').run(Date.now()) + } + finally { database.close() } + const response = await pending + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ code: 'personal_connection_required' }) + expect(requests.length).toBe(previousBatches) + expect(await readdir(directory)).toEqual([]) }) diff --git a/app/api/biometrics/watch-upload/route.ts b/app/api/biometrics/watch-upload/route.ts index c5658c0d2..e7af55d3b 100644 --- a/app/api/biometrics/watch-upload/route.ts +++ b/app/api/biometrics/watch-upload/route.ts @@ -1,3 +1,5 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' import { spawn } from 'node:child_process' import { mkdir, mkdtemp, open, rm } from 'node:fs/promises' import path from 'node:path' @@ -8,7 +10,8 @@ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const MAX_BYTES = 512 * 1024 * 1024 const options = z.object({ side: z.enum(['left', 'right']), days: z.coerce.number().int().min(1).max(90).default(30) }) -const resultSchema = z.object({ stages: z.number(), vitals: z.number(), rejectedStageBatches: z.number(), matchedStages: z.number(), matchedVitals: z.number() }) +const resultSchema = z.object({ stages: z.number(), vitals: z.number(), breathing: z.number(), hrv: z.number(), rejectedStageBatches: z.number(), + matchedStages: z.number(), matchedVitals: z.number(), matchedBreathing: z.number(), matchedHrv: z.number() }) const shared = globalThis as typeof globalThis & { watchUploadBusy?: boolean } /** @@ -28,6 +31,8 @@ function isSameOrigin(request: Request): boolean { /** Raw export.zip/export.xml body, streamed to flash. Uses the same importer as the CLI. */ export async function POST(request: Request): Promise { + const denied = requireAccess(request) + if (denied) return denied if (!isSameOrigin(request)) return Response.json({ error: 'Cross-origin upload is not allowed' }, { status: 403 }) const parsed = options.safeParse(Object.fromEntries(new URL(request.url).searchParams)) if (!parsed.success) return Response.json({ error: 'Specify side=left|right and days=1..90' }, { status: 400 }) @@ -35,7 +40,16 @@ export async function POST(request: Request): Promise { if (Number(request.headers.get('content-length')) > MAX_BYTES) return Response.json({ error: 'Upload exceeds 512 MiB' }, { status: 413 }) if (shared.watchUploadBusy) return Response.json({ error: 'Another Watch import is running; retry when it finishes' }, { status: 409 }) shared.watchUploadBusy = true - let signal = AbortSignal.any([request.signal, AbortSignal.timeout(WATCH_UPLOAD_TIMEOUT_MS)]) + const accessAbort = new AbortController() + let accessDenied: Response | null = null + const checkAccess = () => { + accessDenied ??= requireAccess(request) + if (accessDenied) accessAbort.abort() + return accessDenied + } + // A quiet upload or running importer must also stop after an account change. + const accessTimer = setInterval(checkAccess, 1000) + let signal = AbortSignal.any([request.signal, accessAbort.signal, AbortSignal.timeout(WATCH_UPLOAD_TIMEOUT_MS)]) let directory: string | undefined let tooLarge = false try { @@ -49,12 +63,14 @@ export async function POST(request: Request): Promise { try { await request.body.pipeTo(new WritableStream({ async write(chunk) { + if (checkAccess()) throw new Error('Upload access changed') bytes += chunk.byteLength if (bytes > MAX_BYTES) { tooLarge = true throw new Error('Upload exceeds 512 MiB') } await file.writeFile(chunk) + if (checkAccess()) throw new Error('Upload access changed') }, }), { signal }) } @@ -62,7 +78,9 @@ export async function POST(request: Request): Promise { await file.close() } if (bytes === 0) return Response.json({ error: 'The uploaded file is empty' }, { status: 400 }) - signal = AbortSignal.any([request.signal, AbortSignal.timeout(WATCH_IMPORT_TIMEOUT_MS)]) + const beforeImport = checkAccess() + if (beforeImport) return beforeImport + signal = AbortSignal.any([request.signal, accessAbort.signal, AbortSignal.timeout(WATCH_IMPORT_TIMEOUT_MS)]) const { side, days } = parsed.data const stdout = await new Promise((resolve, reject) => { const child = spawn('python3', [path.resolve('scripts/import-watch-health.py'), filename, @@ -79,9 +97,10 @@ export async function POST(request: Request): Promise { }) child.on('close', code => code === 0 ? resolve(output) : reject(processError ?? new Error('Import failed'))) }) - return Response.json(resultSchema.parse(JSON.parse(stdout))) + return privateApiResponse(request, Response.json(resultSchema.parse(JSON.parse(stdout)))) } catch { + if (accessDenied) return accessDenied return Response.json({ error: tooLarge ? 'Upload exceeds 512 MiB' : signal.aborted @@ -89,6 +108,7 @@ export async function POST(request: Request): Promise { : 'Import failed. Use an Apple Health export.zip or export.xml. Some batches may have been saved; retrying is safe.' }, { status: tooLarge ? 413 : signal.aborted ? 408 : 422 }) } finally { + clearInterval(accessTimer) try { if (directory) await rm(directory, { recursive: true, force: true }) } diff --git a/app/api/db-backup/route.test.ts b/app/api/db-backup/route.test.ts new file mode 100644 index 000000000..c10fb61b4 --- /dev/null +++ b/app/api/db-backup/route.test.ts @@ -0,0 +1,86 @@ +import type DatabaseConstructor from 'better-sqlite3' +// @vitest-environment node +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { ownerRequest } from '@/src/auth/tests/support' + +const state = vi.hoisted(() => ({ + restricted: false, + backup: vi.fn(async () => {}), + spawn: vi.fn(), + remove: vi.fn(async () => {}), +})) +vi.mock('@/src/household/privacy', () => ({ householdBackupRestricted: () => state.restricted })) +vi.mock('@/src/lib/dbInspect', () => ({ DB_FILES: { sleepypod: { path: '/fixture/config.db' } }, backupMarkerPath: () => '/fixture/marker' })) +vi.mock('better-sqlite3', async () => { + const { default: ActualDatabase } = await vi.importActual<{ default: typeof DatabaseConstructor }>('better-sqlite3') + return { default: function (this: { backup: typeof state.backup, close: () => void }, path: string, options?: { fileMustExist?: boolean }) { + if (options?.fileMustExist && path === process.env.DATABASE_URL) return new ActualDatabase(path, options) + this.backup = state.backup + this.close = () => {} + } } +}) +vi.mock('node:child_process', () => ({ spawn: state.spawn })) +vi.mock('node:fs/promises', () => ({ mkdir: async () => {}, mkdtemp: async () => '/fixture/staging', rm: state.remove, writeFile: async () => {} })) + +let child: EventEmitter & { stdout: PassThrough, stderr: PassThrough, kill: ReturnType } +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + state.restricted = false + state.backup.mockImplementation(async () => {}) + child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) }) + state.spawn.mockReturnValue(child) +}) +afterEach(() => { + child.emit('close', 1) + child.stdout.destroy() + child.stderr.destroy() + vi.unstubAllEnvs() +}) + +it('withholds a backup if personal household protection is enabled during its snapshot', async () => { + state.backup.mockImplementationOnce(async () => { + state.restricted = true + }) + const { GET } = await import('./route') + const response = await GET(ownerRequest('http://pod/api/db-backup')) + expect(response.status).toBe(403) + expect(response.headers.get('Content-Disposition')).toBeNull() + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(state.remove).toHaveBeenCalledWith('/fixture/staging', { recursive: true, force: true }) +}) + +it('cancels a running backup when protection changes before the next output chunk', async () => { + const { GET } = await import('./route') + const response = await GET(ownerRequest('http://pod/api/db-backup')) + expect(response.status).toBe(200) + const pending = response.arrayBuffer() + state.restricted = true + child.stdout.write(Buffer.from('private database bytes')) + await expect(pending).rejects.toThrow('access changed') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(state.remove).toHaveBeenCalledWith('/fixture/staging', { recursive: true, force: true }) +}) + +it('does not release a newer backup when the canceled producer later exits', async () => { + const { GET } = await import('./route') + const first = await GET(ownerRequest('http://pod/api/db-backup')) + await first.body?.cancel() + const oldChild = child + const nextChild = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), kill: vi.fn(() => true) }) + state.spawn.mockReturnValue(nextChild) + const next = await GET(ownerRequest('http://pod/api/db-backup')) + expect(next.status).toBe(200) + oldChild.emit('close', 1) + try { + expect((await GET(ownerRequest('http://pod/api/db-backup'))).status).toBe(429) + } + finally { + await next.body?.cancel() + nextChild.emit('close', 1) + nextChild.stdout.destroy() + nextChild.stderr.destroy() + } +}) diff --git a/app/api/db-backup/route.ts b/app/api/db-backup/route.ts index 829f43c86..5960a951d 100644 --- a/app/api/db-backup/route.ts +++ b/app/api/db-backup/route.ts @@ -1,9 +1,12 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' import { spawn } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { Readable } from 'node:stream' import Database from 'better-sqlite3' import { backupMarkerPath, DB_FILES, type DbKey } from '@/src/lib/dbInspect' +import { householdBackupRestricted } from '@/src/household/privacy' // Staging on /persistent, not /tmp: /tmp is a RAM-backed tmpfs on the pod and // the biometrics copy alone can be tens of MB. @@ -29,6 +32,11 @@ async function backupSqlite(dbPath: string, outPath: string): Promise { * copy of each .db with sleepypod stopped. */ export async function GET(request: Request) { + const denied = requireAccess(request) + if (denied) return denied + const backupAccess = () => householdBackupRestricted(DB_FILES.sleepypod.path) ? Response.json({ error: 'Whole-database downloads are unavailable for personal households. Use an encrypted administrator backup and person-scoped exports.' }, { status: 403, headers: { 'Cache-Control': 'private, no-store' } }) : null + const restricted = backupAccess() + if (restricted) return restricted if (inflight) { return Response.json({ error: 'A backup is already in progress, retry shortly' }, { status: 429, headers: { 'Retry-After': '30' } }) } @@ -48,12 +56,15 @@ export async function GET(request: Request) { const cleanup = () => { const dir = stagingDir + // A canceled producer may exit after another request has acquired the slot. + if (!dir) return stagingDir = null - if (dir) rm(dir, { recursive: true, force: true }).catch(() => {}) + rm(dir, { recursive: true, force: true }).catch(() => {}) inflight = false } const abort = () => { tarChild.kill('SIGKILL') + settle() cleanup() } const watchdog = setTimeout(abort, WATCHDOG_MS) @@ -75,12 +86,12 @@ export async function GET(request: Request) { }) const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '') - return new Response(Readable.toWeb(tarChild.stdout) as ReadableStream, { + return privateApiResponse(request, new Response(Readable.toWeb(tarChild.stdout) as ReadableStream, { headers: { 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename="sleepypod-databases-${stamp}.tar.gz"`, }, - }) + }), { additionalAccess: backupAccess, onCancel: abort }) } catch (error) { if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {}) diff --git a/app/api/docs/route.ts b/app/api/docs/route.ts index 5a359a96c..5d0f78c5c 100644 --- a/app/api/docs/route.ts +++ b/app/api/docs/route.ts @@ -1,3 +1,4 @@ +import { requireAccess } from '@/src/auth/access' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' @@ -6,6 +7,8 @@ import type { NextRequest } from 'next/server' * Scalar is loaded from CDN — no extra dependencies required. */ export function GET(req: NextRequest) { + const denied = requireAccess(req) + if (denied) return denied const specUrl = `${req.nextUrl.protocol}//${req.nextUrl.host}/api/openapi.json` const html = ` diff --git a/app/api/export/archive/route.handler.test.ts b/app/api/export/archive/route.handler.test.ts index 87846b2b1..360e756e5 100644 --- a/app/api/export/archive/route.handler.test.ts +++ b/app/api/export/archive/route.handler.test.ts @@ -1,3 +1,5 @@ +import type DatabaseConstructor from 'better-sqlite3' +import { ownerRequest, TEST_OWNER_TOKEN } from '@/src/auth/tests/support' /** * Tests for the GET handler in app/api/export/archive/route.ts. Covers the * inflight guard, the staging-dir setup, conditional db backup, raw symlink @@ -46,8 +48,10 @@ const sqliteMock = vi.hoisted(() => ({ ctor: vi.fn(), })) -vi.mock('better-sqlite3', () => { +vi.mock('better-sqlite3', async () => { + const { default: ActualDatabase } = await vi.importActual<{ default: typeof DatabaseConstructor }>('better-sqlite3') const Database = vi.fn(function (this: Record, ...args: unknown[]) { + if ((args[1] as { fileMustExist?: boolean } | undefined)?.fileMustExist && args[0] === process.env.DATABASE_URL) return new ActualDatabase(args[0] as string, args[1] as object) sqliteMock.ctor(...args) this.backup = sqliteMock.backup this.close = sqliteMock.close @@ -89,10 +93,39 @@ async function loadRoute() { } describe('GET /api/export/archive', () => { + it('does not release a newer export when a canceled producer exits late', async () => { + const { GET } = await loadRoute() + const first = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) + const earlierChild = spawnMock.lastChild + if (!first.body || !earlierChild) throw new Error('Expected archive process') + await first.body.cancel() + const next = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) + expect(next.status).toBe(200) + earlierChild.emit('close', 1) + expect((await GET(ownerRequest('http://localhost/api/export/archive?include=raw'))).status).toBe(429) + await next.body?.cancel() + }) + + it('cancels the archive process and staging if access changes before bytes are read', async () => { + const { GET } = await loadRoute() + const request = ownerRequest('http://localhost/api/export/archive?include=raw') + const response = await GET(request) + const child = spawnMock.lastChild + if (!child) throw new Error('Expected archive process') + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN + '-rotated') + await expect(response.arrayBuffer()).rejects.toThrow('access changed') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(fsMock.rm).toHaveBeenCalledWith(expect.stringContaining('sp-export-'), { recursive: true, force: true }) + const following = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) + expect(following.status).toBe(200) + if (!following.body) throw new Error('Expected archive stream') + await following.body.cancel() + }) + it('returns a gzip stream response with default params (raw + db)', async () => { process.env.BIOMETRICS_DATABASE_URL = 'file:/tmp/biometrics.db' const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive') + const req = ownerRequest('http://localhost/api/export/archive') const res = await GET(req) expect(res.status).toBe(200) @@ -115,10 +148,10 @@ describe('GET /api/export/archive', () => { it('responds 429 when another export is already inflight', async () => { const { GET } = await loadRoute() // First call leaves `inflight = true` until tar closes; don't fire close. - const req1 = new Request('http://localhost/api/export/archive') + const req1 = ownerRequest('http://localhost/api/export/archive') await GET(req1) - const req2 = new Request('http://localhost/api/export/archive') + const req2 = ownerRequest('http://localhost/api/export/archive') const res = await GET(req2) expect(res.status).toBe(429) expect(res.headers.get('Retry-After')).toBe('60') @@ -129,7 +162,7 @@ describe('GET /api/export/archive', () => { it('skips db backup when include omits db', async () => { process.env.BIOMETRICS_DATABASE_URL = 'file:/tmp/biometrics.db' const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=raw&startTs=0&endTs=10') + const req = ownerRequest('http://localhost/api/export/archive?include=raw&startTs=0&endTs=10') const res = await GET(req) expect(res.status).toBe(200) expect(sqliteMock.backup).not.toHaveBeenCalled() @@ -139,7 +172,7 @@ describe('GET /api/export/archive', () => { it('skips db backup when BIOMETRICS_DATABASE_URL is empty even if include=db', async () => { delete process.env.BIOMETRICS_DATABASE_URL const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=db') + const req = ownerRequest('http://localhost/api/export/archive?include=db') const res = await GET(req) expect(res.status).toBe(200) expect(sqliteMock.backup).not.toHaveBeenCalled() @@ -148,7 +181,7 @@ describe('GET /api/export/archive', () => { it('skips raw staging when include omits raw', async () => { process.env.BIOMETRICS_DATABASE_URL = 'file:/tmp/biometrics.db' const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=db') + const req = ownerRequest('http://localhost/api/export/archive?include=db') const res = await GET(req) expect(res.status).toBe(200) // mkdir for staging root is called, but the raw subdir mkdir is not @@ -166,7 +199,7 @@ describe('GET /api/export/archive', () => { fsMock.stat.mockImplementation(async () => ({ mtime: new Date(5000 * 1000) })) const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=raw&startTs=0&endTs=10000') + const req = ownerRequest('http://localhost/api/export/archive?include=raw&startTs=0&endTs=10000') const res = await GET(req) expect(res.status).toBe(200) expect(fsMock.symlink).toHaveBeenCalledTimes(1) @@ -181,7 +214,7 @@ describe('GET /api/export/archive', () => { fsMock.symlink.mockRejectedValueOnce(new Error('EEXIST')) const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=raw&startTs=0&endTs=10000') + const req = ownerRequest('http://localhost/api/export/archive?include=raw&startTs=0&endTs=10000') const res = await GET(req) expect(res.status).toBe(200) // failure is swallowed }) @@ -190,14 +223,14 @@ describe('GET /api/export/archive', () => { fsMock.mkdir.mockRejectedValueOnce(new Error('disk full')) const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive') + const req = ownerRequest('http://localhost/api/export/archive') const res = await GET(req) expect(res.status).toBe(500) const body = await res.json() expect(body).toEqual({ error: 'disk full' }) // After failure, inflight is reset so a follow-up call doesn't 429. - const res2 = await GET(new Request('http://localhost/api/export/archive')) + const res2 = await GET(ownerRequest('http://localhost/api/export/archive')) expect(res2.status).not.toBe(429) }) @@ -207,7 +240,7 @@ describe('GET /api/export/archive', () => { }) const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive') + const req = ownerRequest('http://localhost/api/export/archive') const res = await GET(req) expect(res.status).toBe(500) const body = await res.json() @@ -222,7 +255,7 @@ describe('GET /api/export/archive', () => { .mockImplementationOnce(async () => { throw new Error('boom') }) // raw subdir const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=raw') + const req = ownerRequest('http://localhost/api/export/archive?include=raw') const res = await GET(req) expect(res.status).toBe(500) expect(fsMock.rm).toHaveBeenCalledWith( @@ -234,7 +267,7 @@ describe('GET /api/export/archive', () => { it('logs tar stderr and clears inflight when tar exits non-zero', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=raw') + const req = ownerRequest('http://localhost/api/export/archive?include=raw') const res = await GET(req) expect(res.status).toBe(200) @@ -245,14 +278,14 @@ describe('GET /api/export/archive', () => { expect(errSpy).toHaveBeenCalledWith('tar:', expect.stringContaining('tar: bad file')) // Now inflight should be cleared — follow-up call must not 429. - const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw')) + const res2 = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) expect(res2.status).not.toBe(429) errSpy.mockRestore() }) it('clears inflight on tar spawn error event', async () => { const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?include=raw') + const req = ownerRequest('http://localhost/api/export/archive?include=raw') const res = await GET(req) expect(res.status).toBe(200) @@ -260,27 +293,27 @@ describe('GET /api/export/archive', () => { if (!child) throw new Error('expected spawn to have been called') child.emit('error', new Error('ENOENT: tar not found')) - const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw')) + const res2 = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) expect(res2.status).not.toBe(429) }) it('rejects non-numeric startTs/endTs with 400 without latching inflight', async () => { const { GET } = await loadRoute() - const res = await GET(new Request('http://localhost/api/export/archive?startTs=garbage')) + const res = await GET(ownerRequest('http://localhost/api/export/archive?startTs=garbage')) expect(res.status).toBe(400) const body = await res.json() expect(body).toEqual({ error: 'startTs and endTs must be numeric epoch seconds' }) expect(spawnMock.spawn).not.toHaveBeenCalled() // inflight must not have been taken by the rejected request. - const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw')) + const res2 = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) expect(res2.status).toBe(200) }) it('kills tar, clears inflight, and removes staging when the client disconnects', async () => { const { GET } = await loadRoute() const controller = new AbortController() - const req = new Request('http://localhost/api/export/archive?include=raw', { + const req = ownerRequest('http://localhost/api/export/archive?include=raw', { signal: controller.signal, }) const res = await GET(req) @@ -297,7 +330,7 @@ describe('GET /api/export/archive', () => { { recursive: true, force: true }, ) // inflight cleared — a follow-up export is allowed. - const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw')) + const res2 = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) expect(res2.status).not.toBe(429) }) @@ -305,7 +338,7 @@ describe('GET /api/export/archive', () => { process.env.EXPORT_WATCHDOG_MS = '30' try { const { GET } = await loadRoute() - const res = await GET(new Request('http://localhost/api/export/archive?include=raw')) + const res = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) expect(res.status).toBe(200) const child = spawnMock.lastChild @@ -315,7 +348,7 @@ describe('GET /api/export/archive', () => { await new Promise(r => setTimeout(r, 80)) expect(child.kill).toHaveBeenCalledWith('SIGKILL') - const res2 = await GET(new Request('http://localhost/api/export/archive?include=raw')) + const res2 = await GET(ownerRequest('http://localhost/api/export/archive?include=raw')) expect(res2.status).not.toBe(429) } finally { @@ -325,7 +358,7 @@ describe('GET /api/export/archive', () => { it('uses the provided startTs and endTs in the filename', async () => { const { GET } = await loadRoute() - const req = new Request('http://localhost/api/export/archive?startTs=100&endTs=200&include=raw') + const req = ownerRequest('http://localhost/api/export/archive?startTs=100&endTs=200&include=raw') const res = await GET(req) expect(res.headers.get('Content-Disposition')).toBe( 'attachment; filename="sleepypod-export-100-200.tar.gz"', diff --git a/app/api/export/archive/route.ts b/app/api/export/archive/route.ts index 6ccb4d901..a7a5e83d7 100644 --- a/app/api/export/archive/route.ts +++ b/app/api/export/archive/route.ts @@ -1,3 +1,5 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' import { spawn } from 'node:child_process' import { mkdir, mkdtemp, readdir, rm, stat, symlink } from 'node:fs/promises' import path from 'node:path' @@ -97,6 +99,8 @@ async function backupSqlite(dbPath: string, outPath: string): Promise { } export async function GET(request: Request) { + const denied = requireAccess(request) + if (denied) return denied const url = new URL(request.url) if (inflight) { @@ -156,8 +160,10 @@ export async function GET(request: Request) { const cleanup = () => { const dir = stagingDir + // A canceled producer may exit after another request has acquired the slot. + if (!dir) return stagingDir = null - if (dir) rm(dir, { recursive: true, force: true }).catch(() => {}) + rm(dir, { recursive: true, force: true }).catch(() => {}) inflight = false } @@ -165,6 +171,7 @@ export async function GET(request: Request) { // the stream, and a wedged tar never emits close on its own. const abortExport = () => { tarChild.kill('SIGKILL') + settle() cleanup() } const watchdog = setTimeout(abortExport, EXPORT_WATCHDOG_MS) @@ -186,12 +193,12 @@ export async function GET(request: Request) { const stream = Readable.toWeb(tarChild.stdout) as ReadableStream const filename = `sleepypod-export-${startTs}-${endTs}.tar.gz` - return new Response(stream, { + return privateApiResponse(request, new Response(stream, { headers: { 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename="${filename}"`, }, - }) + }), { onCancel: abortExport }) } catch (error) { if (stagingDir) await rm(stagingDir, { recursive: true, force: true }).catch(() => {}) diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index aa4baca2c..a966715b0 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -1,9 +1,11 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' /** * MCP endpoint (Streamable HTTP, stateless). * * Each request builds a fresh McpServer + transport pair, so no session state * lives in the Next.js process and horizontal scaling or restarts are safe. - * Like the rest of the API this trusts the LAN: do not expose it to the WAN. + * Requires owner authentication and an explicit SLEEPYPOD_ENABLE_MCP opt-in. * * Client setup: * claude mcp add --transport http sleepypod http://:3000/api/mcp @@ -30,6 +32,9 @@ export function isOriginAllowed(req: Request): boolean { } async function handler(req: Request): Promise { + const denied = requireAccess(req) + if (denied) return denied + if (process.env.SLEEPYPOD_ENABLE_MCP !== '1') return new Response(null, { status: 404 }) if (!isOriginAllowed(req)) { return Response.json( { jsonrpc: '2.0', error: { code: -32000, message: 'Forbidden: cross-origin request' }, id: null }, @@ -43,7 +48,7 @@ async function handler(req: Request): Promise { }) try { await server.connect(transport) - return await transport.handleRequest(req) + return privateApiResponse(req, await transport.handleRequest(req)) } finally { // The response body is already materialized in JSON mode, so closing here diff --git a/app/api/mcp/tests/route.test.ts b/app/api/mcp/tests/route.test.ts index 6398bee53..99b920420 100644 --- a/app/api/mcp/tests/route.test.ts +++ b/app/api/mcp/tests/route.test.ts @@ -1,13 +1,14 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/src/server/routers/app', () => ({ appRouter: { createCaller: () => ({}) }, })) +import { ownerRequest } from '@/src/auth/tests/support' import { POST, isOriginAllowed } from '@/app/api/mcp/route' function rpc(body: unknown, extraHeaders: Record = {}): Request { - return new Request('http://pod.local/api/mcp', { + return ownerRequest('http://pod.local/api/mcp', { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', 'host': 'pod.local', ...extraHeaders }, body: JSON.stringify(body), @@ -17,10 +18,20 @@ function rpc(body: unknown, extraHeaders: Record = {}): Request const listTools = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } describe('POST /api/mcp', () => { + beforeEach(() => { + vi.stubEnv('SLEEPYPOD_ENABLE_MCP', '1') + }) + afterEach(() => { + vi.unstubAllEnvs() + }) + it('is disabled unless explicitly enabled, even for an authenticated owner', async () => { + vi.stubEnv('SLEEPYPOD_ENABLE_MCP', '') + expect((await POST(rpc(listTools))).status).toBe(404) + }) it('rejects cross-origin browser requests (DNS rebinding) but allows same-origin and originless clients', async () => { const evil = await POST(rpc(listTools, { origin: 'http://evil.example' })) - expect(evil.status).toBe(403) - expect((await evil.json()).error.message).toContain('cross-origin') + expect(evil.status).toBe(401) + expect((await evil.json()).error).toBe('Authentication required') const same = await POST(rpc(listTools, { origin: 'http://pod.local' })) expect(same.status).toBe(200) diff --git a/app/api/openapi.json/route.ts b/app/api/openapi.json/route.ts index 8315be156..534385c95 100644 --- a/app/api/openapi.json/route.ts +++ b/app/api/openapi.json/route.ts @@ -1,12 +1,15 @@ +import { requireAccess } from '@/src/auth/access' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { getOpenApiDocument } from '@/src/server/openapi' export function GET(req: NextRequest) { + const denied = requireAccess(req) + if (denied) return denied const baseUrl = `${req.nextUrl.protocol}//${req.nextUrl.host}` const document = getOpenApiDocument(baseUrl) return NextResponse.json(document, { - headers: { 'Cache-Control': 'public, max-age=60' }, + headers: { 'Cache-Control': 'private, no-store' }, }) } diff --git a/app/api/raw/[filename]/route.ts b/app/api/raw/[filename]/route.ts index 12b481cf5..2fc4a6636 100644 --- a/app/api/raw/[filename]/route.ts +++ b/app/api/raw/[filename]/route.ts @@ -1,3 +1,5 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' import { NextResponse } from 'next/server' import { createReadStream } from 'node:fs' import { lstat, realpath, stat } from 'node:fs/promises' @@ -13,6 +15,8 @@ export async function GET( _request: Request, { params }: { params: Promise<{ filename: string }> }, ) { + const denied = requireAccess(_request) + if (denied) return denied const { filename } = await params if (!SAFE_FILENAME.test(filename)) { @@ -42,13 +46,13 @@ export async function GET( const stream = createReadStream(/* turbopackIgnore: true */ resolved) const webStream = Readable.toWeb(stream) as ReadableStream - return new Response(webStream, { + return privateApiResponse(_request, new Response(webStream, { headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Length': String(s.size), }, - }) + })) } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { diff --git a/app/api/raw/[filename]/tests/route.test.ts b/app/api/raw/[filename]/tests/route.test.ts index 2a57dd7ac..14fd06d37 100644 --- a/app/api/raw/[filename]/tests/route.test.ts +++ b/app/api/raw/[filename]/tests/route.test.ts @@ -1,11 +1,12 @@ // @vitest-environment node +import { ownerRequest, TEST_OWNER_TOKEN } from '@/src/auth/tests/support' import { Readable } from 'node:stream' import { beforeEach, expect, it, vi } from 'vitest' import { GET } from '../route' const fs = vi.hoisted(() => ({ lstat: vi.fn(), realpath: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() })) vi.mock('node:fs/promises', () => ({ lstat: fs.lstat, realpath: fs.realpath, stat: fs.stat })) vi.mock('node:fs', () => ({ createReadStream: fs.createReadStream })) -const request = (filename = 'capture.RAW') => GET(new Request('http://localhost/api/raw/' + filename), { params: Promise.resolve({ filename }) }) +const request = (filename = 'capture.RAW') => GET(ownerRequest('http://localhost/api/raw/' + filename), { params: Promise.resolve({ filename }) }) beforeEach(() => { vi.resetAllMocks() fs.lstat.mockResolvedValue({ isSymbolicLink: () => false }) @@ -21,6 +22,16 @@ it('streams the original bytes with explicit download metadata', async () => { expect(response.headers.get('Content-Length')).toBe('3') expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])) }) +it('withholds RAW bytes after the connection key changes during filesystem inspection', async () => { + fs.stat.mockImplementationOnce(async () => { + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN + '-rotated') + return { size: 3 } + }) + const response = await request() + expect(response.status).toBe(401) + expect(response.headers.get('Content-Disposition')).toBeNull() + expect(await response.text()).not.toContain('capture.RAW') +}) it.each(['../private.RAW', 'nested/capture.RAW', 'capture.txt', 'capture.RAW\n'])('rejects invalid filename %s before filesystem access', async (filename) => { expect((await request(filename)).status).toBe(400) expect(fs.lstat).not.toHaveBeenCalled() diff --git a/app/api/trpc/[trpc]/route.ts b/app/api/trpc/[trpc]/route.ts index 9befb9f4d..06492b77b 100644 --- a/app/api/trpc/[trpc]/route.ts +++ b/app/api/trpc/[trpc]/route.ts @@ -1,14 +1,17 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' import { appRouter } from '@/src/server/routers/app' import { fetchRequestHandler } from '@trpc/server/adapters/fetch' -const handler = (req: Request) => { - if (process.env.NODE_ENV !== 'production') console.log('tRPC incoming:', req.url) - return fetchRequestHandler({ +const handler = async (req: Request) => { + const denied = requireAccess(req) + if (denied) return denied + return privateApiResponse(req, await fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => ({}), - }) + })) } export { handler as GET, handler as POST } diff --git a/app/api/trpc/route.ts b/app/api/trpc/route.ts index e911a73ad..06492b77b 100644 --- a/app/api/trpc/route.ts +++ b/app/api/trpc/route.ts @@ -1,12 +1,17 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' import { appRouter } from '@/src/server/routers/app' import { fetchRequestHandler } from '@trpc/server/adapters/fetch' -const handler = (req: Request) => - fetchRequestHandler({ +const handler = async (req: Request) => { + const denied = requireAccess(req) + if (denied) return denied + return privateApiResponse(req, await fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => ({}), - }) + })) +} export { handler as GET, handler as POST } diff --git a/app/api/v1/capabilities/route.test.ts b/app/api/v1/capabilities/route.test.ts new file mode 100644 index 000000000..e285a312c --- /dev/null +++ b/app/api/v1/capabilities/route.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { generateOpenApiDocument } from 'trpc-to-openapi' +import { router } from '@/src/server/trpc' +import { ownerRequest, TEST_OWNER_TOKEN } from '@/src/auth/tests/support' +import { buildCapabilities } from '@/src/capabilities/contract' + +const runtime = vi.hoisted(() => ({ read: vi.fn() })) +vi.mock('@/src/capabilities/read', () => ({ readCapabilities: runtime.read })) +const { GET } = await import('./route') +const { capabilitiesRouter } = await import('@/src/server/routers/capabilities') +const address = 'http://pod.local/api/v1/capabilities' + +beforeEach(() => { + runtime.read.mockReset().mockReturnValue(buildCapabilities({ + now: Date.parse('2026-10-25T20:00:00Z'), revision: null, mqttConnected: false, + device: { status: null, current: false, observedAt: null }, base: null, + })) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN) +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('authenticated versioned capability discovery', () => { + it('does not read runtime state without an owner credential', async () => { + expect((await GET(new Request(address))).status).toBe(401) + expect((await GET(new Request(address, { headers: { Authorization: 'Bearer invalid' } }))).status).toBe(401) + expect(runtime.read).not.toHaveBeenCalled() + }) + + it('fails closed before owner provisioning', async () => { + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', '') + expect((await GET(new Request(address))).status).toBe(503) + expect(runtime.read).not.toHaveBeenCalled() + }) + + it('returns the same validated contract through REST and tRPC, without caching', async () => { + const response = await GET(ownerRequest(address)) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('vary')).toBe('Authorization, Cookie') + const expected = await capabilitiesRouter.createCaller({}).get({}) + expect(await response.json()).toEqual(expected) + expect(expected.features.personalComfort).toMatchObject({ implemented: true, runtime: 'not_ready', physicalVerification: 'unverified' }) + }) + + it('rejects browser origins from another site even with a valid bearer', async () => { + const response = await GET(ownerRequest(address, { headers: { Host: 'pod.local', Origin: 'https://different.example' } })) + expect(response.status).toBe(401) + expect(runtime.read).not.toHaveBeenCalled() + }) + + it('publishes the v1 path and response in generated OpenAPI', () => { + const document = generateOpenApiDocument(router({ capabilities: capabilitiesRouter }), { title: 'Test', version: '1', baseUrl: 'http://pod.local/api' }) + expect(document.paths?.['/v1/capabilities']?.get?.responses?.['200']).toBeDefined() + }) +}) diff --git a/app/api/v1/capabilities/route.ts b/app/api/v1/capabilities/route.ts new file mode 100644 index 000000000..2c53d661c --- /dev/null +++ b/app/api/v1/capabilities/route.ts @@ -0,0 +1,13 @@ +import { requireAccess } from '@/src/auth/access' +import { privateApiResponse } from '@/src/auth/privateResponse' + +export const dynamic = 'force-dynamic' + +export async function GET(request: Request): Promise { + const denied = requireAccess(request) + if (denied) return denied + const { capabilitiesRouter } = await import('@/src/server/routers/capabilities') + return privateApiResponse(request, Response.json(await capabilitiesRouter.createCaller({}).get({}), { + headers: { 'Cache-Control': 'private, no-store', 'Vary': 'Authorization, Cookie' }, + })) +} diff --git a/app/api/v1/household/[...path]/route.ts b/app/api/v1/household/[...path]/route.ts new file mode 100644 index 000000000..df8e415d4 --- /dev/null +++ b/app/api/v1/household/[...path]/route.ts @@ -0,0 +1,7 @@ +import { handleHouseholdRequest } from '@/src/household/runtime' + +export const dynamic = 'force-dynamic' +export const GET = handleHouseholdRequest +export const POST = handleHouseholdRequest +export const PUT = handleHouseholdRequest +export const DELETE = handleHouseholdRequest diff --git a/app/api/v1/maintenance/status/route.test.ts b/app/api/v1/maintenance/status/route.test.ts new file mode 100644 index 000000000..08eaac2e1 --- /dev/null +++ b/app/api/v1/maintenance/status/route.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import Database from 'better-sqlite3' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TEST_OWNER_TOKEN } from '@/src/auth/tests/support' +import { issueSession, requireAccess, SESSION_COOKIE } from '@/src/auth/access' +import { buildMaintenanceStatus } from '@/src/maintenance/statusContract' +import { proxy } from '@/proxy' + +const runtime = vi.hoisted(() => ({ read: vi.fn() })) +vi.mock('@/src/maintenance/readStatus', () => ({ readMaintenanceStatus: runtime.read })) +const { GET } = await import('./route') +const address = 'http://127.0.0.1:3000/api/v1/maintenance/status' +function request(init?: RequestInit) { + return new Request(address, { ...init, headers: { Authorization: `Bearer ${TEST_OWNER_TOKEN}`, ...init?.headers } }) +} + +beforeEach(() => { + runtime.read.mockReset().mockResolvedValue(buildMaintenanceStatus({ + now: Date.now(), revision: 'abcdef123', status: null, observedAt: null, current: false, privacyBoundary: 'personal_required', + })) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN) + const path = process.env.DATABASE_URL + if (!path) throw new Error('Isolated privacy fixture is required') + const database = new Database(path) + database.exec('INSERT INTO household_privacy_boundary VALUES (1, 1, \'personal fixture\')') + database.close() +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('private administrative maintenance route', () => { + it('allows exactly one status route while legacy owner paths remain closed', async () => { + expect(requireAccess(request())?.status).toBe(403) + expect(proxy(new NextRequest(request()))).toBeUndefined() + const response = await GET(request()) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(response.headers.get('Vary')).toBe('Authorization, Cookie, Origin') + expect(await response.json()).toEqual(await runtime.read.mock.results[0].value) + for (const path of ['/api/v1/maintenance/anything', '/api/v1/maintenance/status/private', '/api/settings', '/api/v1/capabilities']) { + expect(proxy(new NextRequest(new URL(path, address), { headers: request().headers }))?.status).toBe(403) + } + const rebinding = new NextRequest(address, { headers: { ...Object.fromEntries(request().headers), host: 'attacker.example' } }) + expect(proxy(rebinding)?.status).toBe(403) + }) + + it.each>([ + {}, { Authorization: 'Bearer personal-device-fixture' }, { Authorization: 'Basic invalid' }, + ])('does not read state without the administrative bearer: %s', async (headers) => { + expect((await GET(new Request(address, { headers }))).status).toBe(401) + expect(runtime.read).not.toHaveBeenCalled() + }) + + it.each>([{ Origin: 'http://127.0.0.1:3000' }, { 'Sec-Fetch-Site': 'same-origin' }, { Cookie: 'anything=present' }])('rejects browser context even with a correct key: %s', async (headers) => { + expect((await GET(request({ headers }))).status).toBe(401) + expect(runtime.read).not.toHaveBeenCalled() + }) + + it('does not accept a signed owner session', async () => { + expect((await GET(new Request(address, { headers: { Cookie: `${SESSION_COOKIE}=${issueSession()}` } }))).status).toBe(401) + expect(runtime.read).not.toHaveBeenCalled() + }) + + it('rejects query selectors, non-read methods and missing provisioning', async () => { + expect((await GET(new Request(`${address}?side=left`, { headers: request().headers }))).status).toBe(400) + expect((await GET(request({ method: 'POST' }))).status).toBe(405) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', '') + expect((await GET(request())).status).toBe(503) + expect(runtime.read).not.toHaveBeenCalled() + }) + + it('rechecks key rotation after an awaited read and before consuming the response', async () => { + const response = await GET(request()) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN + '-rotated') + await expect(response.text()).rejects.toThrow('access changed') + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN) + runtime.read.mockImplementation(async () => { + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN + '-rotated') + return {} + }) + expect((await GET(request())).status).toBe(401) + }) + + it('withholds output on abort and never includes an exception or unexpected field', async () => { + const abort = new AbortController() + const response = await GET(request({ signal: abort.signal })) + abort.abort() + await expect(response.text()).rejects.toThrow('access changed') + expect((await GET(request({ signal: abort.signal }))).status).toBe(499) + runtime.read.mockRejectedValue(new Error('private-account-path')) + const failed = await GET(request()) + expect(failed.status).toBe(503) + expect(await failed.text()).not.toContain('private-account-path') + runtime.read.mockResolvedValue({ privateSleeper: 'private fixture' }) + expect((await GET(request())).status).toBe(503) + }) +}) diff --git a/app/api/v1/maintenance/status/route.ts b/app/api/v1/maintenance/status/route.ts new file mode 100644 index 000000000..77a7f8782 --- /dev/null +++ b/app/api/v1/maintenance/status/route.ts @@ -0,0 +1,13 @@ +import { maintenanceError, maintenanceStatusResponse, requireMaintenanceStatusAccess } from '@/src/maintenance/statusAccess' + +export const dynamic = 'force-dynamic' + +export async function GET(request: Request): Promise { + const denied = requireMaintenanceStatusAccess(request) + if (denied) return denied + try { + const { readMaintenanceStatus } = await import('@/src/maintenance/readStatus') + return maintenanceStatusResponse(request, await readMaintenanceStatus()) + } + catch { return maintenanceError(503, 'maintenance_status_unavailable') } +} diff --git a/app/api/v1/me/[...path]/route.ts b/app/api/v1/me/[...path]/route.ts new file mode 100644 index 000000000..65025e0a9 --- /dev/null +++ b/app/api/v1/me/[...path]/route.ts @@ -0,0 +1,6 @@ +import { handleHouseholdRequest } from '@/src/household/runtime' + +export const dynamic = 'force-dynamic' +export const GET = handleHouseholdRequest +export const POST = handleHouseholdRequest +export const PUT = handleHouseholdRequest diff --git a/app/globals.css b/app/globals.css index 3510997a4..cd1b030a7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -574,3 +574,8 @@ html.dark { animation-name: sp-toast-fade; } } + +/* The full-screen bed has its own Settings control. */ +main:has([data-testid="temp-stage"]) > [data-mobile-toolbar] { + display: none; +} diff --git a/docs/ACCOUNT_RESPONSE_BOUNDARIES.md b/docs/ACCOUNT_RESPONSE_BOUNDARIES.md new file mode 100644 index 000000000..21162a182 --- /dev/null +++ b/docs/ACCOUNT_RESPONSE_BOUNDARIES.md @@ -0,0 +1,54 @@ +# Account changes during a response + +An owner browser can change to a personal account while an earlier query, +download or sensor replay is still running. Checking only when a request or +socket first opens leaves the old request authorized for too long. + +The legacy REST and both tRPC adapters now recheck current owner/browser scope +after the handler returns. Their response wrapper checks again before and after +each awaited body read. RAW downloads, database backups, archives and optional +MCP responses use the same boundary. Database downloads also recheck the +persisted household backup restriction while streaming. + +```mermaid +flowchart LR + Request[Authenticated request] --> Work[Await query or file] + Work --> Check[Recheck current browser identity] + Check -->|valid| Chunk[Read one chunk] + Chunk --> CheckAgain[Recheck before delivery] + CheckAgain -->|valid| Send[Deliver] + Check -->|changed| Cancel[Cancel source] + CheckAgain -->|changed| Cancel +``` + +Before response headers are sent, an access change returns an authentication +error. During an already-started stream, it ends the stream with an error; +consumers must treat a truncated download as incomplete. Cancellation kills an +archive producer and removes its temporary staging directory. A later exit from +that canceled producer cannot release a newer download's concurrency slot. Responses remain +private/no-store and vary by credentials. + +The sensor WebSocket retains its original request identity and checks it on +each inbound message, outbound live frame, snapshot, replay and protocol reply. +An invalid identity closes the connection with policy code 1008. A 30-second +check remains for idle sockets. An asynchronous RAW seek rechecks while reading +and replaying, and closes its descriptor if access is lost. Server-side sensor +collection and local routines continue independently of browser connections. + +## Scope and limits + +- Already-delivered bytes cannot be recalled. The client must still clear its + private views and caches when switching accounts. +- Withholding a response does not undo a command or database write already + performed. Personal command/publication receipts remain necessary to reconcile + unknown results; clients must not blindly replay a write after a 401. +- This change does not turn the legacy owner API into person-scoped access. + A still-valid owner credential retains its existing scope. The persisted + household privacy gate, old-client upgrade behavior, person-bound Health + ingestion and shared telemetry consent remain separate activation requirements. +- No household flags, Pod services, device settings or home automations are + enabled by this code. Physical acceptance and release are separate steps. + +Regression coverage uses real HTTP response streams, real WebSocket connections, +persisted browser-scope rows, key rotation during awaited work, canceled archive +producers, continued valid streaming and existing replay/backpressure behavior. diff --git a/docs/ACTIVE_CORE_UPDATES.md b/docs/ACTIVE_CORE_UPDATES.md new file mode 100644 index 000000000..dd4b3abac --- /dev/null +++ b/docs/ACTIVE_CORE_UPDATES.md @@ -0,0 +1,74 @@ +# Updating an active isolated Core + +`scripts/deployment/upgrade.py` updates an already activated Core installation. +It does not activate the retained Free Sleep controller or change boot selection. +Use the initial migration/handoff procedure for a first installation instead. + +Use the complete `scripts/deployment/` module set from the reviewed commit, +including `maintenance_status.py` and `recovery_privacy.py`; do not mix helper +versions. Current tools hold legacy update/restore when personal household +records exist, including unassigned drafts. See the [personal recovery +boundary](PERSONAL_RECOVERY_BOUNDARY.md). The hold preserves data; it is not yet +a supported personal-account recovery workflow. + +Before applying, verify the complete offline release manifest, run the matching +software checks, and confirm the bed is empty, connected and filled with an +attendant nearby. The tool requires idle thermal telemetry before switching and +for a continuous 30-second readiness window afterwards. It does not send an +actuator command to make the bed idle. Saved routines can resume at startup. + +Render a new controller plan and maintenance plan with `service_plan.py` and +`maintenance_plan.py`. Only the release path and an explicitly verified sensor +source may change. Data, credentials, DAC socket and maintenance socket must +stay identical. Other changes require a separately reviewed migration. Keep all +old release files. + +```text +active Core → stop maintenance and all Core writers → private data backup + → byte/owner/mode verification → restore rehearsal + → replace reviewed units → start candidate → verify freshness + → compare saved routines/history → retain verified release + failure ↓ + restore previous data and Core units +``` + +The operation directory must be new and beneath a private root-owned directory. +The command runs as root on the Pod. Pass actual reviewed paths: + +```sh +python3 upgrade.py apply --operation /persistent/sleepypod-unified/updates/new-update \ + --old-plan /persistent/sleepypod-unified/service-plan-OLD \ + --new-plan /persistent/sleepypod-unified/service-plan-NEW \ + --old-maintenance /persistent/sleepypod-unified/maintenance-plan-OLD \ + --new-maintenance /persistent/sleepypod-unified/maintenance-plan-NEW +``` + +`upgrade.json` is a durable private phase record. It contains hashes and paths, +never the owner connection key. Backups contain private health and pairing data; +keep them private and make an off-Pod copy. Do not commit or publish them. + +After a failed update, automatic rollback restores the previous Core and its +quiesced data only when the recovery guards still pass. Personal records created +during the update or unreadable household state prevent that replacement and +require recovery without starting the older runtime. A separate `replaced-data-*` directory retains the failed +candidate's data for inspection. Unrecognized units, a corrupt backup, failed +restoration or an interrupted restoration leave `recovery_required` instead of +silently overwriting unknown state. Inspect that state before retrying. + +For an attended rollback after successful activation: + +```sh +python3 upgrade.py rollback --operation /persistent/sleepypod-unified/updates/new-update +``` + +This restores data to the update's snapshot; newer data remains in +`replaced-data-*`. It requires an idle readiness window and is not an automatic +background downgrade. An interrupted restore may need manual path recovery. + +New activation records explicitly identify the upgrade journal and do not claim +a new Free Sleep migration trial. Old records become `superseded_by_upgrade`. +Physical temperature, vibration and base behavior remain separately unverified. + +Validation: `python3 -m unittest discover -s scripts/deployment -p 'test_*.py'` +covers preservation, failure after migration, changed routines, corrupt backups, +foreign units, data-path changes, backup failures and previous-Core restoration. diff --git a/docs/ALARM_SNOOZE.md b/docs/ALARM_SNOOZE.md new file mode 100644 index 000000000..ec29eaa8c --- /dev/null +++ b/docs/ALARM_SNOOZE.md @@ -0,0 +1,112 @@ +# Alarm snooze settings + +Reviewed integration of upstream `5cb54f2304dd5de7ff1e732bb077d920619b0bea`, +adapted for the stable controller's tap actions and imported bedtime ownership. + +Each scheduled alarm now stores `snoozeDuration` (integer seconds, 60–1800, +default 300) and `snoozeTaper` (0, 30, 40, 50 or 60 percent, default 0). +The web alarm and complete-bedtime editors read and save both fields. Grouping +and copying alarms includes them, so alarms with different snooze settings +remain distinct. + +Tap snooze and HomeKit use the ringing alarm's configuration. The first snooze +uses its full duration; each subsequent snooze is shorter by the chosen +percentage, rounded to seconds with a five-second floor. For example, a +ten-minute alarm at 50% uses 600, 300 and 150 seconds. The repeat count belongs +to that alarm occurrence and resets for the next scheduled alarm. An alarm +without a saved duration uses five minutes for taps or nine minutes for HomeKit. +The legacy explicit-duration snooze API keeps its existing input contract. + +```mermaid +flowchart LR + Saved[Alarm settings] --> Ring[Scheduled alarm occurrence] + Ring --> Snooze[Tap or HomeKit snooze] + Snooze --> Persist[Persist configuration and repeat count] + Persist --> Queue[Wait for deadline and side lock] + Queue --> Check[Check current snooze identity and lateness] + Check --> Repeat[Restart same alarm] + Repeat --> Snooze +``` + +## Client compatibility + +Discover `features.alarmSnooze.implemented` through authenticated +`GET /api/v1/capabilities`. Do not infer support from an empty list of alarms. +This is software support; vibration routing, pattern, intensity, duration and +cancellation on the physical Pod 4 still require attended qualification. + +- Calendar alarm create inputs default omitted fields; partial updates retain + existing values. A client replacing an alarm through delete/create must send + both fields. This legacy API has no whole-operation revision protection. +- `/api/v1/routines` includes both fields inside `draft.alarm`, when present. + Clients must preserve them when editing, copying or moving a complete night. + Default-only older clients remain compatible. A replacement or move which + omits either field while touching a custom snooze returns HTTP 412 without + changing any rows. Reload with a compatible client. Explicit removal remains + possible. +- Complete routines retain revision checks, transaction boundaries and parent + enablement. Changing snooze preferences does not enable a paused parent. +- Discover `features.curveLibrary` separately. The [calendar curve library](SCHEDULE_LIBRARY.md) + preserves alarms and refuses to rewrite an imported bedtime side. Snooze + support alone does not imply library support on an older controller. + +## Upgrade and import + +Generated migrations 0033/0034 add the fields and copy each side's first +configured snoozing **alarm** tap by tap count. Temperature taps with dormant +alarm fields are ignored. Values are bounded to 60–1800 seconds; no taper is +enabled automatically. The same migration updates bedtime JSON from its owned +alarm row, retaining ownership IDs and all other configuration. + +The offline Free Sleep importer also maps the settings explicitly because it +inserts data after applying schema migrations. If a side had different snooze +lengths for different tap counts, its review report identifies the change to one +per-alarm duration. Review that choice before cutover. Back up the database +before upgrading; older servers cannot safely own the new schema. + +## Runtime and evidence boundaries + +Scheduled power-off uses the same alarm window as temperature arbitration: +warm-up begins 30 minutes before the alarm (or earlier for a longer wake +window), and the hold ends 15 minutes after its configured vibration duration. +For a 07:00 alarm lasting 30 seconds, a scheduled 07:00 or 07:05 power-off is +deferred until 07:15:30. This remains true after the recurring alarm callback +has advanced its next invocation to the following week. Disabled or cancelled +alarm jobs cannot create a new hold. Explicit power-on still invalidates a +previously deferred shutdown. + +Real `node-schedule` callbacks with fake time, migrated in-memory SQLite and +mocked hardware cover both same-time registration orders, the exact hold +deadline, a later power-off, and disabled/cancelled alarm jobs. These are +software regression checks, not physical vibration or temperature evidence. +The warm-up and hold differ from Free Sleep's alarm behavior and still require +owner review before migration. + +Pending snoozes retain their configuration and count through the existing +atomic alarm-state file. Persistence remains best-effort: disk-write failure is +logged, so this is not a power-loss durability claim. A restarted controller +restores a snooze at most ten minutes late; an older one is dropped. Snooze +timers leave an immediate request's expiry context because they are deliberate +future schedules, not delayed receipt replays. + +All restart writes use the shared side lock. After waiting, the callback checks +that its pending snooze is still current and within the late grace period. +Cancellation or replacement while waiting invalidates it. Once a hardware +write has begun, cancelling a timer cannot retract that write; use the alarm +clear control and verify reported state. HomeKit and explicit API snooze writes +also share the side lock. + +Behavioral tests cover migration of existing owned routines, offline imports, +old-client rejection without writes, grouping/copying, tapered gestures and +HomeKit, state-file reload, queue cancellation/replacement, late timers and +request-context isolation. Production browser checks use isolated SQLite +fixtures and keep the parent routine paused. They do not actuate a bed or count +as physical qualification or Preview nights. + +On 2026-10-25, an offline upgrade of a new private SQLite backup of the previous +bedtime rehearsal passed integrity and real TypeScript ownership validation for +all 14 routines. The upgrade preserved every non-snooze configuration field and +row ownership ID, including one enabled power routine and zero enabled alarms. +Both source database hashes remained unchanged. The private verification record +is `~/Downloads/SleepyPod-Snooze-Rehearsal-2026-10-25/snooze-verification.json`. +No controller was started and no hardware commands were sent. diff --git a/docs/ARRANGEMENT_REVIEW_API_V1.md b/docs/ARRANGEMENT_REVIEW_API_V1.md new file mode 100644 index 000000000..5ce67e566 --- /dev/null +++ b/docs/ARRANGEMENT_REVIEW_API_V1.md @@ -0,0 +1,101 @@ +# Shared side review v1 + +This is a private proposal and independent approvals, **not activation**. The +current bed arrangement, schedules, controls and history attribution remain +unchanged after either or both approvals. No method in this service writes to +hardware or publishes a routine. `activateArrangement` remains false. + +The separate `SLEEPYPOD_ARRANGEMENT_REVIEW=1` flag enables preparation and approval +only inside the authenticated household feature. It defaults off. No live Pod +flags or settings are changed by this implementation. + +## Contract and clients + +The authoritative schemas are `src/household/arrangementContract.ts`. Portable, +synthetic examples are in `src/household/fixtures/arrangement-review-v1.json`. +All requests use the existing personal device credential or personal cookie, +same-origin protection and pinned `X-Sleepypod-Sleeper-Id`. Owner connection keys +cannot approve for either person. Responses are private and no-store. + +| Method | Path under `/api/v1` | Purpose | +| --- | --- | --- | +| GET | `household/arrangement` | `{ version: 1, review: null \| ownProjection }` for the latest review | +| GET | `household/arrangement/` | Current own projection of this household's review | +| POST | `household/arrangement` | Owner proposes `ownerSide`, explicitly preserving the operating week | +| POST | `household/arrangement/approve` | Each person acknowledges their own side, routine and separate activation | +| POST | `household/arrangement/cancel` | Either participant cancels the review | + +Mutations include a UUID `requestId`, `version: 1`, +`expectedSessionRevision` and `expectedAssignmentRevision`. Approval additionally +includes `reviewId`, `issuedAt`, `expiresAt` (at most 60 seconds), +`confirmMySide: true`, `confirmMyRoutine: true`, and +`acknowledgeSeparateActivation: true`. Proposal includes `ownerSide` and +`confirmPreserveOperatingOwnerWeek: true`; cancellation includes `reviewId`. +There is no activation endpoint and no replay queue. + +The projection contains only the authenticated person's preferences and full +week. For the partner it includes only display name, proposed side and approval +status. The review identifier is random, not a hash of private partner data. +`assignmentAtReview` describes the original solo/unassigned state, not a promise +about the current assignment. `canActivate` is always false and +`effect` is `review_only_bed_unchanged`. + +The web workspace renders a proposed bed, the caller's complete seven-night +summary, three independent acknowledgments, refresh and cancellation. An +uncertain reply disables further review mutations until a GET reconciles the +current state. Reload also requires a GET. Private review bodies are not kept in +browser storage. Pending review actions do not block Stop controls. + +## What is preserved and checked + +Preparation requires an existing solo/follow household with exactly two active, +unassigned accounts: one owner and one member. Both must explicitly save their +preferences; the member must save a private routine draft. Revision-one default +preferences and an untouched empty draft cannot become an approved plan. + +The owner's **actual operating source-side week** is copied, including disabled +nights, notes, every temperature phase, vibration parameters and custom snooze. +An unrelated unpublished owner draft does not replace it. The member contributes +only their own saved draft, which may explicitly contain no routines. Existing +unmanaged calendar schedules require review before proposing a side change. + +A private snapshot binds both people, session/membership/assignment revisions, +the configuration and time zone, both existing operating weeks, personal +preferences and draft revisions. It is rechecked on reads and approvals. +Settings changes invalidate approval even if the visible target side is unchanged. +Approvals bind the actual authenticated device; revoking an approving device +invalidates the review. Transactions are immediate and durably committed with +SQLite `synchronous=FULL` before success is returned. + +Reviews last one hour. A clock earlier than creation or at/after expiry cannot +approve. Exact proposal/approval retries return the current review; reuse with +different content conflicts. A fresh GET, not a retry, is the recommended +client recovery. Cancellation never revives a consumed review. No history or +physical assignment is inferred from a person's body position. + +```mermaid +flowchart LR + Solo[Current solo bed] --> Review[Private side proposal] + Owner[Owner reviews own operating week] --> Review + Member[Member reviews own private draft] --> Review + Review --> Approvals[Two independent approvals] + Approvals --> Pending[Separate activation still required] + Change[Settings change or device revoked] --> Stale[Fresh review required] + Review --> Stale +``` + +## Acceptance and remaining work + +SQLite-backed service and authenticated HTTP tests cover source preservation, +privacy, revisions, expiry, revocation, idempotence, transaction rollback and +unchanged operating rows. Browser workspace tests cover narrow routing, +explicit acknowledgments, lost-response GET recovery, account disposal and +malformed projections. Browser UI scenarios use synthetic intercepted responses; +they do not establish hardware activation or independent physical alarms. + +The activation coordinator still must quiesce **all** command sources, atomically +move assignments/routines/dated attribution, reconcile snooze and temperature +leases, and expose durable recovery. A privacy-compatible backup/rollback path, +person-scoped Health ingestion, native review interface and cross-client physical +qualification remain separate release gates. Do not enable shared operation +merely because this review has two approvals. diff --git a/docs/ATTENDED_OFFLINE_TRIAL.md b/docs/ATTENDED_OFFLINE_TRIAL.md new file mode 100644 index 000000000..0be450ccd --- /dev/null +++ b/docs/ATTENDED_OFFLINE_TRIAL.md @@ -0,0 +1,113 @@ +# Attended offline routine and wake check + +This procedure tests the existing local schedule while the Pod cannot reach the +internet, phone, MQTT broker or home server. The controller and sensors keep +running. It does not change routines, calibration, temperature policy or the +active release. A directly connected Mac retains authenticated web and SSH access +for observations and cancellation. This is a network-outage test, not a power-loss +test or a claim that the recovery Mac was also unavailable. + +```mermaid +flowchart LR + Owner[Owner present and ready] --> Preflight[Read-only preflight] + Preflight --> Timer[Arm independent recovery timer] + Timer --> Outage[Isolate IPv4 and IPv6 for 2 to 5 minutes] + Outage --> Local[Observe existing scheduled action] + Outage --> Recovery[Automatic or early recovery] + Mac[Mac LAN recovery connection] --> Outage + Local --> Evidence[Match acknowledgments with physical observations] + Recovery --> Evidence +``` + +## Preparation + +1. Choose the existing scheduled temperature and alarm occurrences. Do not create + a temporary routine or use a manual action as proof that scheduling worked. + The two observations may use separate short outages within 24 hours. Preserve + the current qualified build, calibration and enabled routine identity. +2. Confirm the owner will attend, the cover is attached, the reservoir is filled, + no priming/base motion is running and the normal Stop control is available. + Obtain fresh readiness before each outage. If unavailable, skip the trial and + let the ordinary routine run. Never arm an unattended future isolation job. +3. Copy the reviewed standalone `scripts/deployment/network_trial.py` into a new + root-owned tool directory outside the active release; make it and its parents + non-writable by other users. Keep its content unchanged while a timer exists. + This helper uses only Python's standard library and existing Pod binaries. +4. From the Mac's direct LAN SSH connection on port 8822, run: + + ```sh + python3 -B /absolute/pinned/path/network_trial.py preflight --control-address MAC_LAN_IP + python3 -B /absolute/pinned/path/network_trial.py rehearse-recovery --control-address MAC_LAN_IP + ``` + + The first command changes nothing. The second exercises a ten-second systemd + timer and its real recovery process, without adding isolation rules. Verify + `/run/sleepypod-network-trial/receipt.json` reaches `restored` and both firewall + rule sets are unchanged. Save an off-host copy of the receipt. + +## At the attended occurrence + +Use an independent clock and begin roughly 30 seconds before the scheduled action: + +```sh +python3 -B /absolute/pinned/path/network_trial.py apply --control-address MAC_LAN_IP --seconds 180 --attended +``` + +The helper requires the supplied address to match the current SSH peer, verifies +that its route is direct and that Core is active, then persists intent and arms +recovery **before** installing rules. It refuses missing IPv6 support, existing +trial chains, an unfinished receipt and durations outside 120–300 seconds. +It allows loopback, the Mac's existing SSH/web ports and IPv4 DHCP. Other IPv4 and +IPv6 traffic is blocked, including already-established remote sessions. It never +stops Wi-Fi, changes permanent firewall configuration, sends an actuator command +or restarts the Pod. + +- Independently verify the Mac's authenticated status/Stop access remains usable. + Verify the phone/home server cannot reach the Pod and inspect packet counters; + a briefly cached MQTT `connected` label does not prove continued connectivity. +- Record the isolation timestamps, local scheduled acknowledgment and owner-felt + temperature/vibration result separately. A requested 105°F wake target does not + establish that the cover actually reached 105°F. +- Test deliberate cancellation while vibration is still active, using the normal + Mac web control or the confirmed local gesture. Agree on the cancellation timing + with the owner before starting. Do not count a naturally expired alarm as a + cancellation or send an extra test vibration without an attended plan. +- Restore immediately if anything unexpected happens. The receipt contains the + exact trial ID for: + + ```sh + python3 -B /absolute/pinned/path/network_trial.py recover --trial-id TRIAL_ID + ``` + +The independent monotonic timer attempts recovery after the chosen duration even +if SSH disconnects. A failed recovery service retries every three seconds. Recovery +removes only this trial's tagged jumps and unchanged private chains; it does not +replace unrelated firewall rules. A foreign rule inside a trial chain is detached +but preserved for inspection. If recovery reports an error, retain the receipt, +inspect the named systemd service and reconcile before starting another trial. +A reboot also discards these volatile rules, but do not restart a thermal session +as the ordinary recovery method. + +## Afterward + +Confirm network/MQTT recovery, unchanged controller identity and saved routines, +and no unexpected actuator activity. Save the receipt, packet observations, +temperature/alarm journal references and owner's actual response off-host before +starting another trial (the next trial replaces the current receipt). + +Only then use the owner-review workflow to confirm `offline_routine`. Its server +evidence requires scheduled temperature and alarm acknowledgments within 24 hours; +the owner separately confirms the outage, physical response and cancellation. +Software/namespace tests and a successful timer rehearsal are not physical +qualification. A failed or unobserved action stays unqualified. Active comfort +still requires the separate seven reviewed, source-backed Preview nights. + +## Software verification + +Unit tests exercise timer-before-firewall ordering, partial IPv6 failures, +idempotent restoration, foreign-rule preservation, mismatched IDs, timer failure, +bounded durations and duplicate attempts. CI additionally creates disposable Linux +network namespaces and sends real IPv4/IPv6 TCP probes: the control peer remains +reachable, a second phone-like address and an outbound broker connection fail, +then all recover with unrelated firewall rules unchanged. The CI job never changes +the runner host's firewall. diff --git a/docs/AUTOMATION_NOTIFICATIONS.md b/docs/AUTOMATION_NOTIFICATIONS.md new file mode 100644 index 000000000..e134e59fc --- /dev/null +++ b/docs/AUTOMATION_NOTIFICATIONS.md @@ -0,0 +1,111 @@ +# Automation alert inbox + +Live `notify` actions now persist an alert in the configuration database before +returning a receipt to the automation engine. The web app polls the authenticated +inbox while open, shows unread counts, and announces newly observed live alerts. +Opening the inbox alone does not mark anything read. Preview/dry-run actions only +produce an Activity proposal; they never publish an alert. + +This is foreground delivery with durable history. It does **not** implement APNs, +Web Push, SMS, email, audio, vibration, or safety-critical alarms. A persisted +receipt is not evidence that a person saw an alert. Existing wake alarms remain +independent. Nothing in the notification store starts hardware or an engine. + +## Native and web contract + +All routes require the existing owner bearer token or same-origin HttpOnly browser +session. No credentials belong in URLs. REST and tRPC dispatchers enforce access +before calling the router; cross-origin cookie mutations are refused. + +| Operation | REST | tRPC | +| --- | --- | --- | +| Newest page | `GET /api/v1/notifications` | `notifications.list({})` | +| Older page | `GET /api/v1/notifications?beforeId=123` | `notifications.list({ beforeId: 123 })` | +| Mark selected alerts read | `POST /api/v1/notifications/acknowledge` with `{ "ids": [123] }` | `notifications.acknowledge({ ids: [123] })` | + +The read response contains: + +```ts +{ + apiVersion: 1, + generatedAt: number, // UTC Unix milliseconds, from the Pod + newestId: number | null, // newest retained visible ID across all pages + nextBeforeId: number | null, // null at the end; pass as beforeId + unreadCount: number, // all retained, unacknowledged, unexpired alerts + items: Array<{ + id: number, + source: 'automation', + sourceId: number, // original rule ID; rule may subsequently be deleted + title: string, // snapshot of rule name at publication + message: string, // plain text; never render as HTML + createdAt: number, + expiresAt: number, + acknowledgedAt: number | null + }>, + delivery: { foregroundInbox: true, backgroundPush: false } +} +``` + +Pages contain at most 50 entries in descending ID order. IDs are positive safe +integers. `generatedAt` defines the server's current expiry boundary; do not assume +the phone and Pod clocks agree. Future-dated rows are hidden. An unread live alert +has `acknowledgedAt === null` and `createdAt <= generatedAt < expiresAt`. + +Acknowledgment accepts 1–50 IDs, collapses duplicates, and returns +`{ generatedAt, acknowledgedIds, missingIds }`. Each row's first acknowledgment +timestamp is retained. If a response is lost, reread the inbox or safely repeat +the same IDs. The client must not claim a successful acknowledgment before a +confirmed response/read. Missing, no-longer-retained and future IDs are reported +in `missingIds`. Other, newly arriving alerts are never implicitly acknowledged. + +## Delivery, expiry and restart behavior + +- Alerts expire after 15 minutes. Expired unread alerts remain visible as history + but no longer contribute to the badge or generate a foreground toast. +- Identical rule ID, title and message within 60 seconds return the original + receipt without changing its expiry or acknowledgment, including after restart. +- History is limited to 30 days and 1,000 rows. Reads omit old rows; the next + publication prunes them physically. No external retention service is required. +- The publisher rechecks persisted global Autopilot consent, rule enablement, + dry-run mode and the current notification action. Storage failure records an + automation error rather than a successful notification receipt. +- The web app polls every five seconds in the foreground. No streaming event + contract is introduced. Initial connection/reload establishes a cursor without + replaying historical alerts. Later polls announce only new, unexpired, unread + items. A restored database/clock rollback cannot lower that session's high-water + ID; lower IDs remain readable in the inbox. Reopening creates a fresh baseline. +- On network failure, retained entries are labeled as an older successful check; + an error is never presented as an empty inbox. Query caches are discarded when + the owner-session component unmounts. The browser stores no alerts in local storage. +- Demo mode shows an empty, unavailable live inbox and cannot acknowledge real data. + +Native clients should use the same foreground polling and cursor rules, display +the capability flags honestly, and acknowledge only explicitly selected IDs. +Treat an older controller's missing endpoint as unavailable. Background iOS +delivery requires its own reviewed APNs/device-registration implementation. + +## Persistence and validation + +Generated migration `0038_same_charles_xavier` adds only `notification_inbox` and +its indexes. No existing routines, controls or biometrics are rewritten. Include +the configuration database in backups as usual; an older binary can ignore this +additive table. No external notification credentials or new public ports are used. + +The tests exercise a real migrated SQLite database, restart, duplicate suppression, +expiry, clock rollback, bounded retention, pagination, input rejection, failed +storage, idempotent acknowledgments and current persisted rule consent. Engine +tests distinguish queued receipts from dry-run and failed publication. Component +tests cover keyboard focus, literal message rendering, network errors and lost +acknowledgment replies. The production browser suite tests actual authenticated +REST/tRPC, foreground delivery and desktop/phone layouts with synthetic alerts. +These tests do not qualify live Pod notifications or native background delivery. + +```mermaid +flowchart LR + Rule[Live rule] --> Consent[Recheck persisted consent] + Consent --> DB[SQLite inbox + receipt] + DB --> API[Authenticated read / acknowledge] + API --> Web[Foreground web inbox] + API --> Native[Native contract] + Preview[Preview rule] --> Activity[Activity proposal only] +``` diff --git a/docs/AUTOMATION_SIGNAL_VALIDITY.md b/docs/AUTOMATION_SIGNAL_VALIDITY.md new file mode 100644 index 000000000..b3c3fff5a --- /dev/null +++ b/docs/AUTOMATION_SIGNAL_VALIDITY.md @@ -0,0 +1,52 @@ +# Automation signal validity + +Live automation decisions require finite, current inputs. Missing measurements +remain unavailable; they are not converted to zero or replaced with an older +row. These checks apply to the general rules engine, independently of the +personal comfort Preview's additional presence, water and artifact guards. + +| Source | Maximum observation age | Additional requirement | +| --- | --- | --- | +| DAC status | Under 15 seconds | Monitor running, connected, no intervening hardware write | +| Vitals and movement | 5 minutes | Latest stored timestamp must not be in the future | +| Bed/water temperature, humidity and light | 15 minutes | Latest stored timestamp must not be in the future | +| Capacitive reducers | 30 seconds | Both firmware sample time and local receipt time must be current | +| Classified presence | Shared occupancy implementation | Supported fresh capacitance and calibration; legacy Pod 4 data is supported | + +All numeric timestamps and values must be finite. Invalid fields do not hide +independent valid fields, but a bad channel invalidates that side's capacitive +reduction. A newly received old frame is still old. The latest malformed or +future-dated database row does not cause a fallback to an earlier measurement. + +A window expression also requires its source to be available at evaluation +time. Retained history cannot keep a rule firing after a sensor disappears. +Windows are sampled at evaluator ticks; `count` counts those samples, not +independent hardware observations. A window is not a validated vital estimator. +Arithmetic overflow, non-finite operands and unavailable or inverted expression +clamp bounds yield an unknown result rather than a temperature command. + +Live policy requests recheck their action and trigger inputs every evaluator +tick, including between the rule's trigger times. Missing inputs withdraw the +request and record `policy-input-unavailable`. Missing conditions already +withdraw policy ownership. The shared temperature controller then arbitrates +remaining requests and routine/manual ownership. An explicit one-shot retains +its resolved target until its existing bounded expiry; it is not re-evaluated +as a continuous sensor policy. Relative temperature actions continue to use +the routine baseline so they cannot compound their own previous output. + +```mermaid +flowchart LR + Source[Timestamped observation] --> Valid{Fresh and finite?} + Valid -->|yes| Evaluate[Current signal and window evaluation] + Valid -->|no| Missing[Unavailable input] + Missing --> Withdraw[Withdraw dependent live policy] + Evaluate --> Authority[Shared temperature authority] + Withdraw --> Authority +``` + +Software validation includes real in-memory SQLite queries, malformed and +future timestamps, source/receipt age, non-finite fields, window history after +sensor loss, policy withdrawal between triggers, recovery without replaying +notifications, and preservation/expiry of explicit one-shot holds. The initial +focused run passed 226 tests across 12 files. No physical sensor outage, Pod +migration or household actuator test is implied by these tests. diff --git a/docs/AUTOPILOT_MODEL_PLAN.md b/docs/AUTOPILOT_MODEL_PLAN.md new file mode 100644 index 000000000..a929b6e48 --- /dev/null +++ b/docs/AUTOPILOT_MODEL_PLAN.md @@ -0,0 +1,142 @@ +# Personal Autopilot: evidence and implementation plan + +Updated 2026-11-01. Software design and evaluation plan; no claim of improved +sleep outcomes or equivalence to Eight Sleep's proprietary Autopilot. + +## What the history can teach + +| Evidence | Useful learning task | What it cannot establish | +| --- | --- | --- | +| Source-separated historical Health sleep intervals | Habitual timing, variability, source coverage | Missing Pod temperatures, room conditions or an ideal temperature | +| Watch stages aligned with full Pod signals | Retrospective personal stage estimation | Clinical ground truth or live stage accuracy | +| Full thermal recordings plus morning comfort feedback | Context-dependent warm/cold direction | Which minute caused discomfort or a causal sleep benefit | +| Consecutive qualified thermal observations and applied targets | Future thermal-response forecasting | Body temperature from water temperature alone | + +Health samples, noon-to-noon days, bed sessions and complete training nights are +different counts. Existing aggregate Health history lacks a record of every gap; +a duration between six and twelve hours is not sufficient to certify it. Re-read +per-source intervals for any future historical training pipeline. Preserve awake +labels and naps separately, and never infer missing sleep or thermal readings. +User data and audit counts remain private, outside this repository. + +## Research that informs the design + +1. [Eight Sleep's current Autopilot description](https://www.eightsleep.com/autopilot/) + names preferences, workouts, room conditions and sleep-stage signals as inputs. + This is a vendor description, not an executable policy or published weight + file. Our Pod 4 must advertise only measurements it actually supplies. +2. [Kim et al., 2025: adaptive thermal regulation](https://pmc.ncbi.nlm.nih.gov/articles/PMC12524338/) + studied 25 adults under PSG with natural, constant and adaptive temperature + conditions. It supports studying thermal timing, but its mattress temperatures, + room conditions and staging system differ from this Pod. Its small cohort and + baseline ordering limit direct transfer; it does not validate our 70°F routine + or a particular automatic correction. +3. [Walch et al., 2019 dataset](https://physionet.org/content/sleep-accel/1.0.0/) + pairs wrist acceleration and heart rate with PSG in 31 participants. Use it to + reproduce a staging benchmark and understand evaluation, not to treat wrist + features as interchangeable with mattress ballistocardiography. +4. [Canton et al., 2026](https://pmc.ncbi.nlm.nih.gov/articles/PMC13283449/) + shows how dataset composition and easy-to-classify wake can inflate wearable + classification performance. Compare simple baselines and report wake + specificity, per-stage performance and per-night results, not accuracy alone. + +These sources motivate controlled experiments. They do not establish that a +large language model should operate a heater, or that a more complex predictor +will outperform a small one on sparse personal data. + +## Implemented model and data contract + +`comfort-ridge-v1` is a real fitted, local ridge regression with five coefficients: +intercept, target, water-minus-target, room temperature and humidity. Fixed scales +and regularization are specified before evaluation. It predicts a morning +feedback direction from nightly medians. The fit runs in TypeScript on the Pod; +no API key, remote provider or Python model runtime is needed. + +Only production Preview recordings qualify. They must have a stable hardware, +routine, calibration and room identity; fresh quiet occupied measurements; an +actual-night review bound to the exact source digest; and a morning comfort +check-in. Windows must cover 6–12 hours, at least 90% qualified thermal time, +at least six actual hours and 72 distinct five-minute buckets. Interior gaps +over ten minutes and edge gaps over 30 minutes fail. Wake warming, manual +pauses, mechanical disturbances, ambiguous sessions and targets spanning over +2°F are excluded. These are conservative engineering filters, not a definition +of normal sleep. Complete short nights currently abstain rather than being padded. + +At least 20 matching nights are required. The newest quarter, minimum five, +stays out of training. Compare directional mean absolute error against holding +temperature and the training-set mean feedback. Require supported contexts, +10% improvement and 0.05 absolute improvement over the better baseline. These +sample-size/error thresholds are engineering choices, not statistically powered +clinical acceptance criteria. Repeatedly tuning to the same holdout is forbidden; +a later model version must reserve fresh evaluation nights. + +The artifact includes provenance hashes, dates, coefficients, input support and +validation. It is invalidated by changed source/review data. Predictions abstain +outside supported conditions or when their magnitude does not exceed the held-out +90th percentile error. That empirical error is not a calibrated confidence +interval. Fitting and context preview never issue a hardware command. Applying +nightly-median training to a single live moment remains experimental and is +explicitly labelled in the UI. + +The native stage trainer separately requires 90% matched Pod presence, heart +rate, movement and Watch labels over 6–12 hour nights. Fragments and overlaps +cannot leak across validation folds. The owner selects actual full nights after +coverage inspection; changed signals require another review. Its whole-night +normalization uses future information and must stay retrospective. + +## Next model stages and acceptance + +1. **Collect complete, reviewed nights.** Keep the requested routine and wake + heating separate. Verify room publisher freshness and inspect exclusion reasons. + Missing data must become visible before another night is lost. +2. **Fit the local feedback model.** Preserve both unsuccessful and successful + validation outcomes. Train explicitly; never fill missing examples with an + LLM or manufacture warm/cold labels from HR, stages or sleep duration. +3. **Learn the thermal response.** The implemented + [water-response forecast](WATER_RESPONSE_FORECAST.md) uses current water, + target, room and preceding-only windows. It splits complete nights into fit, + calibration and evaluation groups and compares 15/30-minute forecasts with + persistence and a simple target-response baseline. It abstains on failed + comparisons or unsupported conditions. This conditional forecast under steady + targets does not establish the causal effect of changing a target. Its software + implementation is separate from fitting and validation on real personal nights. +4. **Combine only validated components.** A bounded optimizer may propose a + target within the owner's range using forecast response and predicted comfort. + The single temperature authority retains freshness, water, occupancy, alarm, + manual-pause and maximum-rate checks. A morning directional label alone must + not become a minute-by-minute heat command. +5. **Prospective Preview and owner review.** Run at least seven qualified nights; + log inputs, model hash, proposed target and abstention reason. Later unseen + nights must beat the baseline. Preview, mechanical qualification and model + evaluation are independent gates; none substitutes for the others. +6. **Bounded attended activation, then a personal experiment.** Begin with at most + 1°F per 30 minutes inside the owner's narrow range, keeping the wake routine + intact. Prespecify outcomes (morning comfort, awakenings, unwanted corrections) + and compare stable baseline/model periods. Do not claim improved sleep from + training accuracy or a single pleasant night. Expand only after review. + +Health activity/workout and wrist-temperature context are later optional inputs +with source, timestamp and permission provenance. Historical wrist temperature +is not a real-time body temperature feed. Cloud/local LLMs remain report writers +and experiment assistants; deterministic code owns actuator commands. + +```mermaid +flowchart LR + History[Source-separated Health history] --> Timing[Retrospective timing and stages] + Pod[Qualified Pod and room readings] --> Audit[Complete-night audit] + Review[Actual-night review and feedback] --> Audit + Audit --> Fit[Local ridge fit] + Fit --> Holdout[Later unseen nights and baselines] + Holdout --> Preview[Research Preview and abstention] + Preview -. future validated controller .-> Authority[Single bounded temperature authority] + Routine[Existing routine and wake alarm] --> Authority +``` + +## Verification boundaries + +Unit/browser/simulator tests establish filtering, persistence, authentication and +presentation. They do not establish live Pod adoption, complete-night collection, +model improvement, physical motion/vibration or sleep benefit. Keep deployment, +TestFlight installation, attended hardware checks and overnight observations in +separate release evidence. Existing model/library counts must identify whether +they describe a stage model or a thermal comfort model. diff --git a/docs/AUTOPILOT_RESEARCH_2026_10.md b/docs/AUTOPILOT_RESEARCH_2026_10.md new file mode 100644 index 000000000..d561c86fd --- /dev/null +++ b/docs/AUTOPILOT_RESEARCH_2026_10.md @@ -0,0 +1,120 @@ +# Personal temperature learning: October 2026 review + +## What Eight Sleep documents + +Eight Sleep describes a preferred temperature schedule with bedtime, night, +dawn and optional thermal-alarm phases. It layers small adjustments on that +schedule using preferences, feedback and sleep/health observations. Its current +Autopilot 4.0 product page also describes workout context, room-temperature +response, sleep-stage response and learning across nights. These are vendor +descriptions; the source code, trained parameters and exact control policy are +not published in the cited pages. They are not validation of this fork. + +Sources, checked 2026-10-30: + +- [Temperature schedule](https://help.eightsleep.com/en_us/what-is-the-temperature-schedule-BkRoPvR66) +- [Autopilot](https://www.eightsleep.com/autopilot/) +- [Separate ambient and sleep-stage response controls](https://help.eightsleep.com/en_us/how-to-turn-autopilot-on-off-B19vVtePp) + +## Separate measurements, estimates and decisions + +| Input | Meaning here | Appropriate use | +| --- | --- | --- | +| Pod target | Commanded water-loop target | Compare the intended routine with acknowledgments | +| Pod reported water temperature | Hardware thermal measurement | Response/lag and comfort context; never call it body temperature | +| Cover surface temperature | Separate sensor, only when available and qualified | Surface trend; not core body temperature | +| Room temperature/humidity | Fresh, attributed bedroom sensors | Bounded ambient response and matched-night context | +| Pod HR, respiration and movement | Estimates from mechanical signals | Quality-gated comparisons; movement alone is not overheating or a sleep stage | +| Apple Watch stages/HR | Separately imported reference observations | Time-aligned validation with explicit coverage gaps | +| Watch sleeping wrist temperature | A nightly aggregate of wrist measurements | Prior-night trend with consent; not live body temperature feedback | +| Workouts/daytime activity | Person-bound Health records or explicit check-ins | Candidate next-night context after source, date and consent validation | + +Apple documents one aggregated sleeping-wrist-temperature sample for the night. +HealthKit can expose it before the Health app has established its approximately +five-night display baseline. It is not an on-demand core-temperature feed. +[HealthKit definition](https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/applesleepingwristtemperature) + +## What upstream models do + +SleepyPod's personal-stage workspace trains logistic regression and random +forest candidates from Pod features labeled with same-night Apple Watch stages. +Its documented comparison uses held-out nights and keeps candidates separate +from the live classifier. This predicts sleep stages; it does not establish +which temperature change benefits a particular sleeper. Historical sleep totals +without contemporaneous Pod/room measurements are not temperature-response +training examples. No cited upstream artifact supplies Eight Sleep's proprietary +Autopilot weights. + +[Personal stage models](https://sleepypod.github.io/core/personal-stage-models/) +and [training workspace](https://sleepypod.github.io/developers/training/). + +## Implementation in this change + +```mermaid +flowchart LR + Sources[Timestamped room and Pod measurements] --> Research[Thermal research admission] + Feedback[Morning comfort check-in] --> Dataset[Comparable complete-night dataset] + Research --> Dataset + Dataset --> Model[Chronological held-out model comparison] + Model --> Suggestion[Reviewable one-degree suggestion] + Qualified[Qualified sensors and owner-reviewed nights] --> Gate[Existing activation gate] + Gate --> Authority[Single bounded command authority] +``` + +Previously thermal examples were saved only when a temperature proposal was +eligible. A missing personal HR baseline could therefore prevent collecting +otherwise valid thermal examples. The new **research-only** receipt permits +collection during Preview when the side is powered, occupied, attributed and +mechanically quiet, water is adequate, the reported target is known, room data +is fresh and no manual pause, away state or alarm window is active. This does +not make the proposal eligible and cannot satisfy the reviewed-night gate. + +In solo mode, logical presence on either half still identifies the sole sleeper, +but a thermal training observation additionally needs fresh calibrated occupancy +on the physical half supplying its water measurement. The empty half's water is +not substituted for the sleeper's exposure, including when they cross the middle. + +Training requires observations spanning at least four hours, at least 48 +distinct five-minute bins, and no gap over ten minutes. A change of calibration +attribution, schedule, hardware, build or bedroom configuration invalidates that +night's combined label. Missing data is not filled with zero. Large target curves +are excluded because one morning comfort rating cannot label every phase. + +The existing contextual model remains advisory: at least 20 distinct matched +nights, later nights held out, comparison against hold-temperature and average +feedback baselines, no extrapolation beyond supported contexts, and at most a +one-degree suggestion. These engineering thresholds are not medical findings. + +Sensor failures now name the cause: missing records, low signal, low quality, +invalid provenance, unsupported estimates, stale data, missing quiet evidence, +incomplete summaries or storage failure. This replaces an ambiguous generic +"waiting" message without weakening the acceptance rules. + +## Next model experiment and acceptance + +1. Preserve a simple preferred overnight baseline and separate explicit wake + warming. Retain one-degree steps, 30-minute spacing, a three-degree total + offset budget, user limits, manual pause and Stop priority. +2. Replay raw recordings against matched Watch intervals, including quiet + sleepers, movement, pump transitions and known empty-bed intervals. The + current piezo `low_signal` flag uses the presence-entry amplitude threshold; + a Pod-specific quality model needs noise-floor and held-out evidence before + replacing that rule. Do not infer absence from a weak piezo signal alone. +3. Add opt-in, person-bound daily context: workout timing/duration/intensity, + daytime activity relative to that person's baseline and prior-night wrist + temperature trend. Do not persist another sleeper's Health data under a side + assignment or upload it to an LLM merely because a key is configured. +4. Fit interpretable regularized and neighbor-based candidates with all records + from one night in the same split. Report per-night error, supported-condition + coverage and uncertainty. Keep the simpler policy unless improvement holds + on later nights. Observational association is not proof of causal benefit. +5. Evaluate phase-aware suggestions only after sleep-stage reliability is + established. Never respond to elevated HR alone by treating it as excess heat. +6. Use the selected LLM for readable, source-grounded reports and experiment + proposals. Deterministic controller code remains responsible for actuation. + A more capable text model cannot repair missing or mislabeled sensor data. + +The daily-context ingestion and phase-aware model are **not implemented by this +change**. It does not replace the running Pod, enable adaptive control, widen an +owner's temperature limits or alter any alarm. Deployment, physical checks and +reviewed Preview evidence remain separate acceptance steps. diff --git a/docs/BEDROOM_CONTEXT.md b/docs/BEDROOM_CONTEXT.md new file mode 100644 index 000000000..9a1be8c97 --- /dev/null +++ b/docs/BEDROOM_CONTEXT.md @@ -0,0 +1,80 @@ +# Bedroom context + +The Pod remains the sole temperature controller. External devices provide observations to the existing automation reader and personal-comfort policy; they never issue temperature commands through this feature. + +```mermaid +flowchart LR + HA[Home Assistant sensors] --> MQTT[Dedicated bedroom report topic] + Apple[Apple Home sensor exporter] --> Publisher[Qualified publisher] + Bridge[Homebridge accessory reports] --> Publisher + Publisher --> MQTT + Publisher --> REST[Authenticated report API] + MQTT --> Store[Versioned per-device observations] + REST --> Store + Store --> Agreement[Freshness and source agreement] + Agreement --> Rules[Existing automation rules] + Agreement --> Comfort[Existing personal comfort policy] + Comfort --> Authority[Shared temperature arbitration] + Store --> Clients[Web / native / bedside client contract] +``` + +## Devices and measurement provenance + +Each physical device can have multiple sources, such as temperature and humidity. A physical-device/metric pair is mapped once, even if several integrations expose it. Devices have a name, model, room/left/right position, integration, external identifier, expected unit, maximum age and an explicit `useForControl` selection. New selections default to display-only. A purifier or diffuser is not assumed to contain a humidity or particulate sensor. + +Supported measurements are temperature, relative humidity, PM2.5 concentration, CO₂, HomeKit air-quality category, power, fan speed and filter life. HomeKit air-quality categories (1 excellent through 5 poor) remain distinct from PM2.5 and from national AQI scales. Category zero is unknown and is rejected as a measurement. + +Publishers declare the clock they have: + +| `timeBasis` | Meaning | Time used for expiry | +| --- | --- | --- | +| `measurement` | The device/integration supplies the actual measurement timestamp | `measuredAt` | +| `device_read` | A successful device read, without a hardware measurement clock | `observedAt` | +| `integration_report` | A newly received, qualified integration report | `observedAt` | +| `cached` | A stored value whose physical freshness is unknown | Display-only; never used for control | + +An HTTP success, a non-null cached HomeKit characteristic, or a Home Assistant entity's poll time does not establish a new physical measurement. Adapters must qualify their actual source path. Do not relabel a cached Homebridge value as `device_read`. A failed read must publish unavailable, not the previous characteristic value. The UI labels measurement and report clocks separately. + +## API and MQTT + +All REST/tRPC endpoints retain owner authentication and private, non-cacheable responses: + +- `GET /api/v1/home/bedroom`: configuration, observations, qualified aggregates, room guidance and MQTT connection status. +- `PUT /api/v1/home/bedroom/configuration`: `{ expectedRevision, config }`. Stale writes return 409. +- `POST /api/v1/home/bedroom/reports`: `{ reports: [...] }`, at most 40. Results identify accepted/rejected sources independently. +- `GET /api/v1/home/bedroom/history`: cursor-based source observations, with the source configuration and original units retained. + +A report contains `schemaVersion: 1`, the current configuration `revision`, `sourceId`, `available`, `value`, `observedAt`, nullable `measuredAt`, and `timeBasis`. Timestamps are Unix milliseconds. Configuration changes clear current readings and reject publishers still using the previous revision. Durable watermarks reject replay across process restarts; saved observations are not considered live after restart. + +The existing MQTT connection also subscribes to `//bedroom/report`, carrying one report per message. Use non-retained QoS 0 messages. The broker account for a sensor publisher should only be allowed to publish this topic, with no access to actuator command topics. MQTT actuator commands need not be enabled. Broker disconnect immediately withdraws MQTT observations; independently received HTTP observations keep their own expiry. Reconnection requires new reports and does not resurrect retained or duplicate packets. + +Unavailable transitions withdraw a reading immediately. Reports more than 30 seconds old or from the future are rejected. Per-source rate limiting is five seconds. Measurement leases range from 30 to 900 seconds. Raw history is bounded to 30 days and 250,000 rows; this is a retention limit, not a promise of 30 days at every publishing frequency. + +## Room-informed comfort and routines + +Fresh, selected physical sensors contribute once each. Climate uses the median. A temperature spread above 4°F or humidity spread above 15 percentage points withholds that aggregate. Selected air-quality sources retain the worst reported concentration/category, without converting between unlike measurements. Power, fan and filter observations remain attached to their device rather than becoming climate signals. + +The opt-in `roomAware` personal-comfort setting requires both qualified temperature and humidity. Initial engineering thresholds suggest: + +- −1°F when the room exceeds 75°F, or is at least 72°F with humidity above 65%. +- +1°F when the room is below 64°F. +- No additional offset otherwise. + +These are bounded comfort heuristics for Preview, not validated sleep-outcome claims or a medical policy. PM2.5, CO₂ and appliance power are not substituted for thermal readings. Their observations remain available for appropriate room rules and later environmental analysis. + +The room contribution shares the learner's total ±3°F offset budget. The existing one-degree step, 30-minute correction interval, limits, six-hour manual pause, calibrated occupancy, water, priming, movement and vibration checks remain authoritative. Missing/disagreeing/expired room inputs withdraw room-dependent comfort, including during cooldown. Dispatch rechecks the data after queue waits, and the application lease cannot outlive the room observation. + +Saved temperature curves, bedtime, power scheduling and wake alarms are not rewritten. The final 30 minutes before a wake alarm remain reserved for scheduled warming. The temperature and schedule screens show current bedroom context alongside that baseline. Personal comfort uses the existing temperature arbiter; no second controller is added. + +Preview evidence records room configuration identity and guidance. A change in source configuration requires matching new room-aware evidence; nights collected without room context cannot approve room-aware Active mode. Existing physical qualification and seven-night review requirements remain in force. + +## Integration qualification and remaining deployment work + +This change supplies the multi-device API, MQTT receiver, web displays, policy and evidence handling. It does **not** claim that an external device is already publishing, that native/ESP32 clients have been updated, or that physical temperature effects have passed qualification. Those require device discovery, actual source adapters, matching client releases and deployed verification. + +- HomePod sensors are visible in Apple Home, but public HomeKit access must be verified on the actual native client. An Apple Home automation/Shortcut exporter is an alternative; do not remove existing pairings to make it work. Never embed the owner's general control key in a shared automation; prefer the scoped MQTT publishing path through the home server. +- The H5106 can be integrated through Homebridge or Home Assistant's official Govee BLE integration. Confirm temperature, humidity and particulate decoding, source clocks and disconnect behavior. Homebridge's [H5106 reports](https://github.com/homebridge-plugins/homebridge-govee/issues/1296) and [release history](https://github.com/homebridge-plugins/homebridge-govee/releases) document decoding/cloud-path issues. A displayed zero by itself does not establish a live zero measurement. +- Home Assistant's [Govee BLE integration](https://www.home-assistant.io/integrations/govee_ble/) requires a working local Bluetooth adapter or proxy. Preserve an existing bedside display's firmware and role when evaluating a proxy. +- Purifier/diffuser controls must reflect their actual services. No new accessory actuator actions are enabled by accepting observations. + +Validation includes isolated SQLite replay/expiry/restart tests, policy/lease/activation-evidence checks, authenticated browser/API tests, and desktop/tablet rendering. Synthetic fixtures are labeled and never sent to a real Pod. diff --git a/docs/BEDROOM_PUBLISHER.md b/docs/BEDROOM_PUBLISHER.md new file mode 100644 index 000000000..24a2ada92 --- /dev/null +++ b/docs/BEDROOM_PUBLISHER.md @@ -0,0 +1,62 @@ +# Read-only Homebridge cache publisher + +`scripts/bedroom/homebridge-cache.mjs` runs on the Homebridge Windows host. Every +15 seconds it reads the already-enabled local HAP accessory cache, then forwards +only selected readings over an encrypted SSH connection to the Pod. A successful +cache query is **not** a new hardware measurement. All reports use `cached` and +are excluded from room-derived Autopilot, even if a source is selected for control. + +The Pod receiver is `cache_ingest.py`, installed root-owned outside the writable +app data. Its configuration contains the owner environment path and an explicit +source-ID allowlist. The owner key stays on the Pod. The publishing key permits +only the forced receiver command; it cannot read sleep data, change configuration +or issue thermal, vibration or base commands. Responses contain counts only. +The receiver rejects caller-supplied commands, stale envelopes, duplicate fields, +unknown sources, nonfinite values and payloads over 32 KiB. + +Provisioning must use a pinned Pod SSH host key and a private Windows directory. +`Install-HomebridgeCache.ps1` accepts immutable source revisions and a publisher +SHA-256. It requires exactly one H5106, H712C and H7161 on the inspected bridge; +ambiguous devices stop setup. It preserves Homebridge configuration and pairings. +The SYSTEM task starts at boot, retries failures, and uses no owner credentials. + +If installation stopped after key generation but before writing configuration, +use `-ResumeIncomplete` with the same pinned publisher and host parameters. It +requires exactly the four expected setup/key files, rejects links or unknown +files and any existing scheduled task, verifies the publisher checksum, and +derives the public key to confirm the pair before continuing. Both key files are +preserved byte-for-byte. An installation that progressed further must be +inspected rather than overwritten. The Windows CI test covers this interrupted +state, mismatched keys, unexpected files and refusal to replace a complete task. + +Authorize the generated public key only after inspecting the mapping. The +`authorized_keys` entry must contain `restrict`, an exact `from` host restriction, +and a `command` invoking Python with `-I`, the reviewed receiver and its private +configuration. Preserve all existing administration keys. Validate sshd's config +before testing; no SSH service restart is needed to add an authorized key. + +Receiver configuration (local/private; example paths and source IDs): + +```json +{ + "environment": "/persistent/sleepypod-unified/controller.env", + "sourceIds": ["govee-h5106-temperature", "govee-h5106-humidity"] +} +``` + +Configure the matching bedroom sources through the authenticated versioned API. +Keep `useForControl: false`. Air quality category zero means unknown; it is +reported unavailable, never translated to a particulate concentration. Numeric +zero temperature is retained as cached evidence, not silently repaired. + +Acceptance requires checking a publication, observing unavailable/stale state +when the publisher stops, verifying reconnection, and confirming rejected shell +and actuator attempts with the restricted key. Cached readings do not qualify +sensor freshness or demonstrate physical appliance operation. HomePod temperature +and humidity need a separate supported Apple Home export; Apple TV discovery +alone does not provide those measurements. + +```sh +python3 -m unittest discover -s scripts/bedroom -p 'test_*.py' +node --test scripts/bedroom/homebridge-cache.test.mjs +``` diff --git a/docs/BED_ENTRY_PREVIEW.md b/docs/BED_ENTRY_PREVIEW.md new file mode 100644 index 000000000..fb491eb90 --- /dev/null +++ b/docs/BED_ENTRY_PREVIEW.md @@ -0,0 +1,93 @@ +# Bed-entry timing in personal comfort Preview + +Autopilot → Personal comfort → **Use Pod sensor guard in Preview**, then +**Follow earlier or later bed entry in Preview**. This is an optional proposal +mode. It does not submit leases, change schedules, power the Pod, move the base, +or vibrate. Active comfort still requires hardware qualification and reviewed +real Preview nights. It is not a sleep-onset detector. + +```mermaid +flowchart LR + Frame[Calibrated capacitance frame] --> Entry[Observed empty to person transition] + Entry --> Stable[Ten minutes of fresh advancing samples] + Stable --> Guards[Existing comfort and vital guards] + Schedule[Timezone-aware schedule events] --> Guards + Guards --> Choice[One persisted timing choice per routine] + Choice --> Proposal[Bounded temperature-phase Preview] + Proposal --> Journal[Atomic SQLite state and observation] + Journal --> App[Owner API and web explanation] +``` + +## What changes in the proposal + +- Entry must be within 90 minutes before or after the actual routine start. + Its minute-rounded difference selects a shift of at most 30 minutes each way. +- Only intermediate temperature phases move. Power-on, power-off and alarm + events retain their original times. A shift is reduced when necessary to keep + every intermediate phase strictly inside its power window. A routine with no + intermediate phases cannot produce a timing proposal. +- Example: a 22:00 routine with a 23:00 temperature phase and observed 22:20 + entry proposes that phase at 23:20. Its wake alarm and power-off stay fixed. +- The user range, one-degree step, thirty-minute correction interval, six-hour + manual pause and all water/presence/alarm/mechanical guards still apply. + The three-degree offset limit is measured against the **original scheduled + baseline**, including when the shifted phase differs substantially. +- The choice is made once per routine and committed with its observation. + Schedule, timezone, side-source or calibration changes withhold that choice; + they do not silently select a different bedtime for the same routine. + +## Evidence and restart behavior + +The existing occupancy detector can use residual movement or vital evidence. +Timing instead requires a valid calibrated capacitance level plus the person +classification. Residual movement alone never establishes bed entry. Legacy +`capSense` and `capSense2` use their existing format-specific calibrations. + +The tracker requires an observed absence-to-person transition, advancing +firmware measurement timestamps, and at least ten minutes of observations +without a sample or evaluation gap over 90 seconds. The input adapter also +rejects capacitance frames older than 30 seconds. Missing, future, reversed, +ambiguous or changed-source evidence and mechanical interference reset the +unfinished observation. Repeated frames cannot advance it. + +Starting or restarting while someone is already in bed does not assign “now” +as their bedtime. A previously committed same-routine selection can be reused +only with matching schedule/calibration identity and fresh guarded inputs. +Stopping Preview clears unfinished evidence; stored choices and manual pauses +remain. This conservative behavior may miss entries. It does not fill gaps +with inferred sleep history. + +## Additive API contract + +Existing owner authentication and version 1 endpoints remain in place: + +- `features.followBedtime: true` advertises this Preview adapter, independently + of `activation.available: false`. +- Configure with `mode: "preview"`, `sensorAware: true`, `followBedtime: true` + and the normal `minimumF`/`maximumF`. Disabling the sensor guard in the web + UI also disables bedtime following. Off preserves both opt-in settings. +- Optional `state.bedtime` contains `session`, schedule `revision`, calibrated + `sourceKey`, `entryAt`, `confirmedAt` (epoch milliseconds) and `shiftMinutes`. + These describe an observed entry and a Preview choice, never an applied action. +- Optional `timing` on status and journal events reports `state` + (`off`, `waiting`, `ready`, `withheld`), `reason`, nullable `selection`, + `scheduledBaselineF` and `phaseBaselineF`. Current timing becomes null when + its observation expires, inputs fail, state changes, or the service stops. + Persisted history remains explicitly historical. +- Native clients can negotiate this capability and ignore optional fields on + older servers. Native UI changes are maintained separately. + +No SQL migration is needed: the new selection is an optional field in the +validated JSON state. **Rollback to a binary with the older strict state schema +requires restoring the pre-deployment database snapshot.** Keep that snapshot +alongside the release and restore it while the service is stopped; do not point +an older binary at state containing the new field. + +## Validation boundary + +Software tests replay early/late entry, repeated/missing/future/reversed frames, +source changes, restarts, atomic journal failures, manual pauses, original-target +bounds, DST events and fixed power/alarm boundaries. The authenticated browser +scenario checks opt-in persistence, prerequisite enforcement, honest missing +evidence and rejection of Active. These checks do not constitute Pod hardware +qualification, a completed Preview night or demonstrated sleep benefit. diff --git a/docs/CALENDAR_PREVIEWS.md b/docs/CALENDAR_PREVIEWS.md new file mode 100644 index 000000000..1ac45de38 --- /dev/null +++ b/docs/CALENDAR_PREVIEWS.md @@ -0,0 +1,33 @@ +# Calendar dates in overnight previews + +Temperature scheduler rows use the actual calendar weekday and clock time. +A Monday 06:00 row runs on Monday morning. A Monday bedtime routine that +includes a following-morning 06:00 phase compiles that phase into a Tuesday +row; the bedtime API retains its Monday ownership separately. + +Night previews must therefore combine Monday afternoon/evening rows with +Tuesday morning rows. Previously several web views took every Monday row +and shifted its early clocks to Tuesday. That could display the wrong target +when adjacent weekdays differed, or invent a following-morning point that +was not scheduled. + +`src/lib/calendarTemperature.ts` now provides the shared projection for +Schedule, Temp, the 3D stage, Autopilot and diagnostic night views. Noon is +the display boundary: the starting day's clocks at or after noon and the +following day's clocks before noon belong to the displayed night. Explicit +display offsets keep morning-only and long curves on their actual dates. +The next-change readout uses each row's calendar occurrence directly. + +This projection does not rewrite saved rows, change controller ownership, +infer whether a sleeper is present, or issue hardware commands. Editable +curve templates retain their existing ordering behavior; display offsets +are only attached to projected calendar rows. These helpers retain the +existing browser-local clock convention. Device-timezone rendering when +the browser is in a different zone remains a separate UI concern. + +Regression checks cover distinct adjacent mornings, Saturday/Sunday rollover, +disabled points, morning-only curves, stage scrubbing and shared chart +offsets. A separate process checks Los Angeles spring-forward and fall-back +dates against the scheduler's cron parser, including the different elapsed +length of those overnight windows. These are software checks, not physical +Pod observations or Preview nights. diff --git a/docs/CLIENT_CAPABILITIES.md b/docs/CLIENT_CAPABILITIES.md new file mode 100644 index 000000000..7c16e3324 --- /dev/null +++ b/docs/CLIENT_CAPABILITIES.md @@ -0,0 +1,79 @@ +# Client capability discovery, v1 + +`GET /api/v1/capabilities` requires the existing owner Bearer credential or +browser session. It is also available as tRPC `capabilities.get({})` and is +included in `/api/openapi.json`. REST responses use `private, no-store`. + +The read observes existing DAC, Bluetooth and MQTT state without starting a +connection, creating a controller or issuing a hardware command. Missing build +metadata returns a null revision; the recorded revision comes from `.git-info`. +The upstream baseline is v3.4.0, not a claim that the fork is an upstream release. + +Each feature separates: + +- `implemented`: the route/runtime integration exists in this revision. +- `runtime`: `ready`, `not_ready` or `unknown`, describing observed technical + prerequisites. It is not permission to execute a command or a guarantee that + the next command will succeed. Command handlers still perform their checks. +- `reason`: a machine-readable explanation; clients should tolerate new values. +- `physicalVerification`: `unverified` or `not_applicable`. No hardware-qualified + state is advertised until a real qualification record is implemented. + +Device observations expire after 15 seconds; base observations after 10 seconds. +`observedAt` and `expiresAt` are UTC ISO timestamps, separate from response +`generatedAt`. Missing, nonfinite, fractional, nonpositive or future timestamps +never become fresh. Cached device reports invalidated by a write/disconnect +remain stale even if their timestamps are recent. Reading an old report does not +refresh it. The controller's clock is authoritative; clients must stop treating +a saved response as current after its expiry or loss of connection. + +Whole-bed base movement remains the only supported scope. Stop remains a +separate capability when connected, including stale-position, busy and unknown +motion states. Keep Stop visible; never infer successful stopping or movement +from HTTP success. Temperature, base and vibration remain physically unverified +on this Core fork, even when their technical prerequisites are ready. + +`nativeCompatibility` remains null and its feature remains unimplemented: the +legacy `/api/native/v1/` adapter has not shipped. `personalComfortPreview` now +describes the production Preview service; it never issues actuator requests. +`personalComfort` still reports active control unavailable. See +[Personal comfort Preview](PERSONAL_COMFORT_PREVIEW.md) for its versioned API. +Room context now has a separately negotiated direct version 1 API; see +[Room context](ROOM_CONTEXT.md). Music vibration remains unavailable. MQTT +connection alone does not imply that room inputs are connected or qualified. +`features.nightReports` on the comfort response advertises per-night reports and +owner review; see [Preview night reports](PREVIEW_NIGHT_REPORTS.md). These routes +and optional bed-entry timing fields are not forwarded through a legacy adapter. +Clients should decode the compatibility prefix as a nullable string and receipt +and expiry support as booleans; these values are not permanent schema constants. +Only enable a feature when this controller reports it. + +`importedHistory` advertises the direct read-only history API at +`endpoints.importedHistory`. Read that endpoint's status before offering records: +software readiness does not mean a snapshot exists. Legacy presence and +five-minute vitals remain a separate, unqualified source and never count as +Preview nights. See [Imported history](IMPORTED_HISTORY.md) for pagination, +units and the remaining Health/coaching boundaries. + +`morningJournal` and `endpoints.journal` advertise the full private check-in API. +`comfort.get.features.modeOnly` advertises the atomic Off/Preview operation. +Both use direct versioned contracts, with revision conflicts for journal edits; +see [Morning journal and comfort mode](MORNING_JOURNAL.md). + +Durable command receipts and expiry are implemented **only** for the versioned +control endpoint advertised by `endpoints.controls`, with +`controlSemantics.receiptScope = "versioned_controls_only"`. Legacy actuator +routes do not gain these guarantees. Query an uncertain request's existing ID; +never replay it as a new request. A successful acknowledgment does not prove a +physical outcome. See [control receipts](CONTROL_RECEIPTS.md). Versioned +measurement envelopes remain separate work. + +`alarmSnooze` describes the implemented per-alarm length/taper contract, including +complete bedtime routines. It is ready as a software settings capability without +a live DAC connection; actual vibration still uses `wakeVibration` prerequisites +and remains physically unqualified. `curveLibrary` reports the calendar curve +library as ready, with the reason `calendar_curves_with_bedtime_ownership_guard`. +An imported bedtime side must still use the complete-routine API: attempts to +apply calendar curves to it return HTTP 412 without writes. Empty alarm or +template collections must not be used to infer either capability. See +[alarm snooze](ALARM_SNOOZE.md) and [schedule library](SCHEDULE_LIBRARY.md). diff --git a/docs/COMFORT_APPLICATION_ADAPTER.md b/docs/COMFORT_APPLICATION_ADAPTER.md new file mode 100644 index 000000000..92ef9fef6 --- /dev/null +++ b/docs/COMFORT_APPLICATION_ADAPTER.md @@ -0,0 +1,96 @@ +# Personal comfort execution adapter + +The execution adapter is wired through the owner activation service documented in +[Owner activation](COMFORT_OWNER_ACTIVATION.md). Off and Preview remain inert; +a saved Active setting alone never authorizes a command. There is no environment +variable or client-supplied qualification bypass. + +```mermaid +flowchart LR + Review[Owner qualification and seven reviewed nights] --> Admission[Revocable admission] + Admission --> Policy[Fresh bounded comfort decision] + Policy --> Authority[Shared temperature authority] + Authority --> Reserve[Persist comfort reservation] + Reserve --> Journal[Persist temperature attempt] + Journal --> Queue[Recheck before each DAC write] + Queue --> Receipt[Acknowledged or unknown] + Receipt --> Cursor[Persist correction cursor] +``` + +## Implemented behavior + +`src/comfort/application.ts` submits passive requests through +`TemperatureController.replacePolicy`. It never connects a hardware client, +turns on a side, supplies a heating duration, or overrides manual, run-once, or +higher-priority automation requests. The existing authority retains temperature +and session ownership. Withdrawing a personal proposal lets that authority +reconcile the remaining owner, including the ordinary routine. + +Each proposal lasts at most ten seconds. Current global Autopilot consent, +persisted Active configuration, a matching unexpired admission revision, manual +pause, routine identity, sensor freshness, presence, water, alarm, power, base, +priming and vibration guards are checked again during selection and **before +each actual DAC FIFO write**. Monotonic expiry complements the wall clock. +These dispatch guards compose with authenticated request deadlines and close +when their operation finishes; detached scheduler work explicitly exits both +scopes. A canceled level/duration pair can be partially delivered and is not +reported as successful. + +Policy application hooks are chosen by the selected request's object identity. +A rule with the same textual request ID cannot borrow another producer's hook. +Unselected proposals consume no correction interval and create no application +reservation. The existing one-degree step, three-degree offset and thirty-minute +correction interval remain in the pure policy. Empty input, an expired lease or +a changed grant requires a fresh evaluation; selection never resurrects a +revoked proposal. + +`comfort_states.state.application` is optional for backward-compatible reads. +It records the policy request UUID, segment, target and preceding reported +target, reservation time, nullable completion time, outcome and shared-journal +receipt UUID. The reservation is committed with SQLite FULL synchronous durability before the shared temperature journal +or hardware call. The separate commits intentionally leave uncertainty on a +crash between them. No new SQL migration is needed for this versioned JSON field. + +Only a durably acknowledged shared-journal result advances `lastAppliedAt`, +`lastTargetF` and `lastSegment`. A keepalive of the same correction does not +restart the thirty-minute interval. Acknowledgment remains **accepted, not +physically verified**. A pre-write firmware poll can briefly lag an acknowledgment; +the lease waits at most ten seconds for a newer report without retrying the +target or repeatedly extending that grace period. + +An interrupted, failed, partially delivered, missing or non-durable result +persists `attempting` or `unknown`. Either withholds later personal adjustments, +including after a restart, an elapsed correction interval or a mode change. +Manual controls, shutdown and ordinary routines remain separate authorities. +There is deliberately no automatic retry or automatic clearing of uncertainty. +The owner recovery workflow archives the uncertain application, returns to Preview and persists a six-hour pause. It neither replays the command nor labels it successful. + +Unexpected target changes seen during selection latch a hold in memory without +writing during a status read. The next adapter evaluation persists the six-hour +pause under the side lock before admitting another proposal. Normal explicit +manual controls continue to use the existing durable manual-pause path. + +## Runtime integration + +The owner service verifies source-bound qualification and seven distinct reviewed +nights before supplying `ComfortAdmission`. The runtime learns and commits its +observation inside the side lock, then reconciles the adapter outside that lock. +Stop, configuration changes and runtime teardown invalidate pending generations. +The adapter's context reader omits arbitration lookup to avoid recursive selection. +An adapter that has never published a policy does not trigger baseline reconciliation +when evaluating Preview or an unqualified saved Active state. + +Bed-entry phase shifting remains Preview-only: this adapter withholds any +configuration with `followBedtime` enabled until its Active phase mapping is +implemented and qualified. No base automation, vibration effect, cloud inference, +or room-device action is enabled by this work. + +## Evidence boundary + +Tests use migrated SQLite databases, mocked Pod writes and explicitly synthetic +admission fixtures. They cover one-degree/cooldown behavior, ownership, two-part +writes, revocation while queued, storage failures, interruption/reopen, mode +locks, read-only selection and delayed firmware reports. A Unix-socket test also +holds a write behind a read, revokes permission, and confirms no actuator bytes +are sent. This is software validation, not an attended hardware qualification or +an overnight Preview result. diff --git a/docs/COMFORT_OWNER_ACTIVATION.md b/docs/COMFORT_OWNER_ACTIVATION.md new file mode 100644 index 000000000..f6c109011 --- /dev/null +++ b/docs/COMFORT_OWNER_ACTIVATION.md @@ -0,0 +1,135 @@ +# Owner qualification and thermal activation + +The personal comfort card now connects attended thermal qualification, reviewed +Preview nights and bounded Active temperature control. This implements the owner +workflow; it does **not** mean a particular Pod has passed those checks. Installation, +an old saved Active setting, elapsed time and imported sleep history cannot activate it. + +```mermaid +flowchart LR + Sensors[Fresh calibrated Pod reports] --> Check[Attended check review] + Commands[Temperature and wake acknowledgments] --> Check + Owner[Owner physical observations] --> Check + Preview[Seven source-backed reviewed nights] --> Review[60-second activation review] + Check --> Review + Review --> Grant[Durable owner grant] + Grant --> Authority[Shared temperature arbitration] + Authority --> Guard[Recheck live inputs at DAC dispatch] + Guard --> Result[Acknowledgment or uncertainty] + Result --> Recovery[Explicit owner recovery if uncertain] + Recovery --> Pause[Preview plus six-hour pause] +``` + +## What the owner reviews + +Six checks record **owner observations separately from machine evidence**: + +| Check | Server evidence | Owner observation | +|---|---|---| +| Empty / occupied side | Fresh calibrated frame and classification | Whether that side was actually empty or occupied | +| Cooling / warming | Recent manual temperature acknowledgment and fresh matching powered target; warming target at least 2°F above cooling | Felt response, routing, absence of leaks or unusual noise | +| Manual stop | Fresh firmware-confirmed off state, manual pause, both thermal checks within 24 hours | Heating/cooling stopped | +| Offline routine and wake | Scheduled temperature and alarm acknowledgments within 24 hours | Internet, phone and home server were unavailable; local routine, wake alarm and cancellation worked | + +Opening or confirming a check never sends a hardware command. Use the ordinary +controls for the attended test. “No confirmed power-on” is not treated as a confirmed +off report. Base idle telemetry, adequate water and inactive priming/vibration are +required. The offline failure conditions and physical results are explicitly +owner-reported; an acknowledgment alone does not prove them. + +All evidence binds to a hashed embedded-storage identity, calibrated presence +source, build revision, enabled weekly routine and comfort policy. Raw hardware +identifiers and credentials are not exposed. Nonproduction hosts and missing +hardware identities cannot qualify. Existing Preview reports remain readable; +reports lacking hardware provenance cannot qualify activation. + +Adaptive capacitance profiles publish a persisted `qualification_epoch`. Ordinary +15-minute baseline updates retain that epoch while the sum of absolute channel-mean +changes from its fixed anchor stays within one quarter of the entry threshold. +The anchor never follows each small update, so many small changes cannot evade the +cumulative limit. Live occupancy still uses the latest means; this does not freeze +the sensor baseline or treat an old observation as current. + +The quarter-threshold limit bounds the change in summed deviation; it is an +engineering validation boundary, not a demonstrated accuracy margin. Manual +recalibration, baseline reseeding, changed thresholds/reference compensation or +excessive drift starts a new epoch. Malformed or out-of-budget epoch metadata +withholds the qualification identity. Normal state/profile restoration retains a +valid epoch across process restarts. Older producers without metadata keep strict +parameter hashing until their first epoch publication. Existing qualifications and +Preview records are never relabeled as belonging to a new epoch or build. + +## Seven nights and explicit consent + +Each night must already have an explicit owner review of its exact retained source +digest. Activation then requires at least four hours of **eligible observation +intervals** on the current Pod/build/calibration/routine/policy. Intervals cannot +bridge gaps over 90 seconds, process restarts, ineligible observations or missing +sensor readiness when the sensor guard is enabled. Dates and overlapping sessions +are deduplicated. These are observation intervals, not inferred sleep duration. + +The server creates a review that expires after 60 seconds. Confirmation accepts +only its ID, an explicit owner acknowledgment and an optional note. It rechecks +identity, latest qualification records, original source digests, latest night +reviews, runtime and global Autopilot consent inside the final transaction. A +stale, expired, cross-side or changed review cannot activate. Repeating an already +consumed review is an inert success acknowledgment, not another activation. + +The global Autopilot switch remains a separate decision: enabling it can also +enable existing rules. Personal activation never turns it on as a side effect. +Direct configure/mode requests still reject `active`; only the owner review can +create the durable grant and set Active atomically. + +Once admitted, the existing policy enforces the reviewed limits (defaults 70–95°F), +at most 1°F per 30 minutes, offsets within ±3°F, occupied/fresh/water/mechanical/alarm +guards, manual priority and a six-hour manual pause. It never powers a side on. +Every hardware write rechecks the grant and live inputs; a model cannot grant consent. + +Configuration edits revoke the grant. Runtime stop immediately invalidates queued +leases. Build, calibration, routine or qualification changes withhold admission; +night review replacement also withholds it. A new build currently requires matching +new Preview evidence; there is no automatic cross-build equivalence claim. The +activation snapshot retains its evidence after routine source-journal retention; +referenced owner reviews remain retained while an unrevoked grant needs them. + +## Recovery and storage + +Migration `0044_comfort_owner_activation.sql` adds prepared reviews, append-only +qualifications, activations and uncertainty resolutions. Existing settings and +learning cursors remain intact. Intent/reservation, completion, owner activation, +recovery and manual-pause writes use FULL-synchronous SQLite transactions. Ordinary +Preview observations keep the normal write mode. + +An attempting/unknown command blocks further adaptive application. Recovery shows +the original request and fresh reported state. Explicit confirmation archives that +evidence, revokes activation and returns to Preview with a six-hour pause. It does +not relabel the old command, retry it, infer movement/vibration, or erase the separate +temperature-application journal. A stale recovery reference cannot clear a later +uncertain command. + +REST endpoints under `/api/v1/comfort/owner` are `GET /`, `POST /prepare`, +`POST /confirm` and `POST /retest`; equivalent authenticated tRPC procedures are +`comfort.owner`, `prepareOwnerReview`, `confirmOwnerReview` and `retestQualification`. +Read endpoints never start hardware services. Capability discovery advertises the +review workflow separately from an actually admitted personal comfort policy. + +For existing native clients, `GET /api/v1/comfort` preserves +`activation.available: false`: the legacy mode/configure endpoints still cannot +activate. The additive `activation.current` field describes an admitted owner +grant; the new owner endpoint exposes its own `active` and `canActivate` fields. +Older clients ignore these additions and can still read status, stop, configure +Off/Preview and save check-ins. The independently maintained native app can adopt +the owner review endpoints without changing this compatibility contract. + +## Remaining physical and feature gates + +No attended hardware test or real Preview night is established by this source +change. Bed-entry phase shifting remains Preview-only until its Active mapping +is implemented and qualified; activation rejects `followBedtime`. Automatic base +movement, experimental music vibration and house automations remain separate gates. +Clinical accuracy and improved sleep outcomes are not established by these checks. + +Software tests exercise the real migrated store, source validation, review races, +durability, uncertainty recovery and the runtime-to-authority path with mocked +hardware. Browser tests exercise authenticated endpoints and honest unavailable +states with all hardware services disabled. None is physical qualification. diff --git a/docs/COMPLETE_NIGHT_SUMMARIES.md b/docs/COMPLETE_NIGHT_SUMMARIES.md new file mode 100644 index 000000000..30a1f4a86 --- /dev/null +++ b/docs/COMPLETE_NIGHT_SUMMARIES.md @@ -0,0 +1,69 @@ +# Complete-night recording summaries + +A night can contain several Pod presence records. Tonight, Night and Week +must use all records assigned to that night; a longer older record must not +replace a newer fragmented night. These are recording summaries, not proof +of uninterrupted sleep. + +```mermaid +flowchart LR + Records[Same-side presence records] --> Calendar[Select noon-to-noon calendar night] + Calendar --> Union[Union overlaps and retain gaps] + Union --> Coverage[Recorded duration and unknown gaps] + Union --> Segments[Classify each recorded window separately] + Segments --> Summary[Combined stages, quality and vitals] +``` + +## Read contract + +`GET /api/biometrics/sleep-stages` retains explicit `sleepRecordId` and +`startDate`/`endDate` requests. With neither, it selects the most recent +recorded night in the last seven days without a minimum segment duration. +Clients can request an evening by `nightDate=YYYY-MM-DD`. `timeZone` is an +optional validated IANA zone; otherwise the Pod's configured zone is used. +Calendar clients pass their display zone so travel does not select a different +night. Morning fragments up to local noon belong to the preceding date. +This convention groups recordings; it does not distinguish naps from sleep. + +The additive `recording` object contains the selected `recordIds`, `nightDate`, +first/last timestamps, merged `durationMs`, inter-segment `gapsMs`, and exits. +Overlaps count once. A multi-record result has `sleepRecordId: null`; explicit +single-record edits still address a real record ID. An empty night returns no +coverage instead of an invented full-night interval. + +`totalSleepMs` now excludes estimated awake epochs. `totalStagedMs` includes +all staged epochs, including awake. Neither includes unknown recording gaps, +and neither is interchangeable with recorded bed duration. Existing clients +that used `totalSleepMs` as total staged time should use `totalStagedMs` or sum +epoch durations. Raw records, calibration, model selection, reference data, +hardware controls and routines are unchanged. + +Week vitals and quality use all merged recording windows. Classification and +smoothing cannot cross gaps between records. Month and Tonight durations use +the union of recorded windows. Tonight's read-only history remains available +while hardware status reconnects. Missing vitals still mean missing stages; +these changes do not repair sensor presence gaps or validate sleep stages. +The heart-rate chart uses actual elapsed time and separate unfilled paths for +contiguous readings; hovering an unknown interval reports no reading. +The bounded record limit is 1,000 and movement limit is 10,000 so fragmented +months and overnight week inputs are not truncated at the old small limits. + +## Validation and release + +Regression coverage includes fragmented newest nights versus older long +records, overlaps, unknown gaps, local-noon/DST grouping, browser/Pod timezone +differences, full-night vital aggregation, all-awake output, request limits, +and compatibility with demo and explicit-record/range requests. + +Before a separately authorized Pod deployment, preserve the current release +and database backups. After release, compare known multi-record nights across +Tonight and Sleep, verify record IDs/duration against the unchanged database, +check gap-free epoch boundaries, and confirm the native explicit-record client +still works. Retain the previous app release for rollback. Merging this source +change is not evidence of deployment, physical sleep accuracy or model quality. + +The generated sensor services use `PartOf=sleepypod.service` and +`BindsTo=sleepypod.service`; the existing upgrade helper also stops the full +service group. A normal controller restart therefore cannot promise uninterrupted +sensor processes. Verify the installed dependency graph and plan an attended +restart accordingly; do not silently detach sensor services to deploy this fix. diff --git a/docs/CONTROLLER_HANDOFF.md b/docs/CONTROLLER_HANDOFF.md new file mode 100644 index 000000000..5f2ee8899 --- /dev/null +++ b/docs/CONTROLLER_HANDOFF.md @@ -0,0 +1,269 @@ +# Controlled first-installation handoff + +The unified installation keeps Free Sleep's code, data, Python environment, +Node runtime, service definitions and dedicated SSH in place. The upstream +installer and purge are not part of this path. Core uses separate directories +under `/persistent/sleepypod-unified/` and its pinned private Node runtime. + +```mermaid +flowchart LR + Plan[Review exact paths and generated services] --> Install[Install absent units only] + Install --> Legacy[Free Sleep remains active and boot default] + Legacy --> Stop[Stop both legacy writers and verify exit] + Stop --> Trial[Start Core and check authenticated fresh telemetry] + Trial --> Restore[Stop Core and its sensor processes] + Restore --> Return[Restart retained Free Sleep] +``` + +## Prepare before any service action + +1. Complete the [offline runtime and dependency probes](OFFLINE_POD_STAGING.md). + Verify every release file against its bundle manifest after transfer. Keep + the exact source archive and source commit with the release. The service + tool does not repeat full bundle verification. +2. Before stopping writers, run the importer's `--check-timezone` for the source + settings timezone in its locked migration environment (see + [offline migration](FREE_SLEEP_MIGRATION.md)). Then take the reviewed quiesced + migration backup and import it into a new data directory. The + [quiesced preparation command](QUIESCED_IMPORT.md) restores Free Sleep before + importing into a separate review folder and retains every translation warning. + Review routines, current temporary overrides and alarm behavior. + Promote all three databases, the migration report and its preserved `legacy/` + data together. The imported history snapshot is required for activation. + Keep Core's adaptive comfort Off or Preview and house/base automations off. + Imported calibration remains pending actual empty/occupied qualification. +3. Put the already-probed Python modules and environments in their own directory. + Ensure their project/lock/source identities match the reviewed release. + These templates are specifically for a **verified NATS installation**. They + run the four NATS-capable processors. The RAW-only cover-buttons observer is + not started or claimed to work on that feed. +4. Give `dac` read/execute access to the release and Python directories and + ownership of the separate data directory. Preserve root ownership of code; + do not recursively change the retained Free Sleep installation. Keep the + owner environment root-owned, mode 0600, outside the data/source directories. +5. Record the working Free Sleep authenticated status, service identities, + filesystem permissions and private recovery artifacts. Keep a second SSH + session available for the attended trial. Review the exact services first. + +The services run as the existing `dac` user. Sensor writers can write only the +new data directory; the controller also needs its inspected DAC directory. +System maintenance, firewall changes and privileged self-updates are not granted +by these templates. Those operations need their own reviewed integration. +The Python services do not receive the controller's credential environment. + +Example configuration (replace revision and paths with the verified artifacts): + +```json +{ + "release": "/persistent/sleepypod-unified/releases/REVIEWED_REVISION", + "modules": "/persistent/sleepypod-unified/sensors/REVIEWED_REVISION/modules", + "data": "/persistent/sleepypod-unified/data", + "environment": "/persistent/sleepypod-unified/controller.env", + "dacSocket": "/persistent/deviceinfo/dac.sock", + "rawData": "/persistent" +} +``` + +`controller.env` contains an unquoted, securely random URL-safe +`SLEEPYPOD_AUTH_TOKEN` of at least 32 characters and the reviewed host/HTTPS +settings from [owner access](OWNER_ACCESS.md). Never put the key in the plan, +command line, source, report or screenshots. The service manager loads it for +the controller; the handoff tool reads it only for a loopback HTTP request. + +## Render and install without starting + +```sh +python3 scripts/deployment/service_plan.py \ + --config /private/reviewed-paths.json --output /private/new-service-plan +``` + +This command only writes a new private plan directory. It validates canonical +paths and separates source, modules, data and credentials. Review its five unit +files and transfer the plan privately to the Pod. As root on the Pod: + +```sh +python3 handoff.py install --plan /private/new-service-plan +``` + +Installation refuses existing units, unit aliases and a previous journal. It +records original unit/drop-in hashes and enablement, writes only absent Core +units and reloads systemd. It **does not start or enable anything**. A partial +installation leaves a journal and never silently overwrites or deletes files; +inspect its recorded creations before recovery. Keep `handoff.py`, +`service_plan.py`, `prepare_offline.py`, `maintenance_status.py` and +`recovery_privacy.py` together, from the reviewed commit. Initial handoff and +retained-Free-Sleep rollback are held if personal household records exist; +see the [personal recovery boundary](PERSONAL_RECOVERY_BOUNDARY.md). + +## Replace an inactive release plan + +For a newer verified release after a stopped trial, render a **new** plan with +the same data, credential, socket and RAW paths, then replace only the managed +unit files: + +```sh +python3 scripts/deployment/replace_plan.py \ + --previous-plan /private/previous-service-plan --plan /private/new-service-plan +``` + +Both plans are locked against concurrent handoffs. Core must be stopped and +disabled and Free Sleep active and enabled. Original unit hashes and settings +are checked before replacement. A partial write failure restores the previous +unit files; unrelated edits require review. Neither installation is started or +enabled by this command. Keep both plans and immutable release directories. + +## Bounded attended trial and rollback + +```sh +python3 handoff.py trial --plan /private/new-service-plan --seconds 120 \ + --settle-seconds 30 --require-idle +``` + +The trial requires both original services active/enabled and every Core service +stopped/disabled. It verifies the installed templates and rejects new drop-ins +or changed legacy units. It stops the original writers, confirms their main +processes exited, then starts Core. Conflicts and ordering also enforce ownership +inside systemd; stopping Core stops its related sensor services. + +The tool waits **up to** the deadline for authenticated capabilities to identify +the exact staged build and a fresh device report. The optional settling period +requires consecutive fresh observations; stale/failed reads reset that period. +`--require-idle` checks the legacy API before stopping services, raw Core heating +countdowns during the trial, and the restored legacy API afterward. Priming or +nonzero/unavailable countdowns reject the trial; a post-restoration mismatch +revokes success. These checks report drift and do not issue corrective actuator +commands. They do not establish physical thermal or vibration behavior. +The tool then stops all Core units +and restores Free Sleep, including on startup failure, stale telemetry or an +ordinary interruption. It never changes boot enablement. A machine power loss +therefore still boots the original enabled services. A process kill that cannot +be caught, changed unit definitions or an OS/service-manager failure can still +require manual recovery; the durable journal records the last completed phase. + +```sh +python3 handoff.py status --plan /private/new-service-plan +python3 handoff.py rollback --plan /private/new-service-plan +``` + +Rollback stops verified Core units before starting the unchanged original +services. It refuses to operate on an unrelated replacement unit. If original +configuration changed, it stops the verified Core processes and leaves a clear +error instead of overwriting that change. No databases are copied over active +writers. Follow recovery with authenticated Free Sleep status and the attended +physical checks; an active process alone does not prove restored thermal control. + +`handoff.json` distinguishes trial success, restoration and physical verification. +It contains no owner key or health measurements. A trial establishes neither +permanent migration nor seven Preview nights. Permanent activation, private proxy, +sensor calibration, base/Stop, vibration/cancellation and overnight acceptance +remain separate work. + +## Permanent activation after a successful idle trial + +`activate.py` is a separate, explicit step. It requires a successful +`--settle-seconds 30 --require-idle` trial of the exact release within the last +24 hours, including verified idle Free Sleep restoration. Repeat the trial if +the receipt is missing, stale, from another release, or only checked API startup. + +Before activation, review imported routines and alarm semantics, source archive +and release checksums, current water/cover connections, and the next scheduled +events. Keep a second SSH session available. Run during an attended idle period +with no impending routine or alarm. Both temperature-control countdowns and +priming must be inactive. This tool never cancels an active user session to +make the bed pass its checks. + +```sh +python3 activate.py activate --plan /private/new-service-plan \ + --legacy-data /persistent/free-sleep-data --seconds 120 +``` + +The read-only gates compare the retained settings, routines, calibration, +comfort history and feedback files with the import's `legacy/` copies. They run +again after stopping the legacy writers. Changed files or additions require a +fresh reviewed import; activation does not overwrite them. Live metric databases +are retained in Free Sleep's directory and are not compared byte-for-byte while +its stream is writing. Take an updated quiesced archive if metrics accumulated +since the original backup must also be included in the migration archive. + +All three databases must pass integrity and foreign-key checks. The separate +`free-sleep-history.db` must match its import checksum, contain the report's +exact history metadata, and refer to the preserved `legacy/free-sleep.db` +checksum. It must be a standalone snapshot without SQLite sidecars. Missing, +changed or partial history copies fail before stopping Free Sleep; older reports +without history evidence require a new import. These checks only read the +candidate and never repair or synthesize missing history. + +Initial activation requires Off/Preview personal comfort, disabled general automation, +base schedules, HomeKit/MQTT, presence-based power, always-on behavior, active +run-once sessions, automatic priming/reboot and unqualified pump stall protection +and recovery. Review and +enable qualified integrations separately after activation. Imported ordinary +temperature/power/alarm routines remain intact and will run at their scheduled +times; activation does not qualify their physical behavior. + +```mermaid +flowchart LR + Gates[Recent idle trial and unchanged import] --> Stop[Stop legacy writers] + Stop --> Core[Start Core and four sensor services] + Core --> Observe[30 continuous seconds of fresh idle telemetry] + Observe --> Boot[Select Core for boot] + Core -->|Failure or interruption| Restore[Restore retained Free Sleep] + Observe -->|Failure| Restore + Boot -->|Partial failure| Restore +``` + +Core's five processes must be active and both Free Sleep processes stopped +throughout settling. Failed/stale telemetry resets the window; a nonzero heating +countdown or priming fails immediately. Only after that window does activation +disable the two original boot entries and enable `sleepypod.service`. Sensor +services remain individually disabled and start through Core's `Wants=` links. +Exact service definitions and final boot/process ownership are checked again. + +`activation.json` is a private, durable phase journal, without the bearer key. +Ordinary startup failures, partial boot-selection failures and interruptions +restore the original services and enablement. Unexpected heat during a failed +idle activation is reported as `recovery_required`; the tool does not issue +actuator commands or claim physical recovery. Inspect authenticated firmware +status and the bed before resuming use. + +```sh +python3 activate.py status --plan /private/new-service-plan +python3 activate.py rollback --plan /private/new-service-plan +``` + +After activation, use **this rollback command**, not `handoff.py rollback`. +The trial tool refuses plans with an activation journal. Rollback stops verified +Core services, restores Free Sleep boot entries, and restarts its writers. +Neither controller's databases are overwritten. An ordinary rollback after +successful activation permits an existing heating session and does not cancel +it. Changed unit definitions require review before starting any replacement. +Reusing an activation journal for another activation is refused; keep it as +evidence, render a new plan, and use the stopped-plan replacement workflow. + +Boot-selection changes are **not atomic across machine power loss**. A hard +kill, power loss, or service-manager failure between writes can require SSH +recovery with the journal and this rollback command. A completed activation +does not establish restart recovery, physical control, sensor accuracy, private +remote access or seven actual Preview nights; verify those gates independently. +Keep `activate.py`, `activation_checks.py` and the handoff modules together from +the reviewed commit. These activation checks have software-test coverage; no +live activation is asserted by this document. + +## Validation + +The deployment test suite exercises unsafe paths, altered/rehashed plans, +existing units, service-stop and startup failures, stale observations, +interruptions, secret-file permissions, changed units and journal recovery. +It verifies that no outgoing process can remain when the other controller +starts and that trials never change boot selection. Run: + +```sh +python3 -m unittest discover -s scripts/deployment -p 'test_*.py' -v +``` + +On 25 October 2026, a separate live sensor-only probe used fresh isolated +databases and the four NATS processors for 90 seconds. All four remained alive +and exited cleanly; both databases passed integrity checks and the original +Free Sleep/stream/firmware process IDs stayed unchanged. Sensor rows were +produced, but occupancy labels and vital accuracy were not independently +verified. This sensor evidence does not substitute for the controller trial. diff --git a/docs/CONTROL_RECEIPTS.md b/docs/CONTROL_RECEIPTS.md new file mode 100644 index 000000000..28d6718fe --- /dev/null +++ b/docs/CONTROL_RECEIPTS.md @@ -0,0 +1,120 @@ +# Versioned control requests + +`POST /api/v1/controls` and `GET /api/v1/controls/{requestId}` add durable +duplicate protection and dispatch deadlines to immediate manual controls. +Their tRPC equivalents are `controls.submit` and `controls.get`. Both transports +use the existing owner authentication boundary. Demo calls cannot actuate hardware. + +Clients must discover `endpoints.controls` and +`controlSemantics.receiptScope = "versioned_controls_only"` from +`GET /api/v1/capabilities`. Legacy device/base routes remain compatible, but +**do not provide receipt or expiry guarantees**. This endpoint does not grant +an LLM, MQTT sender, or house automation unrestricted actuator access. + +## Request contract + +```json +{ + "requestId": "18af1f91-5728-4f16-8e99-c091239c9e14", + "issuedAt": 1793000000000, + "expiresAt": 1793000030000, + "action": { "kind": "temperature", "side": "left", "temperature": 75 } +} +``` + +Times are Unix **milliseconds**. Create a fresh UUID for a deliberate new action; +retain that exact ID, payload and timestamps when checking an uncertain submission. +The example timestamps are illustrative and must not be reused. The lifetime +must be greater than zero and at most 60 seconds. Future-issued requests are +rejected; expired requests are recorded without execution. Synchronize clocks; +do not renew an old request's deadline to get it accepted. + +| Action | Fields | Scope | +| --- | --- | --- | +| `temperature` | `side`, integer `temperature` 55–110°F, optional `holdMinutes` 1–1440 | Existing manual temperature owner, safety checks and adaptive pause | +| `power` | `side`, `powered`, optional integer `temperature` 55–110°F | Existing manual power behavior | +| `vibration` | `side`, `intensity` 1–100, `pattern` `rise`/`double`, `duration` 10–180 seconds | Requested protocol values; physical behavior remains unqualified | +| `vibration.clear` | `side` | Cancel that side's vibration | +| `base.position` | integer `head` 0–60°, `feet` 0–45°, optional `feedRate` 30–100 (default 50) | Whole bed; existing connection, freshness and busy checks | +| `base.stop` | none | Stop remains available without fresh position telemetry | + +Snooze, scheduled actions, priming, music and raw hardware commands are excluded +from this immediate-action contract. Schedule the future through the schedule +APIs rather than retaining a control envelope for later delivery. + +## What a receipt means + +| State | Meaning | Client response | +| --- | --- | --- | +| `executing` | Claimed by this process; operation is still running | Poll the same ID | +| `acknowledged` | The existing hardware operation completed after at least one dispatch barrier | Display acknowledged, with separate reported measurements | +| `not_dispatched` | Handler completed without sending a hardware command, such as a superseded dial update | Refresh state; do not claim an adjustment occurred | +| `rejected` | Invalid timing or another rejection before any dispatch | Show rejection and refresh state | +| `expired` | Deadline prevented dispatch before any command was sent | Show expiry; require a deliberate new action | +| `unknown` | A possible write lost its outcome, a partial operation failed, or execution was interrupted | Show uncertainty; inspect reported state and do not replay | + +`physicalVerification` is always `unverified`; `replayAllowed` is always `false`. +`dispatchCount` counts **durably recorded dispatch attempts**, not confirmed +physical effects. A barrier may be persisted immediately before expiry or a +socket failure prevents the write. `finishedAt` is null for an interrupted +execution whose completion could not be recorded. An acknowledgment can arrive +after expiry if every write began before the deadline: expiry limits dispatch, +not the duration of a physical effect or the response time. + +Repeated identical submissions return the existing receipt and never re-execute. +An ID reused with a changed normalized payload or lifetime returns HTTP 409. +Missing receipts return 404. A storage failure can return 500: query the same ID +before any further action. Do not translate an unknown result into success merely +because the latest temperature or base position happens to match the request. + +```mermaid +flowchart LR + Request[Authenticated request] --> Claim[Durable unique claim] + Claim --> Existing[Existing ID: return receipt] + Claim --> Check[Existing authority and side lock] + Check --> Deadline[Recheck deadline] + Deadline --> Barrier[Durable dispatch barrier] + Barrier --> Write[DAC or Bluetooth write] + Write --> Receipt[Acknowledged or unknown] + Barrier --> Expired[Expired after barrier: unknown] +``` + +## Implementation and recovery boundaries + +- SQLite claims, dispatch barriers and completion updates use independent + `synchronous=FULL` immediate transactions. Ordinary application writes retain + their existing setting. This follows SQLite's documented WAL durability model; + actual power-loss durability still depends on the filesystem/storage honoring + synchronization. See [SQLite synchronous](https://www.sqlite.org/pragma.html#pragma_synchronous). +- The DAC guard runs **inside** the sequential transport queue, just before + socket write. Hello and status reads are excluded. The Bluetooth guard runs + immediately before GATT WriteValue. Guards use wall and monotonic time and + fail closed on rollback, deadline expiry or a closed request context. +- Multi-command actions are not atomic on hardware. For example, a head command + can be sent before the feet command expires. Such a result is `unknown`. +- Hardware write callbacks are never automatically retried after socket errors, + including through legacy routes. Only callbacks explicitly marked read-only + can reconnect and retry once. Periodic temperature arbitration and other + independent owners remain separate actions, not receipt replays. +- Per-side vibration start/clear now use the shared side lock. Clear also checks + the firmware response. Long-lived scheduler initialization explicitly leaves + the caller's request context, so future alarms do not inherit an old deadline. +- No pending command queue is persisted. After restart, an unfinished receipt + is reported as unknown without resuming it. Receipts are retained in + `sleepypod.db` and must be included in backups. Do not prune them or restore an + older configuration database during active use without invalidating outstanding + client requests and verifying the controller clock. Storage exhaustion blocks + new recorded controls; legacy Stop remains callable independently. + +## Validation evidence + +Local tests use real migrated SQLite files, fake clocks, simulated Unix sockets +and a fake BlueZ bus. They exercise duplicate delivery, ID conflicts, expiry +behind queues, clock rollback, partial dispatch, failed persistence, a database +reopen, scheduler isolation, rejected firmware replies and the no-retry rule. +The production browser test exercises authenticated REST/OpenAPI and rejects +anonymous REST/tRPC; it submits only expired requests, never live commands. + +This is software evidence. Pod restart/power-loss behavior, base movement/Stop, +vibration, native adoption of this endpoint and the seven-night Preview remain +separate acceptance steps. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index eb156a87c..3c5bc7e5c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -125,7 +125,48 @@ The pod downloads this pre-built tarball via `sp-update`, so it never needs to r ### Path 3: Remote Update (Web UI / iOS) -The pod self-updates by downloading from GitHub. Triggered via the `system.triggerUpdate` tRPC endpoint. +The pod self-updates by downloading from its installed GitHub repository. Triggered via the `system.triggerUpdate` tRPC endpoint. + +The installer records the selected `OWNER/REPO` in the root-owned, mode-0644 +`/etc/sleepypod/github-repo`, outside the replaceable application directory. +Reinstallation preserves that source unless explicitly overridden. The updater +reads this file directly, so a web/native request through `sudo` does not rely +on inherited environment variables. It never silently falls back to upstream. + +Existing unpinned installations must select their actual repository explicitly +during an attended update, for example `sudo env SLEEPYPOD_GITHUB_REPO=OWNER/REPO +sp-update main`. A successful update persists that selection; failure restores +the previous pin with the code. Missing, malformed or symlinked metadata stops +an ordinary update before network or service changes. Local archives preserve +the existing pin; an archive with no known source stays unpinned. The deployment +script passes an explicit `--repo` or a recognized GitHub `origin` to the updater. +Archive contents cannot choose their own future update source. + +The API also checks the pin before spawning the updater. It returns +`PRECONDITION_FAILED` if unavailable, and includes `repository` alongside +`triggered`, `branch` and `message` when a request is launched. Launch is not +proof of installation success. The separately staged, isolated offline runtime +still uses its reviewed deployment and rollback workflow; this change neither +grants it updater privileges nor activates it. + +### Updating sensors in an isolated runtime + +`scripts/deployment/stage_sensors.py` prepares a separate versioned sensor source +directory from the staged release's checksum-verified source archive. It reuses +the previous virtual environments only when every running module's `uv.lock` and +`pyproject.toml` match exactly. A dependency change requires a separately prepared +locked environment and is refused by this source-only workflow. + +Run the existing dependency probe against the candidate as the `dac` account +before changing services. Render a new service plan that selects both the new +release and candidate modules directory. `upgrade.py` verifies the complete +sensor source and environment links before stopping the controller. Its existing +quiesced backup, restore rehearsal and startup verification then switch the Core +and sensor unit files together. Failure restores the previous units and data; +the previous sensor files and environments were never modified. + +Keep reused environment directories as rollback dependencies. Staging does not +start a daemon, claim a hardware check, or carry qualifications to the new build. ```bash # From SSH on the pod: @@ -139,7 +180,7 @@ sp-update --archive /persistent/sleepypod-core.tar.gz # uploaded pre-built arch ``` **How it works:** -1. Resolves `main`/`latest` to the latest stable release, `dev` to `dev-latest`, and other branches to `-latest`. +1. Resolves `main`/`latest` to the pinned repository's latest stable release, `dev` to `dev-latest`, and other branches to its Build & Package artifact via nightly.link. 2. Downloads and validates the pre-built archive before replacing installed files. 3. Backs up the installed code and database, then installs the new code and production dependencies. 4. Restores the firewall and starts the service (database migrations run at startup). diff --git a/docs/FREE_SLEEP_MIGRATION.md b/docs/FREE_SLEEP_MIGRATION.md new file mode 100644 index 000000000..00419ee0b --- /dev/null +++ b/docs/FREE_SLEEP_MIGRATION.md @@ -0,0 +1,197 @@ +# Offline Free Sleep migration + +This importer builds a **review candidate**, not an installation. It never +contacts the Pod, changes services or sends hardware commands. Keep the working +Free Sleep installation and SSH service. Do not run the upstream purge as part +of this workflow. Core cutover and rollback tooling remain separate work. + +```mermaid +flowchart LR + Backup[Verified Free Sleep backup] --> Validate[Hashes, membership and schemas] + Validate --> Candidate[New Core databases] + Validate --> Legacy[Original data and private recovery archive] + Candidate --> Review[Review and qualification gates] + Review -. not implemented by this script .-> Cutover[Single controller cutover] +``` + +## Run + +Requires Python 3.10+ and the locked `scripts/migration` uv environment. Its +`tzdata` dependency supplies IANA timezone data on Pods whose operating system +omits it. This is a separate tooling environment; sensor dependency locks and +the Pod's global Python installation are unchanged. + +Before stopping any service, check the **actual source settings timezone** with +the same interpreter/environment that will perform the import. For example: + +```sh +uv run --project scripts/migration --frozen python scripts/migration/free_sleep.py \ + --check-timezone America/Los_Angeles +``` + +Run from this repository with its checked-in control and biometric migrations: + +```sh +uv run --project scripts/migration --frozen python scripts/migration/free_sleep.py \ + --backup /absolute/path/to/free-sleep-backup \ + --output /absolute/path/to/new-review-directory +``` + +For an offline Pod, prefetch the hash-verified `tzdata` wheel identified by +`scripts/migration/uv.lock`, install it into a separate uv-created environment +with `--no-index --no-deps`, and run that environment's Python for both commands. +Do not change the source timezone or replace it with UTC to hide missing data. +Timezone validation also runs before either candidate database is created. + +The backup must use `free-sleep-migration/1` from the companion Free Sleep +`scripts/migration/backup.py`, including `manifest.json`, `data.tar.gz` and +`private.tar.gz`. Output must be a new directory outside the source backup; +its parent must exist. Existing destinations are never replaced. Invalid input +removes temporary staging and leaves the source unchanged. Archive links, +traversal, duplicate/noncanonical members, missing members and checksum changes +are rejected. Directories/files are owner-only (0700/0600). + +The backup format contains transactional SQLite snapshots and independently +copied settings. It does not guarantee one transaction across all stores. Take +a fresh, quiesced snapshot at eventual cutover and preserve the installation, +service units, stream configuration, SSH and firewall recovery materials too. + +Output contains: + +- `sleepypod.db` and `biometrics.db`, created using the checked-in Drizzle SQL + and migration ledgers; the ordinary Drizzle migrator can subsequently resume. +- `free-sleep-history.db`, a separate indexed read-only query snapshot of + supported original measurements. Allow additional disk space for its data + and indices; its checksum and source metadata are included in the report. +- `legacy/`, with every original data file, including the legacy biometric DB, + routines, journal and full learning history. +- `source/`, with exact copies of all three backup files. Credentials remain + inside the private archive and are not loaded into Core settings or reports. +- `migration-report.json`, with counts, checksums, unresolved translations and + `readyForCutover: false`. Do not point a running controller at this candidate. + +## Translation contract + +| Source | Candidate behavior | +| --- | --- | +| Timezone, units, side names, away mode | Validated and copied. Core-specific automatic presence controls remain at disabled defaults. | +| Daily prime/reboot | Preserve effective behavior: Free Sleep registers reboot only when daily priming is enabled, one hour before the priming clock. A requested but inactive reboot remains disabled and is reported. | +| Bedtime ownership | Preserve one complete routine per starting weekday in `bedtime_routines`, with exact ownership of its compiled scheduler rows. Edit it through `/api/v1/routines`; calendar mutations cannot sever it. | +| Power | Preserve enabled state and on-target. `offDayOffset=1` only if OFF precedes ON by clock time; otherwise `0`. Equal enabled boundaries are rejected. `endAction=turn_off`. | +| Temperature phases | Earlier clocks move to the next calendar weekday. Only phases strictly inside an enabled power window are enabled. Out-of-window points are retained disabled and reported. | +| Alarms | Earlier clocks move to the next calendar weekday; disabled when the parent routine is disabled. Preserve intensity/pattern and Free Sleep's effective minimum ten-second duration. Core's wake window starts disabled. Per-alarm snooze duration comes from the side's first configured snoozing alarm tap; differing tap durations are flagged for review. Snooze taper starts off. See [snooze compatibility](ALARM_SNOOZE.md). | +| Tap gestures | Translate temperature and alarm actions; unrepresentable values fail rather than round silently. | +| Personal comfort | Preserve learned offset, consumed-feedback/session cursors, manual pause and last request fields. Start Off; unsupported vital/bedtime adapters remain disabled. Preserve full original activity and timing-learning fields under `legacy/`. | +| Journal | Import complete check-ins into revision-checked `journal_entries` and synchronize comfort feedback; retain the original file under `legacy/`. Preserve the source date zone and unknown creation times. Import time is bookkeeping, not an observed sleep night. See [Morning journal](MORNING_JOURNAL.md). | +| Biometrics | Verify original SQLite integrity and table columns; retain it byte-for-byte. Build a separate source-labelled query snapshot for Sleep → History and `/api/v1/history`. Do **not** merge old five-minute interval-start HR/HRV/respiration averages into Core's live metrics under different definitions. See [Imported history](IMPORTED_HISTORY.md). | +| Legacy capacitance | Preserve channel means/std as **pending** profiles. Free Sleep used summed standardized deviations; Core uses a raw-unit threshold. No threshold/quality score is guessed. Tagged newer formats require a separate adapter. | +| Transient overrides | Retain originals and report unexpired overrides; no equivalent Core override is invented. | +| Credentials/integrations | Retain privately for recovery; do not provision owner access, MQTT or HomeKit from the archive. Their Core integrations start disabled. | + +## Gates that still prevent cutover + +1. Qualify attended operational rollback while retaining the working + installation and service/SSH/network configuration. The companion Free Sleep + recovery tool now has an off-host file-restoration rehearsal for 45,126 + installation entries and 70 Bluetooth-state entries, with unchanged running + service process IDs. That is not proof of runtime recovery or a boot image. + See the [recovery runbook](https://github.com/lucaandrea/free-sleep/blob/b46582a/scripts/migration/RECOVERY.md). +2. Qualify overnight editing in native clients against the versioned bedtime + contract below. The web editor now preserves the complete starting-night + routine, including independent power times and power-only routines. Existing + calendar-only sides retain the original curve editor. A mixed calendar/bedtime + side fails closed for review; the app does not guess ownership or convert it. + Missing offsets on legacy calendar APIs retain their calendar-day default. +3. Review enabled alarm behavior: Core adds temperature warm-up and a + post-alarm hold that Free Sleep's alarm executor does not provide. The + importer reports this difference; it does not claim behavioral equivalence. +4. Resolve active temporary overrides, intersecting routines and historical + API compatibility using the preserved source data. Recheck current settings + at cutover; this candidate is not a synchronized live replica. +5. Validate legacy occupancy against actual empty/occupied readings, then + qualify thermal operation, attended base/Stop and vibration/cancel. +6. Run and review at least seven actual Preview nights before bounded active + personal comfort. Imported records and synthetic tests do not count. + +## Versioned bedtime API + +Authenticated `GET /api/v1/routines?side=left` returns `version: 1`, `side`, +`canCreate`, and `routines`. Each routine contains its starting `dayOfWeek`, +opaque `revision`, and complete `draft`: + +```json +{ + "power": { "onTime": "22:00", "offTime": "07:00", "onTemperature": 75, + "enabled": false, "endAction": "turn_off" }, + "temperatures": [], + "alarm": null, + "notes": "" +} +``` + +`POST /api/v1/routines` accepts `{side, changes}`. Every change has +`dayOfWeek`, `expectedRevision` (the loaded revision, or `null` for an empty +night), and `replacement` (a complete draft, or `null` to delete). A move sends +both the original-night deletion and destination-night replacement in one +request. Every selected original is compared **before any write**; a conflict +returns HTTP 409 and rolls back all changes. Clients must keep the original +revision with the draft rather than replacing it on background refetch. + +The transaction validates the resulting week, rejects overlapping enabled +power windows and duplicate events, and compiles following-morning phases and +alarms to the next calendar day. Enabled phases must be strictly inside the +power window. The parent power toggle gates its phases and alarm while retaining +their individual preferences. New web routines start paused. Explicit wake +windows, intensity, pattern, duration and alarm temperature remain reviewable; +firmware acknowledgments do not prove that vibration occurred. + +The response separates committed data from `schedulerApplied`. If registration +fails, the save remains committed and clients must show that condition rather +than retry with stale revisions. A successful response is not evidence of a +physical hardware action. Existing controller diagnostics/reload handle job +reconciliation. During initialization, an older save cannot register rows +already replaced by a newer transaction. Existing calendar writers check +ownership inside their own transaction and reject partial routine changes with +HTTP 412. Direct out-of-band row changes are detected on the next routine read. + +This contract is additive. Native and ESP32 owners should use it for imported +bedtime routines; the old calendar mutation API intentionally refuses those +rows. Native history contract adoption, actual Pod qualification +and seven Preview nights remain separate gates. + +## Verification + +```sh +uv run --project scripts/migration --frozen python -m unittest discover -s scripts/migration -p 'test_*.py' -v +``` + +The suite uses synthetic temporary backups and the installed Node/Drizzle +runtime. It checks overnight/week-wrap mapping, disabled events, learning +cursors/manual pause, original journal/metric preservation, private archive +handling, unsafe/corrupt input, existing-target protection, cleanup, migration +ledger resumption and the default for existing Core rows. The suite runs in +CI's Unit Tests job and the pre-push hook. Core behavioral tests additionally +cover scheduler cron registration, DST Preview boundaries, individual/batch +API preservation and demo parity. + +An initial 25 October 2026 offline rehearsal against the owner's saved backup +produced 14 power rows, 14 alarm rows, zero temperature phases, six tap gestures, +two default-Off comfort states and zero check-ins. All power/alarm rows remained +disabled. The source snapshot contained zero vitals/movement/sleep records; +this says nothing about later or Apple Health history. Both legacy baselines +were preserved as pending. The source archives matched their manifest hashes; +no Pod service or hardware setting changed. + +A later rehearsal of the installation-recovery snapshot used the new bedtime +contract and verified all 14 complete routines against their source preferences. +That newer source contained **one enabled power routine**, zero temperature +phases and zero enabled alarms. The candidate preserved those values exactly; +it was never started as a controller. Both sides passed ownership verification +and SQLite integrity passed. This is separate from the earlier all-disabled +snapshot and does not change the live Pod. + +The bedtime feature passed 16 synthetic importer tests (including the actual +TypeScript ownership reader), transactional/router and editor regressions, and +an isolated production-browser workflow with authentication, persisted moves, +concurrent-edit rejection and a 390-pixel layout. The browser uses disabled +synthetic routines, not the owner's candidate. diff --git a/docs/HOMEKIT_REPORTED_STATE.md b/docs/HOMEKIT_REPORTED_STATE.md new file mode 100644 index 000000000..1dfd4433d --- /dev/null +++ b/docs/HOMEKIT_REPORTED_STATE.md @@ -0,0 +1,84 @@ +# HomeKit reported state and availability + +The existing `Bed left` and `Bed right` thermostat accessories now require a +healthy, post-write DAC observation younger than fifteen seconds. Missing, +invalid, future-dated, disconnected or expired readings return HAP +`SERVICE_COMMUNICATION_FAILURE` (`-70402`). A missing temperature is never +replaced with a neutral 82.5°F value, and missing power/mode data is never +reported as OFF. + +```mermaid +flowchart LR + Report[Fresh post-write DAC observation] --> Values[Per-field thermostat readings] + Missing[Missing or expired observation] --> Unavailable[HAP unavailable] + Values --> Fault[Combined fault status] + Unavailable --> Fault + Pump[Existing pump-stall notice] --> Fault + Request[Explicit HomeKit command] --> Controller[Existing serialized controller] +``` + +## Reported values and requests + +- Current temperature uses the fresh reported current-temperature field. + Current heat/cool/off mode is derived from the **current** level, rather than + the target level. A requested direction does not become current mode merely + because a command was accepted. +- These temperatures retain the existing DAC level-to-Fahrenheit conversion. + They are not independent mattress/water thermistor measurements. The derived + current mode is a firmware-level indication, not physical heat-flow proof. +- Target mode uses the fresh reported target level. An acknowledged power command can + support a queued slider gesture for at most fifteen seconds, but is never + substituted for reported mode on HomeKit reads. +- A target explicitly staged while the side is OFF remains available as a + **requested target**. It never supplies current temperature or evidence of + power. A default power-on value is not displayed as a report. +- Null, non-finite and out-of-range fields fail individually. For example, a + confirmed OFF mode can remain readable while temperature is unavailable. + The service fault indicates that at least one required reading is missing. + +## Freshness and faults + +Reads use `DacMonitor.getFreshStatus`, which rejects observations overlapping +or preceding hardware writes. Status events are triggers to reread that cache; +their payloads are never treated as fresh merely because an event arrived. +Disconnect events withdraw values immediately. A one-second timer withdraws +expired values even without another incoming event; direct reads enforce the +exact fifteen-second boundary. Stopping the accessory removes both event +subscriptions, its timer and its fault contribution. + +Telemetry and pump-notice faults share one `StatusFault` characteristic. Each +contributes independently, including on direct reads. Fresh telemetry cannot +clear a pump warning; clearing a pump warning cannot make stale telemetry +healthy. No leak accessory, new identity or pairing reset is introduced. + +Commands retain serialization, temperature bounds, pump guard and manual pause. +A slider change requires a fresh active session (non-neutral target plus a +positive countdown) or a recent acknowledged write with no intervening hardware +command. This replaces the unbounded intended-power latch. A fresh OFF report +wins immediately; a retained target with zero countdown cannot restart heat. +The remaining reported countdown can shorten that window. Rapid slider changes +cannot renew either deadline. The controller +rechecks this evidence after connecting and before persisting a manual hold. + +An unavailable report rejects the slider's hardware push while retaining its +explicit requested value for a later deliberate ON. An explicit ON can still +use that staged target or the existing 75°F default; OFF remains available even +with missing telemetry or a tripped guard. Failed writes revoke their temporary +command authority. No acknowledgment is presented as physical confirmation. + +## Validation and deployment boundary + +Tests use real HAP characteristics, the production DAC monitor with simulated +hardware responses, and the existing side-controller regression suite. They +exercise startup, expiry without events, clock reversal, disconnection, +intervening/delayed writes, recovery, independent faults, cleanup, off-side +staging, rejected commands and explicit power-off with unavailable status. +Write tests also cover stale/invalid countdowns, retained targets, concurrent +AUTO/slider and OFF gestures, expiry without renewal, intervening commands, +failed writes and evidence loss during connection establishment. + +The bridge identity, enabled/status/unpair API contracts, native occupancy wire +contract and MQTT settings are unchanged. This is source validation. Actual +Apple Home presentation and controls—including an OFF side with unavailable +temperature—still require qualification on the deployed bridge. No owner +pairing, household automation or live Pod control was changed by these tests. diff --git a/docs/HOME_AUTOMATION_PLAN.md b/docs/HOME_AUTOMATION_PLAN.md new file mode 100644 index 000000000..0500dff60 --- /dev/null +++ b/docs/HOME_AUTOMATION_PLAN.md @@ -0,0 +1,288 @@ +# Sleepypod + House Control: personal nights, coordinated mornings + +Research and implementation plan — October 29, 2026. No new house action in this +document has been enabled. Proposed defaults below are design choices, not claims +about currently installed automations or measured sleep benefit. + +## What stays in charge + +The Pod owns temperature, local routines and wake vibration. Home Assistant on the +Lenovo coordinates household devices. A personal iPhone Shortcut handles actions +that belong to that phone, such as Endel and selected Clock alarms. The web/iOS +app shows one reviewable plan, with separate acknowledgments for each participant. + +```mermaid +flowchart LR + Pod[Pod: presence, routines, alarms] -->|fresh MQTT observations| HA[Home Assistant] + HA --> Lights[Approved Hue / Govee lights] + HA --> Notice[Personal notification] + Notice -->|owner taps| Shortcut[iPhone bedtime / wake Shortcut] + Shortcut --> Endel[Endel Sleep] + Endel -->|AirPlay| HomePod[Bedside HomePod minis] + Shortcut --> Clock[Selected Clock alarm] + App[Sleepypod web / iOS] --> Review[Review one-morning plan] + Review --> Pod + Review --> HA + Review --> Shortcut +``` + +The LLM may explain a night or suggest an experiment. It does not bypass identity, +freshness, manual pauses, water/priming guards or the temperature controller. A +room sensor is additional context, not a second controller writing temperatures. + +## Current baseline and the important gaps + +- Luca is currently the only sleeper. Both thermal zones follow his logical Left routine; + the solo sensor mode combines zones into a single logical recording. This is + not biometric identification and does not yet prove side/middle accuracy. +- His saved routine is Sunday–Thursday bedtime at 22:00, overnight target 70°F, + Monday–Friday warming toward 105°F from 06:30 and vibration for 20 seconds at + 07:00. These are Luca's preferences, not defaults for Marissa. +- The existing scheduled vibration command still targets **Left**. Temperature + following does not mirror wake vibration. Feeling the alarm from Right or the + middle has not been qualified; preserve the current ordinary wake and check + routing separately before promising wake coverage across the bed. +- The Pod's saved weekend alarms are **disabled**. Luca's usual 08:00 weekend + alarm may be in Clock or a light app; its actual source must be inventoried. +- MQTT has been connected and publishing. Pod actuator commands over MQTT remain + disabled. New automations need a current entity/freshness check before activation. +- Personal adaptation remains Preview. The solo arrangement needs its own physical + qualification and reviewed nights; prior observations were not relabeled. +- Marissa is not an active sleeper. In roughly six weeks, an explicit reviewed + transition will put her on Left and Luca on Right. The date alone changes nothing. +- Homebridge and Apple Home pairings stay intact. A device is exposed through one + chosen bridge to avoid duplicate accessories and competing automations. + +## What the platforms actually support + +| Piece | Verified documentation | What still needs an on-device check | +|---|---|---| +| Dim lights | Home Assistant light actions accept brightness and transition. | Which of the home's lights are reachable and brightness-capable; 10% may be represented differently by hardware. | +| Endel Sleep | Endel documents Siri Shortcuts and AirPlay playback. | Exact installed Sleep action, lock-state behavior, subscription availability and selected speaker route. | +| Launch a personal Shortcut from HA | The Companion app supports a notification which opens a Shortcut **when tapped**. | Whether a supported personal trigger on this iPhone can make the chosen flow hands-free. MQTT occupancy alone is not such proof. | +| Ask a spoken question | A user-invoked personal Shortcut can combine voice/output and input actions. | Where voice plays, whether the phone must unlock, and interruption/cancellation behavior. A server cannot arbitrarily summon Siri into a conversation. | +| Read/edit existing Clock alarms | Apple's Shortcuts release notes document Find Alarm, Delete Alarms and skip/unskip of the next Sleep alarm. | The exact available actions for the chosen regular alarm versus Health Sleep Schedule, and reversible one-morning edits. | +| App-owned alarms | AlarmKit lists alarms belonging to its current client. | It does **not** grant access to every alarm already in Apple's Clock app. | +| Hue | HA imports Hue scenes; V2 bridges push local state changes. | Whether the specific existing wake automation can be read/changed through the bridge. Scene activation is not schedule editing. | +| Govee | The public API controls reported device capabilities. | No verified general endpoint here edits arbitrary Govee Home schedules. Device brightness access does not establish wake-automation access. | + +Sources: [HA light actions](https://www.home-assistant.io/integrations/light/), +[Endel settings and Siri Shortcuts](https://endel.zendesk.com/hc/en-us/articles/360014879579-Settings-What-s-Inside), +[Endel AirPlay/background playback](https://endel.zendesk.com/hc/en-us/articles/8084345471900-How-to-listen-to-Endel-simultaneously-with-other-audio-applications), +[HA personal Shortcut handoff](https://companion.home-assistant.io/docs/integrations/siri-shortcuts/), +[Apple Clock actions](https://support.apple.com/en-gu/111098), +[AlarmKit ownership](https://developer.apple.com/documentation/alarmkit/alarmmanager/alarms), +[Hue integration](https://www.home-assistant.io/integrations/hue/), +[Govee device capabilities](https://developer.govee.com/reference/control-you-devices). + +## Flow 1: “I'm in bed” after 10 p.m. + +### Trigger and permission + +Use fresh, qualified occupancy continuously present for a proposed two minutes in +the 22:00–04:00 night window. This delay reduces a quick sit-down becoming bedtime. +If someone is already in bed at 22:00, evaluate the same conditions at that boundary. +Do not treat broker reconnect, retained MQTT packets, unknown/offline state or a +server restart as a new arrival. Persist the night's execution marker so restarts +cannot repeat the scene. + +In solo mode, an occupied zone means “someone in Luca's bed,” not a proven identity. +Only apply Luca's opted-in personal flow while this sole-sleeper arrangement is +valid. After Marissa joins, shared audio/all-house lighting requires a jointly +approved scene and protection for someone still awake. A bedside action or phone +confirmation disambiguates who wants a personal flow. + +### Exact proposed sequence + +1. Snapshot the approved light entity IDs, availability, on/off state, brightness + and revision/last-change time. Include lights currently on; exclude off, + unavailable and non-dimmable lights. Do not use a scene that turns other lights on. +2. Fade selected lights to 10% over about 15 seconds. Proposed comfort exception: + leave a light already below 10% at its lower brightness. Recheck each target + immediately before writing; a lamp switched off during the fade stays off. +3. Leave manual adjustments alone afterward. Do not keep forcing brightness back + to 10%. Provide “Undo bedtime lights” for a short window, restoring only targets + still at the values this scene set. Do not restore over a person's later change. +4. Send Luca a “Start bedtime audio” notification tied to this specific night. + Tapping launches the personal Shortcut. The notification expires, rather than + replaying tomorrow when the phone reconnects. +5. The Shortcut selects the tested bedside AirPlay destination, sets that route's + media volume to 35%, then starts Endel's Sleep soundscape. Confirm route and + volume; do not accidentally change ringtone/alarm volume. If the route cannot + be confirmed, stop and show a message instead of playing loudly on the phone. +6. Show separate results: lights applied/skipped, audio started/awaiting tap/failed. + The notification being delivered is not proof that Endel is playing. + +If the two HomePod minis form a stereo pair, use that named pair. If they are +separate AirPlay destinations, validate multi-speaker selection and volume before +promising both. Endel's optional background mode changes lock-screen controls and +can keep audio playing alongside calls; leave it off unless that behavior is wanted. +Endel albums on Apple Music are a possible alternate scene, but are prerecorded +and do not replace the requested adaptive Endel app session. [Endel explanation](https://endel.zendesk.com/hc/en-us/articles/4940341254290-What-is-the-difference-between-the-soundscapes-in-the-app-and-Endel-albums-on-the-streaming-platforms) + +### Acceptance + +Test ten representative lights before expanding to 40–50. Test an off lamp, a +lamp below 10%, an unreachable lamp, a physical switch during the fade, repeated +occupancy, HA restart and MQTT reconnect. Verify the actual speakers and volume +with Luca present. Test locked/unlocked iPhone, a phone call, failed AirPlay and +cancelled Shortcut. Nothing should repeat or turn itself back on afterward. + +## Flow 2: Friday/Saturday “What time am I waking up?” + +### Trigger + +Use a bedtime-night identifier in the Pod's timezone: Friday night remains Friday +for a 01:30 Saturday arrival; Saturday night includes early Sunday. Proposed late +window: 23:00–04:00. Use one fresh, sustained occupancy event per bedtime night, +with an “Ask again” action. A general calendar-day check would incorrectly miss +or misclassify these arrivals. + +Until hands-free personal execution is proven, deliver a notification opening +“Review tomorrow's alarms.” Also expose a Siri phrase and bedside NFC/button as +optional explicit entry points. No microphone should listen continuously for this. + +### Review and voice + +The Shortcut obtains a fresh, exact next occurrence from the Pod and reads only +Clock alarms the user selected during setup. Distinguish regular Clock alarms +from a Health Sleep Schedule wake-up. If there are several alarms, enumerate them +instead of silently choosing the first. Missing access is “not checked,” never +“no alarm.” Share wake times aloud only with that person's consent. + +Example when sources really report these values: + +> “Your bed has no alarm tomorrow. Your selected iPhone alarm is at eight. Your +> wake-up lights still need checking. Keep these, or choose a different time?” + +Offer Keep, 30 minutes later, 60 minutes later, Choose time and Cancel. Read back +the actual date/timezone and every affected participant before confirmation. +Do not invent an 08:00 Pod alarm based on a general preference. + +### One-morning transaction + +Use the existing Pod exact-occurrence override/skip/restore API. Store a new +coordinator record with household/account/sleeper IDs, assignment revision, wake +date, desired time, request ID, expiry and per-provider original state/revision. +The coordinator does not own the underlying recurring weekly routines. + +```mermaid +stateDiagram-v2 + [*] --> ReadSources + ReadSources --> NeedsReview: missing or conflicting alarm + ReadSources --> Preview: sources current + NeedsReview --> Preview: user resolves sources + Preview --> Cancelled: cancel or expiry + Preview --> Applying: explicit confirmation + Applying --> Complete: every selected participant confirmed + Applying --> Partial: failed or unknown result + Partial --> Reconcile: inspect actual provider state + Reconcile --> Complete + Reconcile --> Restored: only confirmed safe compensation + Complete --> [*] + Restored --> [*] + Cancelled --> [*] +``` + +- **Pod:** shift warm-up with the chosen wake occurrence, preserving the selected + lead time and vibration settings; protect manual pause and stale assignment. + If the reviewed wake date has no enabled Pod alarm, offer “keep the bed alarm + off” or an explicitly reviewed one-time alarm for that date. Do not move the + following Monday's alarm into Saturday, enable a dormant weekend routine, or + present the next-occurrence edit API as a one-time alarm creator. A new dated + alarm needs its own durable creation/cancellation API and acceptance tests; + keep that choice unavailable until those exist. +- **Clock:** use a proven Shortcut action for the selected alarm. If regular Clock + lacks a reversible one-occurrence time change, preview a temporary one-shot + alarm plus suppression/restoration of the original only after that sequence is + tested. For a Health Sleep Schedule, do not assume skip/unskip also changes time. + A manual Clock step remains visible until verified, never silently “completed.” +- **Hue/Govee:** identify each actual wake automation first. If its scheduler is not + accessible, either leave a manual checklist or explicitly migrate that one wake + routine into HA, with original settings backed up and duplicate behavior disabled + only after the replacement is verified. Do not delete all vendor automations. +- **Failures:** retain durable per-provider planned/applied/failed/unknown/reverted + receipts. Reconcile unknown results before retrying. Do not roll back someone + else's newer manual edit. Explain partial success immediately and at wake-up. +- **Afterward:** expire the override and restore only the reviewed original state. + Recovery must survive phone/HA restarts. Never rely on a long in-memory delay for + restoring a recurring alarm. [HA execution/restart semantics](https://www.home-assistant.io/docs/automation/which-tool-to-use/) + +### Acceptance + +Test Friday 23:30, Saturday 01:30, Saturday 23:30 and Sunday 01:30; a DST transition; +an empty weekend Pod schedule; multiple Clock alarms; cancelled voice input; phone +locked/offline; provider response lost after success; partial Hue/Govee failures; +and a manual change during recovery. Confirm each provider's next occurrence and +the following week's original routine after the test. + +## Original requests plus an expanded automation library + +All new entries start disabled or in Preview. Each card shows its owner, trigger, +affected people/devices, data freshness, explanation, last outcome and a Stop/undo +path where applicable. + +| Priority | Automation / Shortcut | Trigger and useful behavior | Guard or dependency | +|---|---|---|---| +| 1 | Bedtime lights | After 22:00 sustained presence; dim only currently-on approved lights. | Manual changes win; partner/guest protection; once per night. | +| 1 | Endel bedtime audio | Start tested Sleep action, bedside AirPlay at 35%. | Personal Shortcut; route confirmed; tap fallback. | +| 1 | Review tomorrow | Late Friday/Saturday arrival; announce actual selected alarm sources and ask. | Personal consent; distinguish disabled/unknown; date-aware. | +| 1 | Sleep in once | One-morning Pod/Clock/wake-light plan with preview and receipts. | Per-provider capability check; no silent partial success. | +| 1 | My side off / I'm too warm | Explicit button or Siri phrase stops/cools the authenticated person's assigned zone. | Resolve “my” at execution; no hard-coded Left after side swap. | +| 1 | Morning check-in | After waking, ask warm/cold/comfortable and optional rested feeling. | Personal record; no fabricated sleep stages or clinical claim. | +| 2 | Wind down | Optional pre-bed reminder and approved bedroom shade/light scene. | Respect guests, manual shade settings and household quiet preferences. | +| 2 | Nighttime path | Bed exit plus hallway motion creates a dim path to the bathroom. | Low light, limited area; do not turn the whole house on; cancel if returned. | +| 2 | Coordinated waking | Existing Pod wake occurrence optionally fades selected lights and opens approved shades. | No duplicate vendor wake routines; do not disturb sleeping partner without consent. | +| 2 | Empty-bed response | Sustained absence suppresses dependent room actions and follows the approved Pod absence policy. | Absence is not proof of waking; brief bathroom trips do not reset the night. | +| 2 | Room-aware comfort | Fresh bedroom temperature/humidity informs bounded comfort proposals. | Existing Pod arbitration only; stale data withdraws the input. | +| 2 | Humidity support | Sustained validated room readings prompt or control an eligible humidifier. | Confirm model capabilities, water state and owner-selected bounds; power-only devices cannot promise humidity targets. | +| 2 | Quiet air cleaning | Use supported purifier fan settings before bedtime; quiet mode during occupied sleep. | Read actual discrete levels; manual preference and unavailable air-quality sensors respected. | +| 2 | Quiet house / partner asleep | Protect shared audio, bright lights and whole-bed motion when the other sleeper is resting. | Presence is not identity; explicit profile/side assumptions and shared consent. | +| 2 | Morning room report | Combine overnight room conditions, measured/estimated sleep and personal feedback. | Keep sources separate; no causal claim from correlation. | +| 3 | Travel / away | Pause the person's approved routines and remind them to restore before return. | Never infer both people away from one phone leaving. | +| 3 | Data stopped arriving | Notify once when a mapped sensor/publisher goes stale; explain withdrawn features. | Avoid repeated alerts; restore only after fresh readings. | +| 3 | Low water / maintenance | Surface actual Pod water/priming issues, with manual maintenance help. | No automatic priming while someone is sleeping. | +| 3 | Base presets | Explicit personal request for a qualified whole-bed preset. | Attended head/feet/Stop qualification, both sleepers affected. | +| Research | Snoring elevation | A trusted snoring signal may propose a small head adjustment. | Snoring signal and movement/Stop not yet qualified; unavailable until both pass. | +| Research | Named vibration alerts | Distinguishable approved wake/notification cues with expiry. | Actual Pod 4 pattern/intensity/cancellation tests; wake priority over alerts. | +| Research | Music vibration | User-started local audio experiment with bounded slow cues. | Firmware timing must be measured; not promised beat-sync or HomePod audio capture. | + +## Two-person behavior and privacy + +Create separate personal credentials and persistent sleeper IDs. Preferences, +history, Health consent, learning and private notifications follow the person, not +the side label. Marissa gets no inherited 105°F preference or Luca history. Changing +sides closes the old dated assignment before opening a new one; mixed intervals +stay ambiguous. Local device/account changes cancel background imports and clear +visible caches. Shared wake announcements and room actions require separate opt-in. + +The Pod cannot recognize faces or infer which partner is present from occupancy +alone. In two-person mode, assigned zones are a declared attribution assumption. +Temporary side swaps/guests must be explicit and must suppress unreliable personal +training/Health export. Whole-bed base movement stays a shared control. + +## Delivery order and checks + +1. **Inventory and preserve:** read the actual HA entities, phone actions, selected + Clock/Sleep alarms, Hue schedules and Govee automations. Export originals and + record ownership. Do not scan or export secrets into the plan. +2. **Identity first:** finish private account/device flows and dated person-to-side + assignments. Keep solo mode until Marissa explicitly joins and activation is + reviewed. Test cross-account access, revocation, stale requests and side moves. +3. **Dry-run house rules:** replay occupancy/time/light snapshots and display the + exact proposed targets. Add persistent once-per-night markers and expiry. +4. **One explicit bedtime Shortcut:** prove Endel, AirPlay, 35% media volume and + cancellation with the actual phone/speakers. Only then connect its notification. +5. **One-morning wake review:** read sources first; build the confirmation screen, + provider adapters and durable compensation. Test with temporary alarms before + using the real next morning. Keep unknown provider support visibly unavailable. +6. **Incremental activation:** approve lights first, then audio, then one-morning + coordination. Record physical results. Add room/partner automations individually. +7. **Failure rehearsal:** disconnect phone, MQTT, internet and Lenovo separately in + a separately agreed attended window. Pod schedules remain local. Do not change + tomorrow's normal wake-up to conduct this test. + +Tomorrow, Friday October 30, the check-in is **after waking**. The prepared 07:15 +follow-up reviews the ordinary night and wake logs. No pre-wake network isolation +or extra vibration is scheduled, and ordinary success will not be recorded as an +offline/cancellation qualification. diff --git a/docs/HOME_TELEMETRY.md b/docs/HOME_TELEMETRY.md new file mode 100644 index 000000000..dd4099d08 --- /dev/null +++ b/docs/HOME_TELEMETRY.md @@ -0,0 +1,88 @@ +# Qualified home telemetry + +Home integrations must distinguish an unavailable sensor from a measured zero, +an empty bed, or a healthy device. `src/telemetry/` reads existing sources only; +it never starts a controller or sends hardware commands. + +```mermaid +flowchart LR + Live[Live firmware frames] --> Cache[Bounded live sensor cache] + DAC[Fresh reported DAC poll] --> Samples[Timestamped samples] + Cache --> Samples + Vitals[Live vitals with mechanical proof] --> Samples + Presence[Calibrated capacitance and movement] --> Samples + Samples --> MQTT[MQTT and Home Assistant] + Cache --> Ambient[HomeKit ambient sensor] + Presence --> Occupancy[HomeKit occupancy sensor] +``` + +## Measurement contract + +MQTT JSON state payloads carry `schemaVersion: 1`, `ts` (source epoch ms), +`expiresAt` (epoch ms), `source`, `available`, and `reason`. Unavailable values +are null; reasons are `missing`, `stale`, or `unqualified`. A fresh packet can +still contain a null field for a sensor that particular firmware does not supply. +Consumers must require availability, a non-null field and unexpired time. + +| Source | Expiry | Meaning | +| --- | --- | --- | +| DAC status | 15 seconds | Successful poll start time; connected monitor, healthy poll and no intervening write required | +| Ambient temperature / humidity | 120 seconds | Live bedTemp or bedTemp2 event time; legacy units converted | +| Pump RPM / loop temperature | 30 seconds | Live frzHealth event time; missing branches remain null | +| Occupancy | 30 seconds | Fresh calibrated capacitance plus shared movement logic; occupancy is not sleep | +| Vitals | At most 5 minutes, further limited by current mechanical context | Qualified live processing run with firmware-event time and full quiet-window proof | +| Pump stall warning | 15 seconds | Current software notice presence, not measured pump health or a leak diagnosis | + +Startup backlog, future records and old/duplicate environmental or pump records +cannot refresh the live cache. Shutdown clears live observations. Historical +SQLite readings are retained for analysis but are not promoted to fresh home +telemetry. Unknown or unconfigured base telemetry currently withholds new +mechanically qualified live vitals, including on this owner's unqualified base. + +Beat-interval variability retains the existing `hrv` field/entity identifier for +compatibility, with `variabilityMetric: window_ibi_variability_v1` and a clearer +entity name. It is not Apple Watch RMSSD. DAC temperatures are reported firmware +level-derived values, not independently measured cover temperature. The climate +`heat` mode means active thermal control (which may cool); it preserves the +existing MQTT command contract. Null off-state temperatures are never replaced +with a neutral number. No vibration or movement is claimed physically verified. + +## MQTT and Home Assistant + +State publishes every five seconds and on device-status events. Device-status +notifications trigger a fresh source read; optimistic WebSocket overlays are +never forwarded. Measurement payloads and execution receipts are non-retained. +Availability and discovery remain retained. Reconnect clears old retained state +while offline before publishing online and current measurements. Unsupported +clog discovery and retained state are explicitly removed. + +Discovery combines broker availability with per-field source freshness. Sensors +and binary sensors expire after 15 seconds without a publication. Climate does +not support `expire_after`: its source availability is reevaluated on publication, +and a lost MQTT connection is subject to the broker's keepalive/LWT delay (client +keepalive 30 seconds). These entities are not safety interlocks. A Home Assistant +restart waits for the next live publication rather than replaying retained data. + +See the official [MQTT sensor](https://www.home-assistant.io/integrations/sensor.mqtt/) +and [MQTT climate](https://www.home-assistant.io/integrations/climate.mqtt/) +availability and null-value contracts. Generic consumers of the former raw +`pump//stall` string must migrate to the JSON `active` boolean. The removed +clog detector had no implementation; no absence-of-clog claim replaces it. + +## HomeKit and acceptance + +The ambient accessory uses the same live cache. Missing, invalid or expired +readings return HAP communication failure and StatusFault, rather than an invented +20°C or an indefinitely cached value. Occupancy also faults when calibration or +fresh data is unavailable. Stable accessory names and subtypes are preserved. +Thermostats now also enforce [fresh reported state](HOMEKIT_REPORTED_STATE.md), +with explicit staged targets kept separate and independent fault contributions. +Actual Apple Home pairing, presentation and controls still need end-to-end qualification. + +Tests cover actual streaming dispatch/backlog/shutdown, both environmental wire +formats, finite/sentinel/missing fields, source expiry, calibrated occupancy, +real migrated SQLite mechanical proofs and invalidation, real HAP reads/faults, +and MQTT lifecycle/retained-state cleanup. Local automated checks do not qualify +physical sensors, Apple Home pairing, a live broker or household automations. +All of those deployment gates remain pending; no home automation is enabled by +this source change. diff --git a/docs/HOUSEHOLD_CONTRACT.md b/docs/HOUSEHOLD_CONTRACT.md new file mode 100644 index 000000000..f1ed86fe0 --- /dev/null +++ b/docs/HOUSEHOLD_CONTRACT.md @@ -0,0 +1,312 @@ +# Household accounts and person-owned sleep data + +Status: preparation v1 is implemented for validation; the later assignment and +personal-history sections remain the target contract, not deployed support. +The installed owner-only connection flow remains available while this feature is +built. Never invite a second person by sharing the owner's connection key. + +## Required behavior + +Luca is the sole current sleeper. The existing `solo-left` setting identifies the +logical source routine; it does not assert that his body stays on the left. +Both sensor zones feed that one sleeper's recording, and `unusedZoneMode=follow` +makes the other thermal zone follow the same routine. This live configuration was +explicitly requested on October 29. It does not establish new physical accuracy or +transfer old qualification records to the changed arrangement. +Temperature following does not copy scheduled vibration to the other zone: the +current alarm dispatcher targets the routine's source side. Right/middle wake +coverage remains unqualified and must not be inferred from thermal authority. + +See [the dated readiness record](HOUSEHOLD_READINESS_2026-10-29.md) for merged, +deployed and remaining release gates. The guide is a practice document, not a +claim that a second sleeper can already use live personal controls. + +Marissa is a planned future sleeper, not an active account or occupant. In roughly +six weeks, she accepts her own invitation and chooses her own settings. A separate +reviewed assignment transition places Marissa on Left and Luca on Right. There is +no automatic date-based switch and no copy of Luca's private data to Marissa. + +```mermaid +flowchart LR + Device[Personal device credential] --> Account + Account --> Sleeper[Stable sleeper identity] + Sleeper --> History[Private history and preferences] + Sleeper --> Assignment[Dated bed assignment] + Assignment --> Zone[Physical Left or Right zone] + Sensors[Sensor zones] --> Attribution[Known, ambiguous or unassigned] + Attribution --> History +``` + +## Identity and access + +- Stable identifiers distinguish Pod, household, account, sleeper and device. + Names and selected sides are display/configuration values, never authentication. +- Each device receives a revocable personal connection credential. The server + stores a digest, not the reusable credential. A short-lived, single-use household + invitation pairs a new device/account; no new cloud account is required. +- The existing owner key maps only to the existing owner's account. It cannot be + used as Marissa's invitation. An accepted member may prepare preferences before + receiving an active bed assignment, but cannot control an unassigned zone. +- Household administration and private Health access are separate capabilities. + Partner membership does not grant raw Health, exports, model training or private + history belonging to another sleeper. Shared wake-time announcements are opt-in. +- Member credentials are rejected by every legacy REST, tRPC, raw stream, archive, + model and backup surface. New person endpoints resolve `self` from credentials. + A persisted [personal privacy boundary](PERSONAL_PRIVACY_BOUNDARY.md) restricts + legacy owner paths once physical assignment or shared attribution is committed; old side-only clients must receive an explicit upgrade requirement + rather than expose the new occupant's history. +- Logout/account change cancels background work and clears visible caches. Every + import page/write batch rechecks account, sleeper, session and consent revisions. + +## Additive versioned API + +Advertise `householdIdentity.version = 1` through capabilities only when the +server implementation and route protection are ready. Preserve existing v1 shapes. +Preparation response/error schemas are frozen in `src/household/contract.ts`, with +`src/household/fixtures/preparation-v1.json` shared by web and native tests. + +### Preparation v1 shipping boundary + +`SLEEPYPOD_HOUSEHOLD_PREPARATION=1` enables this namespace; it defaults off. The +existing owner capabilities advertise `features.householdPreparation` with reason +`personal_workspace_drafts_only` when enabled. This is not the future +`householdIdentity` live-control capability. The current implementation includes: + +- Owner-reviewed setup against an already configured solo/follow bed, with stable + Pod/account/sleeper/device IDs. It does not change the physical configuration or + associate historical records automatically. +- Separate new-person or own-device invitations, expiring in 5–1440 minutes; + clients default to 30 minutes. A member can invite only their own devices. The + owner cannot issue an invitation into the other member's existing account. +- Revocable devices, account-derived personal preferences, and private weekly + routine drafts. Two active accounts maximum for this two-zone bed. +- Personal HTTP credentials are rejected by legacy APIs/streams; household rows + are excluded from legacy database browsing. Whole-database HTTP downloads are + disabled when preparation is enabled or household data exists; encrypted + administrator backups remain a separate operational responsibility. +- `activateArrangement`, `healthImport` and `cloudAnalysis` remain false. + `personalHistory` remains false unless its separately gated read API below is + enabled. `personalControls` remains false unless the separately gated + [personal runtime](PERSONAL_CONTROL_API_V1.md) is enabled and the current + arrangement is consistent. Preferences and drafts are not sent to hardware. +- A separate optional `personalRoutinePublication` capability enables reviewed + publication through [the personal routine API](PERSONAL_ROUTINE_API_V1.md). + It defaults off and does not change the existing draft-save semantics. +- Separately gated [private recording history](PERSONAL_HISTORY_API_V1.md) reads + only already-attributed person records. `SLEEPYPOD_PERSONAL_HISTORY` defaults + off; it does not enable continuous ingestion, Health import or legacy claims. + +The web preparation document is `/household/index.html`; the standalone interactive +tutorial is `/guide/marissa.html`. These are static documents outside the owner +React shell. A successful personal browser connection clears the legacy owner +cookie and notifies other tabs to unmount private owner views/subscriptions. + +### Frozen preparation transport + +All responses are private/no-store. Native requests use `Authorization: Bearer` +with their personal key, except explicit owner setup, which uses the existing +owner key. A personal key is never an owner-API fallback. Browser writes require +same-origin protection; browser sessions contain a signed expiring device ID and +resolve device revocation on every request. +Cookie-authenticated preference, routine, invitation and device mutations also +require `X-Sleepypod-Sleeper-Id` matching the current session. This prevents an old +tab from writing into a newly selected account even when both revision numbers +coincidentally match. Reads may provide the same header to detect account changes. +The personal cookie covers `/` so legacy browser routes can suppress delayed +owner login responses until an explicit owner reconnection. A persisted browser +scope stores only hashes of the browser identifier and current signed session. +Both personal and owner cookies must match it. Response reordering, including a +delayed explicit owner reconnection, cannot revive a superseded session. Logout +invalidates the persisted scope before clearing cookies; browser scope storage +failures deny access. Native Bearer credentials do not share browser state. +Browser setup/join/login/logout first reads `/api/auth/session` and sends the +returned `browserScopeRevision` in `X-Sleepypod-Browser-Revision` when present. +This compare-and-swap also rejects requests that arrive after a newer account +change. Newly scoped owner cookies carry a signed v2 discriminator and cannot +authenticate without their browser marker. Enabling household preparation requires +old unscoped owner browser sessions to reconnect; owner native Bearer is unchanged. + +| Route | Method | Input → response | +|---|---|---| +| `/api/v1/household/setup` | POST | `householdSetupSchema` → `householdSessionSchema` | +| `/api/v1/household/join` | POST | `householdJoinSchema` → `householdSessionSchema` | +| `/api/v1/household/session` | GET | personal credential → `householdSessionSchema` | +| same | POST | same-origin `{token}` → session + HttpOnly cookie | +| same | DELETE | same-origin browser logout → 204; native signs out locally | +| `/api/v1/household/invitations` | GET | `invitationsResponseSchema`, self-issued only | +| same | POST | `householdInvitationSchema` → `invitationResponseSchema` | +| same | DELETE | `revokeInvitationSchema` → `{revoked:true}` | +| `/api/v1/household/devices` | GET | `deviceResponseSchema`, self-owned only | +| same | DELETE | `revokeDeviceSchema` → `{revoked:true}` | +| `/api/v1/me/preferences` | GET / PUT | response / `updatePreferencesSchema` → `personalPreferencesResponseSchema` | +| `/api/v1/me/routines` | GET / PUT | response / `updateRoutinesSchema` → `personalRoutinesResponseSchema`, `draft_only` | + +Generate invitation and device secrets with a cryptographic RNG: 32 random bytes, +base64url without padding. The browser uses `getRandomValues` (including HTTP LAN +origins); native stores retained keys/attempts in Keychain. Invitations travel in +URL fragments, not query strings, and are removed from the address on opening. +The server stores only digests. Never log a request body or secret. + +Retain the exact setup/join request and generated device key until the outcome is +known. After a lost response, `GET session` with that same key reconciles success; +exact setup/join replay is safe. An already-claimed invite cannot pair a different +device. Invitation creation likewise takes a client-generated secret and request +ID, so retry can return the same still-pending invitation without storing its +reusable secret. Profile/draft writes have durable idempotency receipts and CAS. +Unknown results are not a reason to generate a different account or key. +The browser requires the user to save its generated personal key privately before +submitting setup/join; native retains it in Keychain first. Browser scope admission +and identity creation share one SQLite transaction, so a stale-scope conflict +cannot consume an invitation or leave an orphan account/device behind. + +The new session response includes: + +| Field | Meaning | +|---|---| +| `apiVersion`, `featureVersion` | Transport and household contract versions | +| `podId`, `householdId` | Stable connection destination | +| `accountId`, `selfSleeperId` | Credential-derived identity | +| `role`, `membership` | Household role and active/revoked state | +| `sessionRevision` | Invalidates stale account/credential contexts | +| `householdRevision`, `assignmentRevision` | Compare-and-swap boundaries | +| `capabilities` | Explicit allowed operations | +| `arrangement` | `solo-anywhere` or `assigned`, with effective time | +| `self` | Display name and current authorized physical zones | + +Full target routes (the table above identifies the implemented preparation subset): + +- `/api/v1/household/session`: current authenticated account/context. +- `/api/v1/household/setup`: owner-reviewed association of the existing installation. +- `/api/v1/household/invitations`: owner creates/revokes an expiring invitation. +- `/api/v1/household/join`: single-use claim; does not activate the two-sleeper bed. +- `/api/v1/household/devices`: list/revoke personal device credentials. +- `/api/v1/household/arrangement`, `/`, `/approve` and `/cancel`: + separately gated private proposal and independent approvals. See + [Shared side review v1](ARRANGEMENT_REVIEW_API_V1.md). These do not change the bed. +- A future activation coordinator must atomically change dated assignments, + routine ownership and actual zone configuration after quiescing command sources. + There is no public activation endpoint yet. +- `/api/v1/me/preferences`, `/routines`, `/sleep`, `/watch-references`, `/comfort` + and `/wake/next`: person-owned data. These full routes share the `/api/v1/me` + prefix. Read responses echo `sleeperId` and applicable revisions. +- `/api/v1/me/controls`: resolve authorized zones at execution, with existing + durable command receipts. Explicit physical targeting remains distinct from + “my” commands. Stop remains prominent. + +Mutations include a unique `requestId`, expected session/assignment revisions and +the resource's expected revision. A stale assignment fails with 409; a revoked or +unauthorized credential fails with 401/403. No silent redirect to another sleeper. +Preparation grants no authority to execute a stale action later. + +## Data, migration and learning + +- Person-owned preferences, feedback, routines, alarms, Health references and + models use immutable `sleeperId`. Dated assignments describe how physical-zone + recordings may be attributed. Moving sides never changes historical ownership. +- Preserve existing data and import identifiers. Legacy records remain explicitly + legacy/unassigned until the owner association is reviewed; never infer ownership + from a human-readable name or bulk relabel them for a new account. +- Raw zone observations remain separate from personal attribution. Solo-middle + occupancy must not double duration. Mixed/ambiguous readings are excluded from + personal Health export and model labels until their attribution is supported. + Sensors do not identify a person merely because a particular side is occupied. +- Person scope is enforced in queries, exports, comparisons, imports and training, + not just in navigation. Per-device local caches/models/receipts are namespaced + by household, account and sleeper. +- Imported Watch records remain with the phone owner's sleeper across side moves. + Preserve stable source-record deduplication and report inserted/unchanged/rejected + counts. Cloud-sharing consent remains per person and per destination. +- Changing assignment or sensor interpretation withholds incompatible adaptation + and qualification. Do not silently reuse Luca's learned policy for Marissa. + +## Dated attribution foundation (internal, not activated) + +### Personal command boundary (implemented, disabled by default) + +The public bed, command and receipt routes now use fresh database authority and +browser-scope validation. Their frozen contracts, synthetic fixtures, lifecycle +rules and remaining release gates are in [Personal control API v1](PERSONAL_CONTROL_API_V1.md). +They remain disabled on the live Pod. The reviewed assignment coordinator, +personal routine publication and complete legacy/shared privacy boundary are +still required before activating two-person operation. There is no shipping +endpoint to assign Marissa or move Luca automatically. + +### Dated observations + +`src/household/personHistory.ts` supplies the durable ownership boundary. +The separately gated [personal history API](PERSONAL_HISTORY_API_V1.md) exposes +its credential-scoped reads. Signup does not create attribution, and continuous +source ingestion and the assignment coordinator are still absent. This is tested +storage and read transport, not a claim that two-sleeper operation is ready. + +- A reviewed epoch binds a stable source dataset and half-open UTC interval to + distinct sleeper identities and recorded zones. `solo_merged` admits only the + logical merged source; an empty-side duplicate cannot add another night's time. +- Separate owner-reviewed legacy intervals preserve original recorded sides. + Signup never claims the earlier records. A new member cannot inherit them. +- Transitions close the preceding epoch and insert its replacement in one + transaction. Compare-and-swap revisions and retained review receipts reject + stale or changed retries. No admitted record may be moved across the boundary. +- Complete observations must fit wholly within one epoch. Gaps, overlapping + epochs, transition-spanning measurements, future/open records and unknown zones + are withheld. Attribution means reviewed arrangement, not biometric identity. +- Immutable minimal record snapshots keep stable person-record UUIDs across side + moves. A changed source is quarantined rather than silently relabelled or + overwritten. Overlapping presence records cannot double the person's duration. +- Reads derive self from the current personal credential, including device + revocation checks. Household owner status grants no partner-history access. + Bounded, signed cursors bind identity, revisions, filters and an ingestion + watermark. New records wait for refresh; corrections invalidate prior pages. + Cursors expire on the history singleton's restart. Results sort by observation + time, so a late legacy import does not displace newer nights. +- Presence, movement and Pod vitals retain distinct semantics. Presence is not + estimated sleep; the Pod variability metric is not Watch HRV. No Health import, + model training, raw export or cloud analysis is enabled by this layer. + +Before enabling the complete personal release, implement the reviewed assignment coordinator and source +adapters with stable dataset identities, explicit timestamp conversion and full +measurement-window provenance. Bind consent and current physical configuration +to the review receipt; never expose `appendReviewedEpoch` directly as an HTTP +mutation. Finish personal HTTP/native views and legacy-owner privacy gates before +dual activation. Invalidated source corrections need a separate reviewed repair +flow; this implementation deliberately cannot reclaim reserved source intervals. + +## One-morning wake coordination + +Reuse the existing exact-occurrence Pod override, skip and restore semantics. +A new coordinator previews the actual wake date/timezone, Pod warm-up/vibration, +selected Clock alarm and selected Hue/Govee wake occurrences. Confirmation applies +only the reviewed morning. Weekly plans remain unchanged. + +Every participant has its own `planned`, `applied`, `failed`, `unknown` or `reverted` +receipt. Reconcile an unknown outcome before retrying; partial success is visible. +Clock/Endel/audio routing is a separately tested personal iPhone Shortcut bridge. +AlarmKit only exposes alarms owned by its current app client; it is not a bridge +to existing Clock alarms. A Home automation is not proof that a personal iPhone +Shortcut can run unattended. + +## Acceptance gates + +1. Two personal tokens cannot read/import/export/train on each other's data through + either legacy or new routes, streams, archives or model endpoints. +2. Expired/replayed/revoked invites and device revocation during an import fail + without duplicate data or implicit scope fallback. +3. Marissa remains unassigned until explicit acceptance and reviewed activation. +4. Luca's Left-to-Right move preserves his history/preferences and gives Marissa + none of his old private history. Rollback retains original data and assignments. +5. Solo-middle and side-crossing sessions do not double duration; ambiguous data + stays visibly unassigned. Thermal-zone configuration is independently tested. +6. Stale account/assignment revisions reject commands and writes. Switching account + clears visible data and stops background imports/model work. +7. A one-morning change handles Friday/Saturday nights crossing midnight, DST, + disconnects, partial failures and restoration of the recurring plan. +8. Web/iOS account flows are tested independently; TestFlight installation and + physical bed acceptance are separate from software and fixture checks. + +## References checked October 29, 2026 + +- [AlarmKit](https://developer.apple.com/documentation/alarmkit) +- [AlarmManager.alarms: current client ownership](https://developer.apple.com/documentation/alarmkit/alarmmanager/alarms) +- [Apple Home automations](https://support.apple.com/guide/shortcuts/create-a-new-home-automation-apd2a290f633/ios) +- [Clock actions in Shortcuts](https://support.apple.com/en-gu/111098) +- [Home Assistant notification commands by platform](https://companion.home-assistant.io/docs/notifications/notification-commands/) diff --git a/docs/HOUSEHOLD_READINESS_2026-10-29.md b/docs/HOUSEHOLD_READINESS_2026-10-29.md new file mode 100644 index 000000000..faa87eb9f --- /dev/null +++ b/docs/HOUSEHOLD_READINESS_2026-10-29.md @@ -0,0 +1,119 @@ +# Household readiness — October 29, 2026 + +The interactive guide and researched automation plan are ready to review. Private +account preparation and the internal ownership/command boundaries are merged and +tested. **The complete two-person app is not yet released.** This dated record +separates that software from the working Pod and lists the remaining integration. + +- [Marissa's illustrated practice guide](../public/guide/marissa.html) +- [Home automation plan: 22 proposed automations and Shortcuts](HOME_AUTOMATION_PLAN.md) +- [Identity, transport and privacy contract](HOUSEHOLD_CONTRACT.md) + +## The bed today + +Luca is the sole sleeper. Live read-only checks confirm `solo-left` with the +unused thermal zone following. The sensor services report combining both zones +into one logical Left recording. “Left” is the current recording/routine source; +it is not a claim that Luca must physically remain on that half. Actual empty, +Left, Right and middle recognition still needs attended qualification under this +arrangement. Occupancy cannot identify a person. + +The saved thermal routine is Sunday–Thursday at 22:00, targeting 70°F overnight. +Weekday wake warming begins at 06:30 toward 105°F, with a 20-second vibration at +07:00. The existing vibration command targets Left; thermal following does not +establish vibration coverage on Right or in the middle. Weekend Pod alarms are +disabled. The next occurrence was read back as Friday October 30 at 07:00 Pacific. + +Marissa has no live account or assigned side. No calendar date changes that. There +is no new light, audio, base or notification automation enabled by this work. +Personal adaptation remains Preview; changes to the bed arrangement do not carry +over old physical qualification as if it applied to the new one. + +## Merged software, not a deployment claim + +| Change | Evidence | Current limit | +|---|---|---| +| [Core #63: private account preparation](https://github.com/lucaandrea/sleepypod-core/pull/63) | Local full suite: 8,375 passed, 19 skipped; browser: 34 passed; exact-head hosted checks passed. | Feature defaults off. Personal preferences and weekly drafts do not command the bed. | +| [Core #64: person-owned history](https://github.com/lucaandrea/sleepypod-core/pull/64) | 53 focused tests, 6,730 related tests passed; hosted checks passed. | Internal attribution/query service; no live ingestion or personal history route yet. | +| [Core #65: solo presence across zones](https://github.com/lucaandrea/sleepypod-core/pull/65) | 21 focused tests, 6,743 related tests passed; hosted checks passed. | Logical comfort-presence adapter; physical detection is not established by these tests. | +| [Core #66: person-bound command authority](https://github.com/lucaandrea/sleepypod-core/pull/66) | 115 focused tests; full related retry with two workers: 6,774 passed, 17 skipped; all seven exact-head hosted checks passed. | Internal service, no public control route or activation capability. | +| [Core #68: personal control API and atomic routine storage](https://github.com/lucaandrea/sleepypod-core/pull/68) | 140 focused tests; mandatory related suite: 6,817 passed, 17 skipped; production build and two actual household browser scenarios passed; all seven final hosted checks passed. | Runtime defaults off; two-person assignments in tests are fixtures. | +| [Core #69: personal routine publication](https://github.com/lucaandrea/sleepypod-core/pull/69) | Hosted unit suite: 8,505 passed, 19 skipped; hosted browser suite and all seven checks passed. Local: 151 focused and 6,845 related tests passed. | Separate publication flag defaults off; saved state and scheduler registration have separate receipts. | +| [Core #70: isolated personal web controls and routine editor](https://github.com/lucaandrea/sleepypod-core/pull/70) | 35 focused tests and six browser scenarios passed locally. Hosted: 8,540 unit tests passed, 19 skipped; 38 browser checks passed, plus both builds, lint and typecheck. Merge tree verified identical. | Controls and publication remain disabled on the live Pod; a joining member is still unassigned. | +| [Core #71: account changes during private responses](https://github.com/lucaandrea/sleepypod-core/pull/71) | Hosted: 8,565 unit tests passed, 19 skipped; 38 browser checks and all eight checks passed. Merge tree verified identical. | Response/stream access is rechecked; prior committed mutations and delivered bytes are not undone. | +| [Core #72: persistent personal privacy boundary](https://github.com/lucaandrea/sleepypod-core/pull/72) | Hosted: 8,593 unit tests passed, 19 skipped; 39 browser checks and all eight checks passed. Merge tree verified identical. | Legacy owner access and shared biometric output close after assignment; real HomeKit occupancy reports unavailable. No live flags enabled. | +| [Core #73: independent arrangement reviews](https://github.com/lucaandrea/sleepypod-core/pull/73) | Hosted: 8,630 unit tests passed, 19 skipped; 41 browser scenarios and all seven checks passed. Merge tree verified identical. | Each person approves their own settings privately. Approvals do not activate assignments. | +| [Native #47: personal account workspace](https://github.com/lucaandrea/sleepypod-ios/pull/47) | Hosted result: 1,010 passed, nine live-service tests skipped, zero failures. | Merged; not uploaded as a new TestFlight build. | +| [Native #48: reusable temperature presentation](https://github.com/lucaandrea/sleepypod-ios/pull/48) | All four hosted checks passed; 1,010 passed, nine skipped; six temperature UI checks and unsigned Release build passed. | Installed TestFlight remains unchanged. Personal runtime integration follows in #49. | +| [Native #49: isolated personal controls and receipt recovery](https://github.com/lucaandrea/sleepypod-ios/pull/49) | All four hosted checks passed; the native chat verified 1,042 passed, nine skipped, zero failures in the downloaded result. | Merged, not uploaded to TestFlight. Routine publication is being validated separately in #50. | +| [Native #50: personal routine publication](https://github.com/lucaandrea/sleepypod-ios/pull/50) | The native chat verified all four hosted checks and downloaded results: 1,071 passed, nine live-service skips, zero failures. | Merged, not uploaded. Native arrangement review is being validated separately in #51. | + +The first broad Core #66 run had five timeouts in four independent files. Those +files passed unchanged in an isolated two-worker run (57 tests); the complete +mandatory related suite then passed unchanged with two workers. No check or hook +was bypassed. These overlapping suites are not additive independent test counts. + +At this checkpoint the Pod remains on `51678cf349b52336b33665035b1f9cc077594925`. +The installed native release remains **0.4.2 (15)**. Merging is not proof of a +deployment, a TestFlight release, physical behavior or improved sleep outcomes. + +## What remains before Marissa can operate her side + +1. **Reviewed assignment activation.** The [side review](ARRANGEMENT_REVIEW_API_V1.md) + preserves the owner's actual operating week, uses the joining person's saved + draft, and requires separate private approvals. It remains inert even with + both approvals. Finish the coordinator that moves assignments and routine + ownership together, placing Marissa Left / Luca Right only after review. + Invalidate old queued commands, snoozes, manual holds and qualification under + the existing side locks. Joining alone must never perform this transition. +2. **Personal live controls and weekly alarms.** The filtered bed snapshot and + command API now resolve current account/assignment authority and recheck it + at hardware dispatch. The separately gated [publication API](PERSONAL_ROUTINE_API_V1.md) + adds whole-week review, revision checks and a durable save/refresh receipt. + The [isolated personal web workspace](PERSONAL_WEB_WORKSPACE.md) now includes + scoped controls, full-week draft editing, review and receipt recovery. Finish + native routine-publication acceptance and the real side-transfer workflow, then test independent + schedules and stale callbacks through that transition. These + implementations remain off on the live Pod. +3. **Private history and Health integration.** Attach live observations to dated + person assignments. Review Luca's legacy records explicitly instead of + relabeling old Left history as Marissa's. Bind Health imports, exports, caches, + feedback, consent and models to the person. Withhold ambiguous intervals. +4. **Close every shared/legacy access path.** The [persistent privacy boundary](PERSONAL_PRIVACY_BOUNDARY.md) + restricts legacy owner APIs and streams after shared assignment/attribution and + withholds shared presence/vitals. Finish the assignment coordinator and outstanding + request-effect quiescence before enabling it. + Household administration is not consent to read another person's Health data. + Review shared HomeKit/MQTT telemetry and command consent; preserve pairings. + MQTT live messages already use `retain:false` and clear legacy retained + measurements on reconnect, but that does not itself establish personal consent. + The [administrative status and recovery hold](PERSONAL_RECOVERY_BOUNDARY.md) + keeps maintenance reads narrow and blocks legacy restore when personal records + exist. Complete durable, version-compatible personal recovery before activation; + this hold alone is not a supported personal-account backup/restore workflow. +5. **Web/native acceptance and release.** Freeze real route fixtures, test two + independent accounts, account switching, credential revocation, unknown command + outcomes, reconnection and private-data isolation. Build both clients against + those routes. Release only the tested integrated versions. +6. **Attended two-person checks.** Confirm physical Left/Right, empty/occupied + detection, each zone's temperature, Stop, vibration routing/cancellation and + independent alarms. Separately review Preview nights before adaptation. These + checks cannot be completed before the real shared arrangement exists. + +```mermaid +flowchart LR + Invitation[Personal invitation] --> Drafts[Private preferences and routine drafts] + Drafts --> Review[Review both assignments and settings] + Review --> Checks[Integrated release and attended checks] + Checks --> Shared[Marissa Left / Luca Right] + Solo[Luca solo anywhere] -->|continues until reviewed activation| Review +``` + +## Tomorrow's ordinary wake review + +Friday October 30's follow-up is at **07:15 Pacific, after waking**. Review how the +night felt, target and measured temperatures, whether warming began around 06:30, +and what happened at 07:00. No pre-wake network isolation, controller restart or +extra alarm is planned. An ordinary successful alarm is not an offline or +cancellation qualification. Recent Watch references are absent because Luca has +not worn the Watch overnight recently; missing references are not failed imports. diff --git a/docs/IMPORTED_HISTORY.md b/docs/IMPORTED_HISTORY.md new file mode 100644 index 000000000..f8515160a --- /dev/null +++ b/docs/IMPORTED_HISTORY.md @@ -0,0 +1,145 @@ +# Imported Free Sleep history + +Sleep → History displays earlier Free Sleep observations without inserting them +into Core's live measurements, Apple Watch references, personal baseline or +Preview-night evidence. Both historical sides remain selectable even if one +side is currently away. Current occupant names are not retroactively assigned. + +The importer preserves `legacy/free-sleep.db` byte-for-byte and creates a +separate `free-sleep-history.db` next to `sleepypod.db`. Only the supported +columns from `vitals`, `movement` and `sleep_records` are copied, with indices +for bounded pagination. Extra source tables are not exposed. Metadata records +the source SHA-256, original settings timezone, import time, per-side counts +and readable timestamp bounds. Invalid identities/timestamps and unknown +sides remain in the preserved files; they do not become valid API records. + +```mermaid +flowchart LR + Backup[Verified offline backup] --> Archive[Unchanged original database] + Archive --> Snapshot[Separate indexed history snapshot] + Snapshot --> API[Authenticated read-only v1 API] + API --> Web[Sleep / History] + API --> Native[Native history client] + Live[Core live metrics] --> Current[Current Sleep views] +``` + +## Contract for web and native clients + +All routes use existing owner Bearer authentication or the browser session, +and private, no-store responses. They are also tRPC queries under +`importedHistory`. There are no mutations or hardware calls in this subsystem. +Discover `features.importedHistory` and `endpoints.importedHistory` through +capabilities. A ready software capability does **not** establish that records +have been imported; read status first. + +| REST route | Query | Result | +| --- | --- | --- | +| `GET /api/v1/history` | None | `apiVersion:1`, `status`, `snapshot`, `message`, `semantics` | +| `GET /api/v1/history/records` | `snapshotId`, `side`, `kind`, `startTime`, `endTime`, optional `cursor`, `limit` | Newest-first records, nullable `nextCursor`, source identity and semantics | +| `GET /api/v1/history/presence` | `snapshotId`, `side`, `id` | One presence record, normalized present/absent intervals and bounded original interval JSON | + +Status is `ready`, `not_imported` or `unavailable`. Only ready status has a +snapshot. A missing or unreadable snapshot must not render an empty successful +night, zero vitals or a fabricated score. The snapshot ID binds the source +checksum, format and timezone. Import time is bookkeeping, not a sleep event. + +`side` is `left` or `right`; `kind` is `presence`, `vitals` or `movement`. +Date bounds are ISO timestamps with an explicit UTC offset. Windows are +half-open, positive and at most 90 × 24 hours. The web picker uses inclusive +calendar dates in the source timezone and converts the following midnight to +the exclusive bound, including 23/25-hour DST days. An overnight presence +record is included whenever its window overlaps the requested range. Vitals +and movement use their recorded timestamp, not the presence window. + +`limit` defaults to 100 and may be 1–500. Ordering is descending timestamp, +then descending original ID. Pass `nextCursor` unchanged with the same +snapshot, side, kind and range. Never reuse it after changing filters. A +replacement snapshot returns HTTP 409; reload status and restart pagination. +Invalid cursor scope/input returns 400, unavailable snapshots 412, and a +missing or other-side presence ID 404. Reads are transactionally consistent +within each request; this is not a live replica of a still-running Free Sleep. + +### Measurement meaning + +Record IDs are original positive integer IDs. Record timestamps and interval +endpoints are **Unix seconds**. `snapshot.importedAt` is **Unix milliseconds**. +The source timezone is for display and range selection, not a timestamp offset +to add to the stored epoch. + +- **Presence:** `enteredBedAt`, `leftBedAt`, `reportedDurationSeconds`, `exits`. + This is bed occupancy, not measured sleep or sleep stages. The raw reported + duration is retained separately from occupied time computed from intervals. +- **Vitals:** `timestamp`, `heartRateBpm`, `hrvEstimateMs`, + `breathingRatePerMinute`. These are the legacy five-minute averages stamped + at the interval start. Missing/nonfinite values become null. There is no + retroactively verified signal quality, mechanical-artifact exclusion or + HRV estimator definition. Do not combine these with Watch SDNN or current + Core HRV as if their measurement definitions were identical. +- **Movement:** `timestamp`, `totalMovement`, preserving the original aggregate + rather than inventing a normalized activity or sleep-stage value. + +Presence details clip and union source intervals inside the record window, +so overlap does not double-count occupancy. Present and absent intervals are +independent source claims; contradictions are not silently repaired. Empty, +missing, invalid or entirely out-of-window intervals have unknown duration, +not zero. Fields over 256 KiB, arrays over 10,000 intervals and windows over +72 hours are not interpreted. A status explains why; excessive raw payloads +are omitted. No interval reconstruction or sleep staging is performed. + +Every response includes `quality:legacy_unqualified`, `access:read_only`, +`purpose:history_only`, `previewEvidence:false` and `automationInput:false`. +These limitations should remain visible in native clients and any analysis +that explicitly imports this source. This change does not send health data to +an LLM, write Apple Health samples or influence comfort control. + +## Storage and migration + +The new file is a derived query snapshot, not a replacement for the original. +Allow extra disk space for its supported rows and indices before importing. +The importer builds it inside private staging, records its checksum in +`migration-report.json` and removes failed staging as a whole. The original +source archives and byte-preserved database remain the recovery authority. + +The derived file uses DELETE journal mode. The reader checks its SQLite header +before opening with `readonly` and `fileMustExist`, rejects symlinks/WAL-mode +files, and sets query-only/trusted-schema restrictions. This avoids treating a +WAL snapshot as a writable auxiliary-file location; SQLite documents the +special requirements for [read-only WAL databases](https://www.sqlite.org/wal.html#read_only_databases). +The API path is fixed beside `DATABASE_URL`; clients cannot choose filenames. +Rebuild an unavailable derived snapshot offline from its verified source. +Do not attach the API directly to the running Free Sleep database or overwrite +the only rollback copy. Existing candidates without this file honestly report +`not_imported`; generate a fresh candidate before the eventual cutover. + +The existing two-database download covers current Core configuration and +biometrics. Retain the original migration `source/` archives in the off-host +recovery set as well; they preserve imported history and can rebuild this +derived snapshot. A current-Core database download alone is not a complete +backup of the legacy installation. + +## Native compatibility boundaries + +This direct v1 API replaces the need to read old biometric observations through +Free Sleep's analytics endpoints. It does **not** install a blanket +`/api/native/v1` proxy; `nativeCompatibility` remains null. + +| Original capability | Core/native integration direction | +| --- | --- | +| Historical bed episodes, vitals and movement | Adopt this API and keep provenance/units/unknown values intact. Current Core nights remain a separate source. | +| Apple Health history and export | Native HealthKit retains its consent, source identities, duplicate prevention and accessible historical queries. These history GETs do not authorize Health writes; no legacy `health/export` route is supplied. | +| AI coaching | Native local/cloud model selection and explicit sharing remain the intended path. No legacy `ai/coach` forwarding route is supplied. Reports must label imported observations and their limitations. | +| Morning journal | Full restfulness, comfort, habits and notes now have a revision-checked local API and web editor, with complete offline import. Native adoption uses the [journal contract](MORNING_JOURNAL.md); the original JSON remains preserved. | + +## Validation and remaining gates + +Synthetic fixtures use the actual Python importer and the real readonly reader. +They cover original bytes and sidecar preservation, unknown sides/invalid time +counts, stable tied-timestamp pagination, side and snapshot isolation, missing +values, overlapping intervals, bounded malformed input, DST and source-local +dates. Router checks assert error mapping. The production browser workflow +checks anonymous REST/tRPC rejection, authenticated REST and UI, presence +details, pagination, side/range changes and 390px light/dark layouts. + +This is source and isolated-test evidence. Native adoption, a fresh real +backup/import, actual Pod runtime qualification and at least seven genuine +Preview nights remain separate gates. Preserved history does not satisfy them. diff --git a/docs/LIVE_VITAL_FRESHNESS.md b/docs/LIVE_VITAL_FRESHNESS.md new file mode 100644 index 000000000..631bc6bfa --- /dev/null +++ b/docs/LIVE_VITAL_FRESHNESS.md @@ -0,0 +1,85 @@ +# Live vital timestamp boundary + +The live piezo processor now timestamps new estimates with the accepted +firmware `piezo-dual.ts` value. Previously it used the processing clock, so a +delayed frame could produce a current-looking database row. This is a source +freshness fix, not validation of the heart-rate algorithm or sleep outcomes. + +```mermaid +flowchart LR + Frame[Firmware piezo frame] --> Gate[Fresh and ordered timestamp] + Gate -->|accepted| Buffer[Continuous sample buffers] + Gate -->|invalid or old| Reset[Discard unfinished estimates] + Gap[Missing frame or pump artifact] --> Reset + Buffer --> Estimate[Vital estimate at firmware time] + Estimate --> Database[Vitals and matching quality row] +``` + +## Live behavior + +- Require a finite positive firmware timestamp, no later than the Pod clock and + no more than ten seconds old. Missing, boolean, malformed, future, stale, + duplicate and reversed timestamps are rejected before samples enter buffers. +- The documented live format carries one second of samples per piezo frame. + More than 1.5 seconds between source frames, or a regressed monotonic arrival + clock, starts a new window. Slow processing alone does not discard complete, + consecutive source frames, provided they remain within the age limit. +- Rejected timestamps, empty channels and pump-gated frames clear unfinished + HR, breathing and HRV buffers, trackers and presence state. Normal consecutive + frames retain state. Completed candidates awaiting the other side or a + database retry keep their original timestamp and existing retry behavior. +- New live run provenance declares `timestampBasis: firmware_event_time_v1`. + Old `processing_clock` and unknown legacy rows are unchanged. Readers must + check provenance before combining their time semantics. +- Timestamp failures report degraded module health; valid input and available + configuration restore health. Fresh input alone does not establish signal + quality, calibration, occupancy, or the absence of base/vibration artifacts. + +## Compatibility and remaining work + +This change does not enable personal comfort Active or its sensor-aware option. +The comfort adapter still needs explicit quality, whole-window artifact and +personal-baseline handling. Priming, vibration and base-motion exclusion needs +recorded evidence before those samples can train a personal baseline. + +The prospective shadow candidate now uses the same shared frame-admission and +buffer-reset implementation as the live processor. Firmware time drives its +estimates; output timestamps truncate to integer Unix seconds exactly as the +live SQLite writer does. The active reader requires matching +`firmware_event_time_v1` provenance and rejects old processing-clock rows without +advancing its capture cursor. New candidate and active comparison records retain +that basis. Historical receipts and samples remain readable without acquiring a +new clock label. + +Recorded arrival time drives the prospective candidate's freshness check and +pump/router timers. It substitutes for the live process's local monotonic clock, +which remains an explicit limitation. The host's saved source-freshness policy +may be stricter and can terminate capture before a frame reaches the candidate. +Cold-start state, context timing, source delivery and clock skew still affect +comparison coverage; equal clock semantics do not prove equal physical input. + +Current worker replies declare their clock basis and timestamp-rejection count; +missing, mismatched or impossible receipts fail validation. Retained waveform +replay keeps its existing declared semantics. The shared input boundary is +included in the manifest and Python build identity, so previously qualified +experimental builds do not qualify this changed bundle automatically. + +Deployment requires a fresh pinned sensor package and runtime validation. The +working Pod remains on Free Sleep until the separate controller handoff and +attended qualification gates pass. Tests use synthetic fixtures and local +SQLite; they do not count as observed Preview nights or physical qualification. + +## Regression coverage + +Tests cover the ten-second boundary, event versus arrival time, missing and +malformed timestamps, duplicate/reversed frames, gaps and clock discontinuity, +recovery after stale input and a pump artifact, estimator buffer reset, and +preservation of event time and provenance through a delayed database write. + +A production-loop regression runs real runtime binding, pump gating, routing, +canonical SQLite writes and the isolated prospective candidate against the same +synthetic stream. It compares values, quality, flags and integer-second event +timestamps, including delayed arrival, missing/empty frames, stale/future/ +duplicate/reversed timestamps, pump interference and a logical-side change. +Protocol tests also cover clock-label preservation, historical receipt +compatibility, rejection accounting and build-identity invalidation. diff --git a/docs/MECHANICAL_DATA_QUALITY.md b/docs/MECHANICAL_DATA_QUALITY.md new file mode 100644 index 000000000..acdf7c16b --- /dev/null +++ b/docs/MECHANICAL_DATA_QUALITY.md @@ -0,0 +1,101 @@ +# Mechanical interference and biometric evidence + +Priming, wake vibration and base movement can contaminate piezo estimates. +The live processor now requires continuous, fresh mechanical context before +admitting frames and before committing a completed vital estimate. This is an +engineering exclusion policy; it has not been validated as a clinical signal +quality detector or tested on this owner's base/vibration hardware. + +```mermaid +flowchart LR + Command[Mechanical command] --> Journal[Durable exclusion before dispatch] + Telemetry[DAC and base telemetry] --> Journal + Journal --> Gate[Fresh quiet interval] + Piezo[Firmware-timestamped piezo frames] --> Gate + Gate --> Estimate[Bounded rolling estimates] + Estimate --> Pending[Immutable candidate queue] + Journal --> Pending + Pending -->|Whole window covered| Vitals[Vitals and evidence receipt] + Vitals --> Eligibility[Current interval and provenance checks] + Eligibility --> Rules[Automation signals] +``` + +## Evidence and limitations + +- DAC status reports priming. Base movement is inferred from fresh position + changes and pending writes. Two base samples are required before stationarity + can be inferred. Core subscribes to the configured base at hardware startup; + this does not issue movement commands. +- Firmware does **not** report physical vibration. The alarm flag is inferred + from application commands. Every alarm dispatch records a conservative + 180-second exclusion plus ten seconds settling, regardless of payload. + External or undocumented vibration triggers are not proven detectable. +- Missing, unconfigured or disconnected base telemetry is unknown, not an + assertion that the bed has no base. Such installations withhold new live + vitals until supported telemetry is available. An explicit, reviewed + no-base capability is still needed for systems without an adjustable base. +- No hardware calibration, snoring detector or physical Stop qualification is + implied. The ten-second settling interval and one-second frame-edge margins + are conservative software defaults that still need hardware measurements. +- Existing raw recordings and historical vitals are not rewritten. Historical + replay without mechanical context remains exploratory and reports its missing + context. Prospective firmware-time comparison requires context; missing + context produces excluded frames rather than invented quiet evidence. + +## Persistence and command boundaries + +Migration `0032_mechanical_signal_context.sql` adds an observed quiet-span journal +and one current-state record. The controller samples existing DAC/base state +once a second. Only actual telemetry timestamps extend a span; repeatedly +reading cached status does not extend it. The freshness bound is ten seconds. +Restart, a ten-second observer gap, clock reversal, unknown state or interference +breaks continuity. Quiet must resume after settling. Failed journal writes +invalidate the in-memory epoch, including failures during emergency cancellation. + +DAC recording occurs inside its real command FIFO immediately before socket +write, covering normal and raw command callers. Base recording occurs at its +serialized transport boundary. A start command fails if its exclusion cannot be +recorded. Emergency alarm clear and base Stop remain available; they never +shorten an existing possible-vibration exclusion. Shutdown marks context stopped. +The journal retains at most 20,000 spans and 35 days; readers take at most 64 +recent spans. There is one controller writer and read-only Python consumers. +Journal writes use a 25-millisecond SQLite busy timeout and restore the shared +connection's timeout afterward, so contention cannot impose its usual multi-second +wait on emergency cancellation. + +## Processing and learning + +Any context failure clears unfinished HR, respiration and variability buffers. +Completed candidates retain their values, event timestamp, configuration and +logical/physical side while waiting for coverage. The separate queue holds at +most 16 candidates for at most 120 seconds, including database retries. Expired +or unconfirmed candidates are excluded; they are never retimed to appear fresh. +Shutdown logs pending exclusions instead of forcing unverified writes. + +Coverage includes the longest non-null metric window: 30 seconds for HR, +60 seconds for respiration, 300 seconds for window-IBI variability, plus a second +on each edge. This can deliberately withhold early estimates during warm-up. +Each committed row's quality flags contain one immutable +`mechanical_context_v1::::` receipt. Its processing +run records `mechanicalContext: mechanical-context-v1`. + +Automation vitals and the vital-based occupancy upgrade require that receipt, +matching live-run provenance, a fresh row, and coverage in the **current** quiet +interval. Older or unqualified rows remain visible but cannot drive these +signals. This change does not enable the personal learner's Active mode. + +Prospective capture carries the same bounded mechanical snapshot into the +isolated Python worker and uses the same frame gate and output queue. The parent +validates mechanical exclusion counts; build fingerprints include the new shared +module. Old engine fingerprints and saved plans are not interchangeable with +this runtime. Retained historical replay is not retroactively certified. + +## Validation boundary + +Tests exercise the generated migration, stale/future/missing context, clock +reversal, restart and Stop persistence, storage failure, real socket FIFO +dispatch, bounded immutable retries, full-window exclusion, automation +eligibility and production-loop/SQLite/prospective-worker parity. + +These are local software checks. Installation, live thermal/base/vibration +qualification and seven actual Preview nights remain separate acceptance gates. diff --git a/docs/MORNING_JOURNAL.md b/docs/MORNING_JOURNAL.md new file mode 100644 index 000000000..06a1903f0 --- /dev/null +++ b/docs/MORNING_JOURNAL.md @@ -0,0 +1,118 @@ +# Morning journal and mode-only comfort + +The authenticated web journal lives at `/sleep?view=journal` within Sleep. It keeps +the original Free Sleep check-in: wake date, physical side, restfulness (1–5), +temperature comfort, caffeine, alcohol, exercise and up to 500 characters of notes. +Both sides remain accessible regardless of current away mode or occupant names. +This is local storage and numerical analysis; no cloud model is called. + +## Native and REST contract + +`GET /api/v1/journal?side=left&date=2026-10-24&days=30` + +- `date` defaults to today's calendar date in the Pod's saved time zone. It is + the selected wake date **and** inclusive end of the analysis window. +- `days` is 1–90, default 30. Select an older date to access older history; the + journal is not restricted to the most recent 90 days. Future and nonexistent + local dates fail with 400. Date bounds begin at 1970-01-01. +- Response: `apiVersion:1`, `side`, `timeZone`, `deviceDate`, `from`, `through`, + `selected`, `entries` (newest first), and `patterns`. +- Each selection has `side`, `date`, nullable UUID `revision`, and nullable + `record`. A record contains `fields`, `side`, `date`, `timeZone`, `origin` + (`free-sleep` or `sleepypod`), `createdAt`, `updatedAt`, `importedAt`. + All non-null timestamps are epoch **milliseconds**. +- Imported creation/update timestamps are unknown (`null`), not migration time. + Original record source and date zone survive edits. `updatedAt` records a new + edit. Current names are not assigned retroactively to a historical side. + +`POST /api/v1/journal` saves or removes one check-in: + +```json +{ + "side": "left", + "date": "2026-10-24", + "expectedRevision": null, + "fields": { + "restfulness": 4, + "comfort": "comfortable", + "caffeine": false, + "alcohol": false, + "exercise": true, + "notes": "" + } +} +``` + +Use the exact revision from the selection, including a deletion's revision. +`expectedRevision:null` only matches a date never stored. `fields:null` removes +the current entry and its comfort feedback. A content-free tombstone prevents a +stale client from recreating deleted notes. Removal does not erase old backups, +the original migration archive, previous event summaries, or learning already +applied. Every successful mutation returns the new selection. + +409 means another writer changed the selection. Retain the draft, refresh the +saved entry, and ask the owner which version to keep. After an uncertain network +result, read the selected entry before retrying; do not obtain a new revision and +blindly replay the edit. This endpoint uses optimistic concurrency, not durable +hardware command receipts. REST/tRPC reads and writes require owner authentication +and return private, no-store responses. Errors never include note contents. + +tRPC names are `journal.get` and `journal.save`. Negotiate +`features.morningJournal` / `endpoints.journal`; the general legacy compatibility +prefix remains null. Demo reads/writes explain that a real Pod is required. + +## Feedback and analysis + +Journal and comfort-feedback writes commit in one database transaction, under +the shared per-side lock. Existing comfort-only clients change just the comfort +field of an existing full entry and rotate its revision. They cannot erase habits +or notes. Changes never rewind a consumed feedback cursor or reset manual pauses. + +The latest seven check-ins in the chosen window supply average restfulness and +warm/cold counts. Habit associations require **five distinct matched wake dates +with and five without** a habit, independently in each source and date zone. +Their metric is the difference in average **bed-presence minutes**, not measured +sleep duration or a clinical outcome. The output reports matched and excluded +date counts. Sources are never pooled to satisfy the minimum. + +Presence intervals are clipped to their recorded window and unioned across +overlapping records. A date with missing, malformed, oversized or unsupported +intervals is excluded instead of assigned zero or a partial duration. Future +wake timestamps are excluded even within the current calendar date. Different +time zones are excluded. Reads use at most 1,000 source records and 8 MiB of +interval JSON; larger ranges return `too_many_records` with no associations. +Imported history stays unqualified and cannot count as Preview evidence or +authorize automation. Notes are excluded from all numerical calculations. + +The authenticated API returns the owner's notes for editing. No implementation +here sends notes or patterns to a model, MQTT, HomeKit, or an external service. +This does not establish encrypted-at-rest storage; access control and backups +remain the owner's responsibility. + +## Migration and client state + +Generated config migration `0041_morning_journal` adds `journal_entries`. +The offline importer restores all full journal fields and initializes revisions, +while retaining the byte-preserved source archive. Existing staged candidates +must be rebuilt from a fresh quiesced source before a controller switch. +Applying this schema alone does not import a preserved legacy JSON file. + +The web editor keeps side/date drafts while the page stays open, preserves them +on errors, compares conflicting saved data, and requires explicit removal. +Drafts are not persisted in browser local storage. Refresh/closing the page +discards unsaved drafts; the UI states this. Native clients should use the same +revision contract and keep their own scoped drafts. + +## Atomic comfort mode + +`POST /api/v1/comfort/mode` accepts only `{ "side":"left", "mode":"preview" }` +(or `off`). `active` returns 412. No other configuration field is accepted. +The runtime reads current settings inside its write lock, replaces only mode, +recovers any pending manual pause, and preserves limits, sensor/bedtime options +and learning cursors. Response is the ordinary comfort status with +`features.modeOnly:true`. tRPC is `comfort.mode`. Use this operation for a +mode switch rather than GET followed by a full configuration replacement. + +Active hardware adaptation remains locked. These changes do not qualify the +bed, import Apple Health, add model-generated coaching, or complete any Preview +night. Software and production-browser checks use synthetic isolated databases. diff --git a/docs/MQTT_COMMANDS.md b/docs/MQTT_COMMANDS.md new file mode 100644 index 000000000..9f8dbd761 --- /dev/null +++ b/docs/MQTT_COMMANDS.md @@ -0,0 +1,92 @@ +# MQTT command delivery + +The bridge defaults to **telemetry only**. Settings displays the effective +command mode. `MQTT_COMMANDS_ENABLED=true` in the protected host environment +opts into command reception on the next bridge start. Configure dedicated +broker credentials and least-privilege topic ACLs first. The HTTP owner key +is not a broker password. + +Home Assistant discovery supplies a UUID and 30-second expiry when a user +changes temperature or mode. Telemetry-only discovery omits command topics. +Discovery is republished on reconnect. The bridge uses a clean MQTT session +and does not automatically restore an old command subscription. + +## Contract + +Publish non-retained, QoS 0 JSON to `//cmd/request`: + +```json +{ + "requestId": "63a82982-947d-4bf2-80ee-4cbe6824d7d4", + "issuedAt": 1792965600000, + "expiresAt": 1792965630000, + "action": { "kind": "power", "side": "left", "powered": false } +} +``` + +These timestamps are illustrative and expired. Generate a new UUID and current +epoch-millisecond timestamps for a **new user action**. Maximum lifetime is +60 seconds. Reuse the exact request when reconciling an uncertain result; +changing its ID or deadline would create a new action. + +The non-retained receipt is published to +`//receipt/`. If publication is lost, retrieve +the durable receipt through the authenticated +`GET /api/v1/controls/{requestId}` endpoint. A network acknowledgment does not +establish that the bed moved, heated or vibrated. + +Only `temperature`, `power`, `vibration`, and `vibration.clear` actions are +accepted. They reuse the app's control contract, side locks, manual pauses and +hardware guards. Base motion and priming remain in their existing app controls. +The previous `cmd/set-*`, `cmd/clear-alarm` and `cmd/start-priming` payloads are +no longer accepted; update custom automations before opting in. + +Retained messages, MQTT redelivery packets, missing packet metadata, malformed +or oversized payloads, future requests and expired requests do not dispatch. +At most eight requests may await completion. A durable receipt is committed +before each transport write, where wall-clock and monotonic deadlines are +checked again after queue waits. Identical IDs are not executed again, +including after restart; changed content under the same ID is rejected. +Interrupted or uncertain writes report `unknown`. A disabled or replaced +bridge cannot accept stale client callbacks. + +```mermaid +flowchart LR + Broker[Authorized broker topic] --> Gate[Opt-in and envelope checks] + Gate --> Ledger[Durable control receipt] + Ledger --> Authority[Existing control authority and locks] + Authority --> Deadline[Deadline and receipt before write] + Deadline --> Pod[Hardware] + Ledger --> Result[Receipt / authenticated lookup] +``` + +## Broker access + +Give the Pod identity write access only to its availability, state, pump, +receipt and own discovery topics. It needs read access to its `cmd/request` +topic only when control is enabled. A telemetry-only credential needs no +command subscription. Restrict command publishers to that Pod's request topic, +and receipt and biometric readers to intended consumers. With the official +Home Assistant Mosquitto app, preserve the reserved `homeassistant` and `addons` +permissions required by the app. See its +[ACL documentation](https://github.com/home-assistant/addons/blob/master/mosquitto/DOCS.md#access-control-lists-acls). + +The command gate does not qualify sensor truth or physical hardware. Telemetry +still needs per-source freshness and capability checks before it drives +external automations. Actual broker ACL provisioning, hardware qualification +and Home Assistant control acceptance are separate integration gates. + +## Validation + +Tests exercise real migrated SQLite receipts through the MQTT adapter: duplicate +delivery, database reopen, uncertain writes, stale/future commands, queue expiry, +clock rollback, unavailable storage, payload limits and unsupported actions. +Bridge tests cover telemetry-only discovery, opt-in, non-retained receipts, +legacy-topic rejection, packet metadata and replaced-client callbacks. These +tests use synthetic actions; they do not operate a Pod. + +The owner's Home Assistant template editor rendered temperature and both power +values successfully, each with a valid UUID and a 30-second timestamp interval. +Only synthetic text was rendered; no MQTT message or hardware command was sent. +The editor was restored afterwards. This confirms template compatibility, not +end-to-end broker delivery or physical acceptance. diff --git a/docs/NEXT_ALARMS.md b/docs/NEXT_ALARMS.md new file mode 100644 index 000000000..4032668d1 --- /dev/null +++ b/docs/NEXT_ALARMS.md @@ -0,0 +1,147 @@ +# One-occurrence wake changes + +The Temp Cards view now separates **Skip this alarm**, **Change this time** and **Restore routine +alarm** from **Weekly schedule**. The first three actions persist a temporary +change on the Pod without rewriting weekly alarm rows or bedtime routines. +Saved alarms remain accessible while live hardware status reconnects. +The same API is available to independently implemented native clients. + +## Contract + +Owner authentication is required for REST and tRPC; responses are private and +not cached. GET only reads saved configuration and delivery receipts. It does +not initialize a scheduler or contact hardware. + +| Operation | REST | tRPC | +| --- | --- | --- | +| Read current occurrence | `GET /api/v1/alarm/next?side=left` | `nextAlarm.get` | +| Change one occurrence | `POST /api/v1/alarm/override` | `nextAlarm.change` | + +`features.nextAlarmOverride` and `endpoints.nextAlarm` / `endpoints.alarmOverride` +in `/api/v1/capabilities` advertise this implementation. Physical verification +remains `unverified`. + +A read returns `apiVersion: 1`, physical `side`, Pod `timeZone`, an opaque +`revision`, `state` (`scheduled`, `skipped`, `none`, `away`), `canChange`, an +explanatory `reason`, and these optional records: + +- `alarm`: `scheduleIds`, absolute `at` and `originalAt`, local `time`, + `source` (`routine` or `override`), `duration` in seconds, `wakeWindow` in + minutes and `alarmTemperatureF`. `originalAt` retains the weekly event even + after repeated time changes. For a skipped occurrence, `at` identifies the + suppressed original event, not a command that will execute. +- `followingAt`: the next distinct weekly event after this occurrence. Show it + when an alarm is skipped; do not label the skipped event the next vibration. +- `change`: UUID `revision`, `status` (`active`, `expired`, `invalidated`), + `originalAt`, requested `at` (null means skipped) and `expiresAt`. +- `execution`: null, `attempting`, `acknowledged` or `unknown`. An acknowledgment + is a protocol result, not evidence that the mattress physically vibrated. + A crash after the durable claim can leave `attempting`; do not treat it as + proof of either delivery or non-delivery. + +Copy the displayed revision and timestamp strings verbatim into requests: + +```json +{"side":"left","action":"skip","expectedRevision":"","expectedAt":""} +``` + +```json +{"side":"left","action":"time","time":"07:30","expectedRevision":"","expectedAt":""} +``` + +```json +{"side":"left","action":"restore","expectedRevision":"","expectedExpiresAt":""} +``` + +The write response has the read shape plus `schedulerUpdated`. False means the +change is persisted but timer registration was not confirmed. It is not a +hardware success. Normal startup and the scheduler heartbeat reconcile saved +future changes. An elapsed override is never caught up after restart. + +Use HTTP 409 to refresh stale/consumed occurrences; 400 identifies invalid +requests or times; 412 indicates missing time-zone configuration. Preserve a +local time draft after errors. After a lost response, GET first and require +review before another write; never automatically retry a mutation against a +new revision. Restoring retains a generation tombstone, so an older request +cannot become valid again simply because the visible alarm matches its original +value. The web editor keeps its draft bound to the revision it opened with. + +## Time and schedule behavior + +A changed time uses the displayed alarm's local wake date. It must be in the +future and must not cross another scheduled alarm. Skipped DST wall times and +repeated fall-back times are rejected; weekly routines keep their existing cron +semantics. Identical simultaneous rows are one wake event. Differing simultaneous +profiles continue their individual weekly behavior, but one-time editing is +unavailable until that conflict is resolved in Schedule. + +A change retains the original weekly row identities and plan fingerprint. +Editing enabled weekly rows or the Pod time zone invalidates the temporary +change; it does not replay a captured profile that has since been deleted or +edited. Away/inactive sides stay silent. Changing a wake event already attempted +is refused; existing stop/snooze controls still manage the active vibration. +Restore removes the temporary change but never replays a consumed or elapsed +original occurrence. + +The single temperature authority uses the effective alarm's warm-up and hold: +`max(30 minutes, wakeWindow)` before wake, through `duration + 15 minutes` after. +A skip suppresses the original span. Moving earlier still suppresses the later +original span; expiry covers both. An off side is not powered on for warm-up. +A previously deferred power-off rechecks the current span at its deadline, so +an overlapping later wake window is not cut short. An explicit power-on still +invalidates the old deferred shutdown. A nonoverlapping, substantially later +alarm does not extend an already-finished heating session. + +Personal comfort withholds adjustments for the affected bedtime routine and +active alarm spans. A change next week does not suppress unrelated nights. +Snooze configuration follows the original alarm profile. Existing pending +snoozes are neither canceled nor rescheduled by a future one-time edit. + +## Delivery and recovery + +```mermaid +flowchart LR + Edit[Revision checked edit] --> Saved[(Temporary occurrence)] + Saved --> Timers[Weekly and absolute timers] + Timers --> Guard[Current plan and side lock] + Guard --> Claim[(Durable occurrence attempt)] + Claim --> Queue[DAC queue: recheck deadline and plan] + Queue --> Result[Acknowledged or unknown] +``` + +Generated migration `0042_next_alarm_occurrences` adds `alarm_overrides` and +`alarm_occurrences`. Early movement wake, normal clock callbacks, temporary +callbacks and restarted schedulers share the original occurrence identity. +A durable claim is made before transport. Unknown outcomes and interrupted +claims are consumed, not resent. Claims older than 30 days are pruned on a new +claim; stale callbacks cannot use that pruning to execute because their deadline +has elapsed. + +Before the actual DAC write, the shared transport rechecks the current plan, +side ownership and a maximum 60-second wall/monotonic deadline. A queue wait, +clock rollback, schedule edit or disconnection cannot revive an old command. +The heartbeat preserves an identical timer even at its exact deadline. +Temporary alarm and deferred-shutdown jobs are excluded from weekly-job drift +counts. Replacing a one-time shutdown from its own callback no longer removes +the replacement from the scheduler's registry. + +The demo explicitly reports that a live authenticated Pod is required. No demo +change is presented as a saved wake command. + +## Migration and acceptance + +This does not silently translate active legacy Free Sleep overrides into new +Core occurrence identities. The offline importer preserves their original +settings and lists active overrides under `reviewRequired`. Review or let those +changes expire before the attended cutover; retain their rollback archive. +Create a fresh quiesced backup/import before switching controllers. + +Software coverage uses migrated SQLite databases, real scheduler timers with a +fake clock, mocked DAC transport, and an isolated authenticated production web +server. It covers CAS/replay, side isolation, DST, original-row preservation, +expiry, warm-up/hold suppression, shutdown rescheduling, early-vs-clock duplicate +claims, connection/queue delays, uncertain delivery, restart, browser draft +recovery and phone layout. These checks do not qualify the Pod's actual routing, +intensity, duration, vibration cancellation or snooze behavior. Those attended +hardware checks, native adoption and the genuine Preview nights remain separate +release gates. No live Pod controller was switched for this implementation. diff --git a/docs/OFFLINE_POD_STAGING.md b/docs/OFFLINE_POD_STAGING.md new file mode 100644 index 000000000..caa53a5a6 --- /dev/null +++ b/docs/OFFLINE_POD_STAGING.md @@ -0,0 +1,120 @@ +# Offline Pod runtime staging + +The working Free Sleep installation is retained. This path prepares a separate +Linux ARM64 application/runtime bundle on the development computer. It does not +run the upstream installer, purge Free Sleep, change global Node symlinks, edit +firewall/SSH configuration, start a controller or claim runtime rollback. + +## Prepare + +Use a clean committed checkout, Node 24.22.1 and pnpm 10.34.6. Build after the +commit so `.git-info` matches HEAD. Never reuse an old build with a new revision. + +```sh +DATABASE_URL=:memory: BIOMETRICS_DATABASE_URL=:memory: pnpm build +python3 scripts/deployment/prepare_offline.py --output /absolute/new/private-directory +``` + +The output must not exist and must be outside the repository. The package has: + +- `app/`: standalone Next server, static assets, migrations, pure biometric + workers and a fresh locked production dependency install for Linux/ARM64/glibc. +- `runtime/`: the pinned Node Linux ARM64 distribution. No system binary changes. +- `source.tar.gz`, the AGPL license, a per-file/hash/symlink manifest and the + compressed `sleepypod-runtime.tar.gz` with a SHA-256 sidecar. + +Build tracing can include workstation databases. The packager excludes these, +`.env*`, keys, Python environments and workstation `node_modules`. It installs +production dependencies from the lockfile with lifecycle scripts disabled and +uses the reviewed better-sqlite3 prebuild for Node ABI 137. All native shared +objects must be Linux ARM64 ELF files. Escaping links, unsafe archive paths, +dirty sources, wrong build identity and demo builds are rejected. Existing +output is never overwritten. Failed preparation removes only its new directory. + +Pinned runtime sources: + +- [Node 24.22.1 security release](https://nodejs.org/en/blog/release/v24.22.1) + and [official checksums](https://nodejs.org/dist/v24.22.1/SHASUMS256.txt). +- [better-sqlite3 12.11.1 release](https://github.com/WiseLibs/better-sqlite3/releases/tag/v12.11.1), + Linux ARM64 Node 137 asset, digest pinned from its release metadata. +- [pnpm 10 supported architectures](https://pnpm.io/10.x/settings#supportedarchitectures). + +These versions/digests live in `prepare_offline.py`; a dependency update must be +reviewed there instead of silently downloading a different native binary. The +normal installer also pins fresh Node installs to 24.22.1; its existing-major +version check is unchanged and does not upgrade every existing Node 24 install. + +## Inert target probe + +Transfer to a **new private staging directory**, verify the archive SHA-256 on +the Pod, inspect its manifest, then extract there. Never extract over `/` or the +working Free Sleep tree. From the extracted directory: + +```sh +./runtime/node-v24.22.1-linux-arm64/bin/node ./app/scripts/deployment/probe_runtime.mjs +``` + +This checks Node/ABI/architecture, an in-memory SQLite query/integrity check and +one-pixel image encoding. It does not import the app, open hardware sockets, +start scheduled jobs, read health data or bind a service port. Its successful +result proves runtime dependency compatibility only. + +## Separate cutover gates + +### Sensor dependency probe + +Prepare the Python modules in a separate staging tree using each committed +`uv.lock`: `uv sync --frozen --no-dev --no-install-project --no-build`. Keep the +environment, cache and interpreter outside the working Free Sleep installation. +Probe the selected interpreter's standard library before choosing system Python; +do not silently upgrade the numerical Python/dependency contract. + +```sh +python3 scripts/deployment/probe_modules.py \ + --modules-root /absolute/staged/modules \ + --output /absolute/new-private-receipt.json +``` + +The probe imports real installed dependencies in isolated subprocesses and +checks a small NumPy/SciPy calculation. It never imports a daemon, connects to +NATS/DAC, starts a service or opens a database. It records package versions and +the exact project/lock hashes, continues across failing modules, and exits +nonzero if any environment fails. This is dependency compatibility evidence, +not evidence that a running sensor pipeline produces accurate vitals. + +The initial Pod 4 probe found that HeartPy 1.2.7 imports `pkg_resources` without +declaring its provider. The piezo module now explicitly requires +`setuptools>=70,<82`, locked to 81.0.0. Numerical dependencies remain unchanged. +Setuptools removed that API in 82.0.0; remove this compatibility dependency when +HeartPy migrates to `importlib.resources`, after reviewing its numerical changes. +See [setuptools' removal notice](https://setuptools.pypa.io/en/stable/deprecated/pkg_resources.html). +CI runs the real dependency probe before adding test-only packages so mocks and +test tooling cannot conceal a missing production import. + +### Controller switch + +The [controlled handoff](CONTROLLER_HANDOFF.md) provides reviewed service +templates and a bounded trial that restores Free Sleep without changing the +boot default. Installing those units alone does not switch controllers. + +Do not launch `app/server.js` against the working Pod casually: app startup owns +the DAC socket and can initialize hardware, scheduling and networking. Even +`CI=1` is not an authorization boundary for arbitrary API calls. + +Before a controller switch, complete the quiesced data import, source/settings +review, required Python-module environments, service ownership/exclusion, +authenticated private origin and attended operational rollback. Retain original +service units, installation, Bluetooth state, stock configuration and dedicated +SSH. Only one controller owns the DAC socket and base connection. Qualification +and seven actual Preview nights remain separate from this packaging check. + +## Tests + +```sh +python3 -m unittest discover -s scripts/deployment -p 'test_*.py' -v +``` + +The suite checks archive traversal/link attacks, native architecture, secret/data +exclusion, corrupted cached artifacts and existing-directory preservation. It +runs in the Unit Tests CI job and the pre-push gate. Live staging receipts belong +in private local recovery records; no owner credentials belong in this document. diff --git a/docs/OWNER_ACCESS.md b/docs/OWNER_ACCESS.md new file mode 100644 index 000000000..371d8d1b2 --- /dev/null +++ b/docs/OWNER_ACCESS.md @@ -0,0 +1,17 @@ +# Owner access in the unified fork + +This fork requires `SLEEPYPOD_AUTH_TOKEN` (at least 32 characters of securely generated random material) for the controller API and sensor WebSocket. Store it in a private service environment file. Do not place it in a URL, source file, browser local storage, or a public deployment variable. Missing configuration closes the API with HTTP 503; it never falls back to trusting the LAN. + +Native clients send `Authorization: Bearer `. The web login exchanges the key for a signed twelve-hour HttpOnly, SameSite=Strict session. Cookie writes require a matching Origin; streams require a matching hostname because their listener uses a separate port. Rotating the owner key invalidates sessions and closes existing sensor connections on their next thirty-second access check. + +Use private Tailscale HTTPS. Set `SLEEPYPOD_HTTPS_ONLY=1` behind the HTTPS proxy so session cookies require HTTPS; forward the browser's original Host and Origin. The WebSocket must be proxied under the same private hostname. Add that exact hostname to `SLEEPYPOD_ALLOWED_HOSTS` rather than weakening the host check. Plain HTTP remains available for trusted local setup, with bearer credentials visible to that network; HTTPS is the intended ongoing connection. + +`/api/auth/session` exposes only configuration/session readiness without authentication. Route handlers recheck authorization independently of the Next proxy. Demo pages use simulated client data; the demo flag does not remove API authentication. + +Authenticated responses from the generated REST and tRPC handlers, including +errors, use `Cache-Control: private, no-store` and vary by Authorization/Cookie. +The response wrapper preserves streaming bodies and existing adapter headers. + +General-purpose MCP and raw hardware passthrough are disabled. `SLEEPYPOD_ENABLE_MCP=1` and `SLEEPYPOD_ENABLE_RAW_COMMANDS=1` are separate explicit diagnostic opt-ins; leave both unset in the home installation. Health import subprocesses inherit the service environment and send the connection key in an Authorization header, never as a command-line argument. + +These are local implementation checks, not a completed deployment or external security review. The working Free Sleep installation has not been replaced. diff --git a/docs/PERSONAL_BASELINE_PREVIEW.md b/docs/PERSONAL_BASELINE_PREVIEW.md new file mode 100644 index 000000000..b29c1edc1 --- /dev/null +++ b/docs/PERSONAL_BASELINE_PREVIEW.md @@ -0,0 +1,88 @@ +# Personal baseline and sensor-aware Preview + +Personal comfort now offers an explicit **Use Pod sensor guard in Preview** +setting. It persists across stopping/starting Preview and remains off unless +selected or imported from the owner's prior configuration. Preview has no +actuator dependency. Active mode remains locked. Optional [bed-entry timing Preview](BED_ENTRY_PREVIEW.md) +can shift proposed temperature phases without changing the bed. + +```mermaid +flowchart LR + History[Qualified historical Pod estimates] --> Baseline[Equal-weight daily medians] + Current[Fresh quiet-state Pod estimates] --> Windows[Two adjacent five-minute summaries] + Baseline --> Guard[Hold or admit a Preview proposal] + Windows --> Guard + Feedback[Comfort check-ins] --> Offset[Bounded personal offset] + Offset --> Guard + Guard --> Journal[Explanation and source evidence] +``` + +## Evidence and limits + +- Core emits approximately one estimate per minute. Each summary requires five + distinct minute buckets, successive estimates 45–75 seconds apart, and a + completed five-minute UTC interval. This summarizes discrete estimates; it + does not claim continuous five-minute measurement or independent HRV windows. +- Every estimate requires one matching quality row and a live processing run + with firmware event timestamps, logical/physical side identity, numerical + config hash, shipped manifest hash, and its original mechanical-window receipt. + Different processing identities and physical channels are not pooled. +- Receipts must be covered by retained quiet spans. The complete summary interval + must also be quiet. Current summaries and the latest estimate must belong to + the currently confirmed quiet interval. Unconfigured base telemetry is unknown, + not evidence that no base exists. Priming, motion, vibration, missing receipts, + replay rows and legacy observations cannot supply new proof. +- Conservative comparison filters require HR 30–90 bpm, quality score at least + 0.5, and no flags except the receipt or missing non-HR metrics. These are + engineering eligibility thresholds, not clinical limits or calibrated + probabilities. Missing/out-of-envelope estimates hold proposals. +- History covers the previous 29 days, excluding the latest 24 hours. A local + date needs at least twelve complete summaries. The median of daily medians + gives each qualifying date equal weight. Seven dates are required. This is a + **baseline data requirement**, separate from the seven attended/reviewed + Preview nights required before considering activation. It does not establish + seven nights of sleep, benefit, or accuracy. +- Two adjacent current summaries are compared with the baseline. If both differ + in the same direction by at least `max(10 bpm, 20% of baseline)`, proposals are + held. The app makes no diagnosis and does not change temperature or base angle + in response to an abnormal heart rate. +- Apple Health remains a separate reference source; it never fills missing Pod + qualification, baseline days, or current readings. + +## Runtime and contract + +The existing authenticated `/api/v1/comfort` API adds optional `signals` to status +and journal entries. It includes source channel, qualified day/window counts, +baseline reading time, current summary values, observation/expiry times and a +hold explanation. `features.sensorAware` means the Preview adapter exists; +`activation.available` remains false. Old stored events remain readable. + +Baseline construction occurs before acquiring the actuator side lock. Reads are +bounded to 1,500 rows per UTC day and yield to other controls between days. A +15-minute window cache retains the original baseline read time; retained spans +are checked again on each evaluation. Cache keys separate processing and source +identity. Current estimates and quiet state are reread after taking the lock. +Missing/corrupt/oversized inputs fail closed. Cached history never creates a +fresh current measurement, and the latest malformed row cannot be replaced +silently with an older good one. + +Current evidence expires at the earliest of quiet-state expiry, two minutes +after the latest estimate, or six minutes after the latest complete summary. +Status additionally drops decisions after ten seconds without evaluation. The +UI exposes missing/incomplete evidence rather than inventing a baseline. The +existing manual pause, water, presence, routine, alarm and ownership guards +remain mandatory. + +## Validation boundary + +Real migrated SQLite tests cover historical/current receipts, insufficient +days, equal daily weighting, gaps and duplicates, source/config changes, +missing and duplicated quality rows, future/stale clocks, deleted quiet spans, +replay/legacy exclusion, and sustained deviations. Runtime/API/component tests +cover opt-in, persisted manual pauses, lock ordering, expiry, journal evidence, +and rejection of Active mode. The production-browser test exercises owner +authentication, persisted consent, honest empty states and desktop/mobile layout +against isolated databases with hardware initialization disabled. + +These are software checks. No physical movement, vibration, thermal activation, +overnight qualification or improved sleep outcome is established by this change. diff --git a/docs/PERSONAL_COMFORT_PREVIEW.md b/docs/PERSONAL_COMFORT_PREVIEW.md new file mode 100644 index 000000000..d333b18a2 --- /dev/null +++ b/docs/PERSONAL_COMFORT_PREVIEW.md @@ -0,0 +1,141 @@ +# Personal comfort Preview + +This fork now runs a persisted personal comfort **Preview** beside the shared +temperature controller. It defaults Off. Preview has no actuator dependency and +does not submit temperature leases, power, base or vibration commands. Existing +scheduled and manually configured rules retain their own behavior. + +```mermaid +flowchart LR + Sensors[Fresh Pod and calibrated presence] --> Preview[Personal comfort Preview] + Routines[Actual timezone-aware schedule events] --> Preview + Feedback[Local-date comfort check-ins] --> Preview + Manual[Manual controls] --> Pause[Persisted six-hour pause] + Pause --> Preview + Preview --> Journal[SQLite observations and learning cursors] + Journal --> Clients[Web panel and authenticated API] + Rules[Existing rules and schedules] --> Authority[Shared temperature controller] + Authority --> Hardware[Pod hardware] +``` + +## Using it + +Autopilot → Personal comfort shows each side's Off/Preview state, limits, +learned offset, last evaluation, persisted manual pause and recent observations. +Select Start Preview deliberately. The initial range is 70–95°F. The panel +distinguishes a proposed temperature from the reported target and its owner. +An unavailable sensor or journal never appears as a successful observation. + +Check-ins describe a date in the Pod's configured timezone. Today and the prior +fourteen dates are accepted, with one editable check-in per side/date. The +learner consumes each date once, requires at least three new check-ins, changes +at most one degree per routine session, and bounds the learned offset to ±3°F. +Editing an already-consumed date does not make it new training evidence. + +Manual temperature, duration, power, resume, relevant tap gestures, HomeKit +controls and manual priming persist a six-hour personal-comfort pause. Scheduled +power events do not create that pause. If journaling fails, a manual shutdown +still proceeds; the runtime withholds proposals until the missing pause can be +recorded. A restart preserves successfully persisted pauses and learning cursors. + +## API + +All routes use the existing owner Bearer/browser-session authentication. Inspect +`/api/openapi.json` for complete schemas. Dates below are examples, not evidence +of an observed sleep session. + +| Route | Input | Result | +| --- | --- | --- | +| `GET /api/v1/comfort` | Query `side=left` or `right` | State, timestamped decision, recent observations, runtime and unavailable activation gates | +| `POST /api/v1/comfort/configure` | `{side, config: {mode: "off" or "preview", minimumF, maximumF}}` | Persisted configuration and current status | +| `POST /api/v1/comfort/feedback` | `{side, feedback: {date: "2026-10-25", comfort: "warm"}}` | Updated check-in and status; comfort accepts `warm`, `cold` or `comfortable` | + +Equivalent tRPC procedures are `comfort.get`, `comfort.configure` and +`comfort.feedback`. `generatedAt`, `evaluatedAt`, pause timestamps and event +observation times use epoch milliseconds. `deviceDate` is the current local +date in `timezone`. Reads never start the service or hardware. Configuration +updates do not erase a pause, learned offset or consumed-feedback cursor. + +Capabilities distinguish `personalComfortPreview` from `personalComfort`. +Preview is implemented; active control remains unavailable. An `active` request returns HTTP 412. `followBedtime` is now available in +Off/Preview when `sensorAware` is also true (otherwise HTTP 400). The explicit +`sensorAware` option is now available in Off/Preview; see [personal baseline +evidence](PERSONAL_BASELINE_PREVIEW.md). Existing +saved active settings remain inert and display the qualification gate. + +## Input and storage behavior + +In a sole-sleeper arrangement, live comfort presence now follows the same logical +sleeper as the Python recording services: either calibrated zone may supply a +person observation. Both zones must be available, from the same fresh frame and +with valid calibration identities before the combined observation is eligible. +An empty reading from one half alone cannot establish absence. Residual movement +cannot fabricate a calibrated entry transition. Physical-zone diagnostics retain +their original measurements; this does not identify a person biometrically. + +The combined qualification identity includes both calibration sources and the +logical home side. Changing either source withholds the old qualification. Two +active sleepers continue to use separate per-side observations. This adapter does +not change recorded history, saved routines, temperature bounds or activation +consent. Left/right/middle observations on the actual mattress still need attended +validation; synthetic tests establish routing behavior only. + +- Uses the existing scheduler's weekday and DST semantics. A Saturday 22:00 + start does **not** imply a Sunday 07:00 end unless that end event exists. + Overlapping starts, equal boundaries, missing ends and windows over 26 hours + are withheld. Schedule edits invalidate the cache within the current minute. +- Requires a fresh DAC report, confirmed water, powered side, calibrated person + presence, no priming/vibration/base motion, no away state and a known target. + Unknown base telemetry withholds proposals. An alarm's longer wake window is + protected as well as the usual thirty-minute warm-up. +- Does not assume the old Free Sleep five-minute vital-window format matches + Core's processing. The [sensor-aware Preview adapter](PERSONAL_BASELINE_PREVIEW.md) + now qualifies Core minute estimates and recorded mechanical receipts explicitly. + [Bed-entry timing Preview](BED_ENTRY_PREVIEW.md) can shift proposed temperature + phases; snoring-based actuation remains unavailable. +- SQLite migration 0029 adds separate state, feedback and observation tables. + State and its observation are committed together. Malformed state fails + closed; it is never silently replaced with defaults. +- Non-overlapping five-second evaluations record changed decisions immediately + and unchanged decisions at most once a minute. History responses return the + latest 60 records. Hourly pruning retains thirty days and at most 50,000 + observations per side. Shutdown drains admitted work before database close. +- Observations retain proposals, reported targets, owner and sensor timestamps; + they do not claim physical success or manufacture completed Preview nights. + +## Remaining release gates + +This is not deployment or qualification. The physical Pod still runs customized +Free Sleep. The offline importer and corrected idle handoff trial tooling are +implemented separately; see [controller handoff](CONTROLLER_HANDOFF.md). +The legacy `/api/native/v1` adapter has not shipped; native clients use the +authenticated direct version 1 APIs and their capability flags. +Per-night reporting and exact-source owner review are now implemented; see +[Preview night reports](PREVIEW_NIGHT_REPORTS.md). No actual night is inferred +from baseline data, test results or the passage of time. +The shared authority now journals [temperature applications](TEMPERATURE_APPLICATIONS.md), +including uncertain results; Preview still never calls it. Remaining work includes +the active policy admission/application adapter, hardware qualification, seven real +Preview nights with owner review, and bounded activation. A Preview proposal +alone is not evidence of improved sleep or a completed night. + +Validation records are software-only: migrated SQLite reopen/rollback, DST and +ambiguous schedules, input freshness and alarm windows, failure recovery, +feedback cursors, manual/scheduled intent, API gates and the web controls. + +On 2026-10-25, 564 targeted tests and TypeScript/full ESLint/both Drizzle checks +passed before the browser review. The production build passed with isolated +SQLite databases. Loopback HTTP checks verified missing-key 401, status 200, +Preview save 200, activation 412, future feedback 400, OpenAPI and capability +negotiation. Browser checks verified authenticated access, range saving, a +synthetic local check-in, Off/Preview controls and layouts at desktop and 390px +width. The local server deliberately used CI mode: the UI correctly showed a +stopped Preview service and no evaluated night. No real sleep record or owner +connection key was used. Browser review prompted a small spacing fix and an +additional regression for decisions invalidated by a persisted manual pause. + +The final Preview push gate passed 3,124 related tests in 123 files, TypeScript, +full ESLint and both Drizzle checks. One preceding full run had an intermittent +WebSocket authentication failure; its 127-test file and the complete retry +passed without changing authentication behavior. The separate general rules +engine now also enforces [signal validity](AUTOMATION_SIGNAL_VALIDITY.md). diff --git a/docs/PERSONAL_CONTROL_API_V1.md b/docs/PERSONAL_CONTROL_API_V1.md new file mode 100644 index 000000000..ba587d6e8 --- /dev/null +++ b/docs/PERSONAL_CONTROL_API_V1.md @@ -0,0 +1,138 @@ +# Personal bed and control API v1 + +The request/response shapes below are implemented and covered by database and +HTTP tests. They are additive to the household preparation contract. They are +**not deployed or enabled** on the owner's Pod. Full two-person operation also +needs the reviewed assignment coordinator, private history/Health integration, +legacy owner access restrictions, shared telemetry consent and both clients. +The assigned household in the tests is seeded data, not an activation workflow. + +Both `SLEEPYPOD_HOUSEHOLD_PREPARATION=1` and `SLEEPYPOD_PERSONAL_RUNTIME=1` are +required for live personal commands. Both default off. Do not enable these flags +as a substitute for finishing and reviewing the household release gates. +Preparation draft writes remain inert; this change does not publish a schedule, +change assignments, start an alarm or grant access to a partner's Health data. + +## Transport + +All requests use the personal device credential established by preparation v1. +Never use the existing owner key as fallback. Native uses `Authorization: Bearer`; +the browser uses its signed HttpOnly personal session and persisted browser scope. +Browser writes also require same-origin headers and `X-Sleepypod-Sleeper-Id`. +Clients should send that sleeper header on reads too. Browser account switching +uses the existing `X-Sleepypod-Browser-Revision` compare-and-swap mechanism. + +Every response uses `Cache-Control: private, no-store` and +`Vary: Authorization, Cookie`. Errors use `householdErrorSchema`. + +| Route | Method | Contract | +|---|---|---| +| `/api/v1/household/session` | GET | Existing session; `capabilities.personalControls` reflects the current runtime and arrangement | +| `/api/v1/me/bed` | GET | `personalBedSchema` | +| `/api/v1/me/controls` | POST | `personalControlRequestSchema` → `personalControlReceiptSchema` | +| `/api/v1/me/controls/{requestId}` | GET | Own `personalControlReceiptSchema`, or indistinguishable 404 for absent/another person's receipt | + +Schemas: `src/household/bedContract.ts`, `controlContract.ts` and `contract.ts`. +Frozen synthetic fixtures: `src/household/fixtures/personal-controls-v1.json`. +No fixture contains a usable connection key or real sleeper data. + +## Bed reading and per-action routing + +The response pins `podId`, `householdId`, `sleeperId`, `sessionRevision`, +`assignmentRevision` and the opaque `configurationRevision`. The server derives +these and the allowed zones from the current database. A display name or selected +side never establishes identity. The revision also changes when the physical +arrangement, timezone or away modes change. + +`availability` is `ready`, `unassigned`, `configuration_changed` or +`runtime_disabled`. Only `ready` authorizes a new command. Other states return no +zone measurements or routing authority and do not poll the hardware. + +`routing` deliberately separates temperature, power, vibration cancellation, +scheduled vibration and the source routine. A verified solo/follow configuration +allows temperature and power across both zones. Its scheduled vibration still +targets only the source routine's zone; thermal following is not evidence that +the other side vibrates. An assigned person has only their own zone. These fields +describe routing, not a physical qualification or an alarm-edit capability. + +`observation.state=fresh` requires a timestamp less than 15 seconds old and not +in the future. Unavailable/stale readings use null temperatures/power/priming and +unknown water status; they do not become zero, off or the last cached value. +The per-zone `measuredTemperatureF`, `reportedTargetF` and `requestedTargetF` are +distinct. A requested target may exist without a current hardware observation. +`reportedPowered` is null when the status cannot establish power state. +`physicalVerification` is always `unverified` for a network response. + +The response is explicitly constructed from permitted fields. It includes no +raw diagnostics, other-side readings, vitals, gestures, sleep history or account +roster. Device revocation, account switch and configuration changes during an +asynchronous read invalidate that read before it can be returned. + +## Commands and durable reconciliation + +The client persists the exact request before sending it. A request has a random +UUID, the complete last reviewed scope, `issuedAt`, `expiresAt` and one action: + +- `temperature`: integer `temperatureF` 55–110 and `holdMinutes` 1–1440. +- `power`: `powered` plus optional integer `temperatureF` 55–110. +- `vibration.clear`: cancellation only. + +There is no caller-supplied physical side. Base motion, vibration playback, +snooze and routine publication are not part of this contract. Manual control +ranges are not permission to bypass the narrower adaptive-comfort policy. + +The validity window must be positive and at most 60 seconds. Delayed or future +commands fail without a write. Scope and credentials are rechecked at each zone +and every hardware FIFO write. An account switch while a command is queued cannot +carry the old authority into another person's zone. The service also checks the +browser binding and device authorization before returning a private receipt. + +Receipts retain the original `scope`, action and `resolvedZones`. Outcomes: + +| State | Interpretation | +|---|---| +| `executing` | Claimed by this live process; still awaiting an outcome | +| `acknowledged` | The transport acknowledged the dispatched operation on every resolved zone; physical effect remains unverified | +| `not_dispatched` | No hardware write, including a superseded/debounced request | +| `rejected` / `expired` | Refused before a hardware write | +| `unknown` | Some write may have occurred or the process was interrupted; inspect/reconcile before a new action | + +`dispatchCount` counts actual transport writes, not the number of zones. Each +zone can require several writes. If one solo zone was superseded without writing +but the other wrote, the result is `unknown`, not whole-bed acknowledgment. + +After a lost response, GET the same request ID with the same personal account. +An exact duplicate POST also returns the historical receipt without dispatching. +Changing the payload under an existing ID returns 409. Every receipt has +`replayAllowed=false`; reconnecting, rebooting or moving sides never replays it. +Unknown is not a reason to issue a fresh UUID automatically. + +Another account cannot retrieve the receipt, even if it knows the UUID. A later +side move permits the original author to inspect their old receipt but does not +apply it to their new side. No old-side Shortcut is silently redirected. + +## Client integration requirements + +Pin each asynchronous response to the connection, full person scope and local +workspace generation. Discard a result after logout, account change, side move +or a newer view request. Isolate pending requests and caches by Pod, household +and sleeper. The native parent workspace remains the sole Keychain-vault writer; +children receive scoped storage callbacks, not a second vault writer. + +Do not adapt this private DTO into the existing owner-wide device manager or +WebSocket stream. Use a dedicated personal transport/view and the extracted pure +temperature presentation. Refresh session plus bed scope before a new command; +return explicit reconnection/review UI on 401/403/409. Hide controls while capability +is false, preserve unknown outcomes visibly, and keep Stop accessible when current +authority allows it. Weekly schedule drafts still say **draft only**. Publishing +them requires the separately gated [routine publication contract](PERSONAL_ROUTINE_API_V1.md). + +## Atomic routine storage prerequisite + +`replaceBedtimeRoutineBatch` reuses the existing routine normalization, compiled +row ownership and revision checks in one SQLite transaction across both sides. +It can nest in a future reviewed assignment transaction. A stale second side, +failed insert or outer rollback cannot leave the first side partly moved. +Scheduler refresh follows commit and has a separate acknowledgment. This is a +storage primitive; it does not by itself perform an assignment transition, revoke +leases/snoozes or publish a person's draft. diff --git a/docs/PERSONAL_HISTORY_API_V1.md b/docs/PERSONAL_HISTORY_API_V1.md new file mode 100644 index 000000000..0142dedc0 --- /dev/null +++ b/docs/PERSONAL_HISTORY_API_V1.md @@ -0,0 +1,126 @@ +# Private recording history v1 + +This read-only API exposes the existing person-attributed recording store. It is +separate from the legacy side-selected biometric API. The feature defaults off: +both `SLEEPYPOD_HOUSEHOLD_PREPARATION=1` and `SLEEPYPOD_PERSONAL_HISTORY=1` are +required. The personal session then advertises `capabilities.personalHistory`. +This does not enable Health import, assignment activation or cloud analysis. + +The pure shared schemas are in `src/household/historyApiContract.ts`; the frozen +web/native examples are in `src/household/fixtures/personal-history-v1.json`. + +## Reads + +| Method and route | Result | +|---|---| +| `GET /api/v1/me/sleep?startsAt=…&endsAt=…&kind=presence&limit=30` | `personalHistoryPageSchema` | +| Same, with the exact returned `cursor` | Next page from that snapshot | +| `GET /api/v1/me/sleep/` | `personalHistoryDetailSchema` | + +`startsAt` and `endsAt` are canonical decimal UTC milliseconds, with a positive +range of at most 90 days. `kind` is `presence`, `vitals` or `movement`. `limit` is +1–100, default 30. Duplicate, unknown, side, account or sleeper selectors are +rejected. Detail reads accept no query parameters. There is no write operation. + +Responses identify Pod, household, account and sleeper; session, household, +assignment and attribution revisions; generation time; and the explicit Pod +source. A client must compare the returned scope with its current personal +session, even after an earlier successful preflight. Never fall back to the +owner/side APIs, silently adopt a response's account, or retain visible private +records after sign-out, revocation or an account/Pod change. Credentials remain +personal; the owner administrative connection key cannot read this namespace. + +Native uses the personal Bearer credential. Browser reads require the signed +personal session and `X-Sleepypod-Sleeper-Id` matching the current account. +Cross-origin browser requests are rejected. All output is `private, no-store`. +The controller revalidates authority, feature availability and selected records +before delivering the single response chunk. A concurrent account, assignment, +source or attribution change can abort the body: discard it and refresh after +rechecking the personal session. Partial output is not a usable page. + +A missing, foreign, revoked or invalidated record has the same generic 404 +response. Invalid query fields return 400; stale cursors/context or disabled +history return 409; invalid credentials return 401. Storage errors return a +generic 503 without database values. Empty records mean **no attributed records +in this query**, never zero sleep or successful live collection. + +## Complete intervals and snapshot pagination + +Ranges are half-open `[startsAt, endsAt)`. Pages include complete records that +overlap the range. For example, an overnight recording from 23:00 to 07:00 is +included in a query starting at midnight; the original boundaries are retained. +Records are not silently dropped, truncated, proportionally scaled or relabeled. + +`intervalSelection: overlap_full_records` is explicit. Do not count the entire +duration as time within a smaller selected period. Presence totals alone do not +locate individual exits; do not invent a proportional distribution of those +gaps. De-duplicate record IDs across overlapping date queries and group complete +nights deliberately. `reportedPresenceSeconds` is bed presence, not confirmed +sleep. Movement is not a sleep stage. Pod HRV keeps its metric label and must not +be treated as equivalent to Watch HRV. None of these records are Apple Health +references or measured identity recognition. + +Records are ordered by observation start time descending, then internal sequence. +Signed opaque cursors bind the original person, revisions, filters, page size and +snapshot watermark. Later arrivals wait for refresh. Reusing a cursor for another +person/filter, changing ownership, source invalidation, or restarting the +controller expires it; discard the accumulated pages and start a fresh query. +The API never accepts an arbitrary source dataset ID or a caller-provided owner. + +## Ownership and integration limits + +Existing `PersonHistoryStore` admission requires an explicit dated attribution +epoch and a complete source observation. Records spanning an assignment change, +unknown owners and overlapping duplicate presence are withheld. Moving sides +does not transfer historical records. Household administration does not grant +the member's records. This API reads that store; it does not create epochs, +silently claim old Left records, import Health data, or start sensor processes. + +Continuous source ingestion, explicit legacy claims, person-bound Health +consent/import/export, the cross-client history release and the real arrangement +coordinator remain integration work. The new routes do not by themselves make +the two-person release ready. Keep the live flags disabled until the complete +personal recovery and account release gates are satisfied. + +## Personal browser history + +The isolated household workspace includes a private recordings panel backed only +by this API. It does not fall back to the owner-wide biometrics endpoints when +an account has no attributed records. Users choose calendar dates in the +browser's stated timezone and a recording type: bed presence, Pod vitals or +movement. The through date is inclusive, including daylight-saving changes; +the server's maximum range remains 90 times 24 hours. + +The panel retains one page in memory. Older pages replace it; reload starts a +fresh snapshot. A bounded cursor and record-ID set rejects repeated pages and +duplicate records anywhere in the cursor chain, including equal timestamps. +After 256 continuations, users can choose an earlier date range. No recording +body or cursor is written to localStorage, IndexedDB or a recovery journal. + +Each fetch rechecks the personal session before and after reading, validates the +exact response scope and query, and rejects changed attribution or ordering. +Visible loaded history rechecks access every 15 seconds. Filter changes, +backgrounding the tab, account invalidation and disposal clear the page. Late +responses cannot restore it. The history transport and loading state are +independent of bed controls, so a slow history fetch does not disable Stop. + +Records keep historical side labels and complete interval boundaries. Missing +vitals remain unavailable; zero HRV remains zero. Short presence recordings +retain seconds instead of appearing as zero hours. The UI distinguishes an +empty attributed history, an unavailable feature and a failed read, and never +infers a sleep-duration total from these pages. + +```mermaid +flowchart LR + Source[Complete Pod observation] --> Epoch[Reviewed dated attribution] + Epoch --> Person[Person-owned recording] + Credential[Personal credential] --> Self[Resolve self] + Self --> Query[Bounded snapshot query] + Person --> Query + Query --> Recheck[Recheck account and selected records] + Recheck --> Client[Private complete-interval response] +``` + +Validation uses isolated SQLite databases, credential changes, two accounts, +side transitions and the built server. It does not establish physical identity, +sleep-classification accuracy or live ingestion on the owner's Pod. diff --git a/docs/PERSONAL_PRIVACY_BOUNDARY.md b/docs/PERSONAL_PRIVACY_BOUNDARY.md new file mode 100644 index 000000000..b157023ea --- /dev/null +++ b/docs/PERSONAL_PRIVACY_BOUNDARY.md @@ -0,0 +1,94 @@ +# Persistent personal privacy boundary + +This boundary is part of the household integration. It does not activate a second +sleeper, enable any feature flag, change a routine, or deploy to the Pod. + +Preparing an account, accepting an invitation, and saving an unassigned private +routine remain separate from changing the operating bed. Luca's existing solo +arrangement therefore continues during preparation. + +## When the boundary becomes permanent + +SQLite triggers insert a single persistent boundary in the same transaction as +an assigned household, a physical account assignment, assigned history +attribution, or a member's admitted observation. Existing qualifying records are +backfilled during migration. A failed transaction also rolls back its boundary. + +The boundary is not keyed to the current mode or an environment flag. Account +revocation, changing sides, returning to solo, and disabling the household flags +do not remove it. Ordinary SQL updates/deletes of the boundary are rejected. An +administrator with filesystem/root access can still replace a database or install +older software; this is an application privacy boundary, not encryption against +the administrator. Restoring an older installation must be reviewed separately. + +```mermaid +flowchart LR + Join[Prepare personal account] --> Draft[Private preferences and draft] + Review[Reviewed assignment transaction] --> Boundary[Persist personal privacy boundary] + Boundary --> Personal[Only person-scoped application routes] + Boundary --> Shared[Withhold shared presence and vitals] + Boundary --> Legacy[Reject old owner API and raw streams] +``` + +## Network access + +The owner connection key still proves the owner's identity where explicit setup +administration requires it. It no longer grants legacy application data access +after the boundary is present. The proxy and route guards deny old REST/tRPC, +raw records, archive/database downloads, model/diagnostic routes, optional MCP, +and legacy Watch uploads. This deliberately includes old capabilities calls: +old clients need a personal connection, not another owner-key attempt. + +An authenticated legacy request receives HTTP 403 with +`code: personal_connection_required` and the relative +`personalWorkspace: /household/index.html` link. Missing/unverifiable privacy +storage returns HTTP 503 (`privacy_unavailable`). A missing credential remains +HTTP 401. Partial household schema without the boundary is unavailable. A +pre-household database with no account tables retains its original owner flow. + +The auth-session endpoint includes `personalConnectionRequired` and +`privacyAvailable`. The owner web shell unmounts private children and directs the +user to the isolated personal workspace. Old owner login requests cannot mint a +new broad browser cookie after activation. Personal `/api/v1/household/*` and +`/api/v1/me/*` routes continue using their own revocable account credentials. + +Response bodies and raw WebSockets recheck access at delivery boundaries. An +existing stream closes with code 1008 when permission changes. A legacy Health +upload checks while staging and before starting its importer; a one-second +watchdog also interrupts a quiet upload or importer. Each import batch still +passes through the authenticated API. Previously saved batches or delivered +bytes are not undone. + +## Home integration + +Shared occupancy, heart rate, respiration and HRV become unavailable, with null +values/timestamps and `reason: privacy_required`. A fresh sensor value is not +permission to disclose it to everyone using Apple Home or the MQTT broker. +MQTT rechecks personal topics immediately before publishing, including cached +snapshots. Empty retained-message tombstones still pass so stale retained data +can be withdrawn. The HomeKit occupancy characteristic returns an unavailable +error and sets its fault indicator; it never substitutes an empty-bed value. +Both direct reads and periodic notifications recheck privacy. No broker credentials, accessory identifiers or HomeKit +pairings are changed. Shared climate, water and pump status continue as before. + +This version does not add a consent switch or shared-actuator permissions. The +reviewed per-person sharing and common HomeKit/MQTT command policy remain release +gates. Broker subscribers or Apple Home clients may already have stored earlier +observations; the Pod cannot erase those external histories. + +## Validation and remaining integration + +Behavioral checks cover migrated/backfilled installations, persisted revocation, +mode/flag changes, failed-transaction rollback, missing/corrupt storage, owner +browser switching, in-flight response cancellation, actual WebSocket closure, +Health-upload interruption, cached MQTT redaction and actual HomeKit characteristic +reads/polling after access changes. The tests use temporary +databases and synthetic observations, never the owner's live data. + +Two-person activation is still disabled. Its coordinator must lock both sides, +review each person's exact routine, apply dated attribution, quiesce prior +request effects/queues and snoozes, and invalidate incompatible learning and +qualification. A response guard cannot reverse an old mutation that already +committed. Person-bound Health import/history and separately reviewed shared +control consent are also required before release. No endpoint in this change +performs that transition or claims physical validation. diff --git a/docs/PERSONAL_RECOVERY_BOUNDARY.md b/docs/PERSONAL_RECOVERY_BOUNDARY.md new file mode 100644 index 000000000..a0d2c9742 --- /dev/null +++ b/docs/PERSONAL_RECOVERY_BOUNDARY.md @@ -0,0 +1,90 @@ +# Maintenance and recovery with personal accounts + +The private account release needs an administrative health check that does not +reopen the legacy owner API. It also needs recovery that cannot replace private +records with a pre-account backup. These are separate requirements. + +## Administrative status + +`GET /api/v1/maintenance/status` (and the framework's read-only `HEAD`) accepts +the provisioned administrative Bearer connection key. It rejects cookies, +browser Origin / Fetch Metadata, personal-device credentials, query selectors, +and non-read methods. The Host allowlist still applies. No other maintenance +path bypasses the legacy privacy check. + +The version-one response contains only: + +- Protocol identity, response time, controller identity and packaged revision. +- Whole-controller device freshness, thermal readiness and aggregate thermal + idleness (or unknown). +- Whether the persistent personal privacy boundary is legacy, required or + unavailable. Unavailable storage cannot assert thermal readiness/idleness. + +It does not return account identifiers, names, side measurements, occupancy, +targets, schedules, alarms, preferences, Health data, history, credentials or +private-state hashes. It reads the existing monitor and never starts a service, +polls hardware, changes a target or writes to a database. Authentication is +checked before the read and again before response delivery. Output is private +and uncached; rotating the connection key invalidates pending output. + +An idle report means fresh, valid, zero heating countdowns on both zones with +priming off. It does **not** establish an empty bed, stopped vibration, a stationary +base or physical safety to restart. The attended deployment gate still applies. + +The deployment observation client prefers this endpoint and verifies its exact +shape, revision and timestamp. Only HTTP 404 permits compatibility fallback to +the old capabilities/device API. Authorization failures, malformed payloads, +storage failures and unknown protocols do not trigger fallback. New-protocol +payloads are projected into the validated maintenance journal fields. + +## Current recovery hold + +The managed `upgrade.py`, initial handoff and retained-Free-Sleep rollback paths +refuse legacy recovery once any personal household records exist. This includes +unassigned preparation, revoked accounts, personal drafts, attribution epochs +and future populated `household_*` tables. Empty migrated tables and the legacy +browser-scope table alone do not mean that personal accounts exist. + +Checks read committed SQLite/WAL state without reading private row values, verify +the required Core tables, and run SQLite's `quick_check`. A missing, incomplete, +unreadable, corrupt or linked database fails closed. Checks run before +service changes and again after writers stop. Upgrade rollback checks both the +current data and proposed backup before replacing files. A concurrent join or +candidate write is preserved; it cannot silently vanish into an older backup. +If that race is detected after services stop, the operation records +`recovery_required`, retains the data and does not start the older controller. +An ordinary manual rollback rejected before stopping leaves the current runtime +in place. No override flag is supplied to erase this boundary. + +This conservative hold is **not yet a supported personal-household restore**. +It does not protect against manually running older scripts, replacing the whole +storage directory outside these tools, or root access. Personal runtime flags +must remain disabled on the live Pod until the integrated release is qualified. + +## Remaining recovery work + +Before personal activation, implement and test an external durable recovery +record, independent of the replaceable database, with an audited compatible +runtime floor. Runtime admission, packaging, startup and restore must enforce +that record. A declaration must come from the target's committed source, not +from a newer packager pointed at an older checkout. + +The restore protocol must preserve account/device revocations, identity and +assignment revisions, dated attribution epochs, routine publication receipts, +private preferences/history and pending reconciliation. Test power loss at each +durable boundary and restoration of older backups. Use a reviewed recovery path +that never makes another person's data available through an older owner-key API. + +```mermaid +flowchart LR + Operator[Administrative bearer] --> Read[Aggregate status only] + Read --> Fresh{Fresh and available?} + Fresh -->|yes| Attended[Attended maintenance gate] + Recovery[Legacy update or restore] --> Personal{Personal records exist?} + Personal -->|yes or unknown| Hold[Preserve data and hold recovery] + Personal -->|no| Recheck[Stop writers and check again] + Recheck --> Backup[Verified legacy backup and restore] +``` + +Tests use isolated databases and simulated services. None of this document is +evidence of a Pod restart, a physical test or a personal-account deployment. diff --git a/docs/PERSONAL_ROUTINE_API_V1.md b/docs/PERSONAL_ROUTINE_API_V1.md new file mode 100644 index 000000000..ffa3e0ac7 --- /dev/null +++ b/docs/PERSONAL_ROUTINE_API_V1.md @@ -0,0 +1,129 @@ +# Personal routine publication API v1 + +This additive contract separates a person's saved draft from the Pod's operating +week. It is implemented for validation, **not enabled or deployed** on the Pod. +It does not assign a bed side or finish the household release gates. Preparation, +personal runtime and publication must all be enabled explicitly: +`SLEEPYPOD_HOUSEHOLD_PREPARATION=1`, `SLEEPYPOD_PERSONAL_RUNTIME=1` and +`SLEEPYPOD_PERSONAL_ROUTINES=1`. All default off. + +Use the personal transport, browser-scope protection, no-store caching and full +person context described in [the control contract](PERSONAL_CONTROL_API_V1.md). +The optional session capability `personalRoutinePublication` must be true before +offering publication. Older clients treat an absent capability as false. The +existing `GET/PUT /api/v1/me/routines` remains `draft_only`, even after a draft has +been published. A later draft save does not update the operating schedule. + +| Route | Method | Shape | +|---|---|---| +| `/api/v1/me/routines/review` | GET | `personalRoutineReviewSchema` | +| `/api/v1/me/routines/publish` | POST | `personalRoutinePublishSchema` → `personalRoutineReceiptSchema` | +| `/api/v1/me/routines/publications/{requestId}` | GET | Own receipt, or indistinguishable 404 for absent/another account's receipt | + +Schemas: `src/household/routineContract.ts`. Frozen synthetic fixtures: +`src/household/fixtures/personal-routines-v1.json`. They contain no real identity, +credentials or live schedule. The illustrated 69°F edit is an example, not a +change to Luca's 70°F overnight target. + +## Review the whole week + +The server derives the source side and affected temperature zones from the +authenticated account and current arrangement. The client never sends a side. +An unassigned account or inconsistent/disabled runtime receives 409 before any +live routine is exposed. Solo following affects both thermal zones but scheduled +vibration remains on the source side; do not imply vibration on both sides. + +The review includes the full live week, full normalized draft week, Pod timezone, +draft revision, live revision, full control scope and an opaque `reviewRevision`. +The hash binds all of these. Changing a draft, routine, assignment, timezone or +relevant physical configuration invalidates the review. Each change identifies +a night as `create`, `replace` or `remove`; unchanged nights retain their compiled +schedule IDs. A night is named for bedtime: Sunday 22:00 with a 07:00 alarm wakes +on Monday. The routine compiler preserves morning rollover and validates overlaps. + +`canPublish` is true only for `reason=ready`. Other reasons: + +- `draft_not_prepared`: the new account's revision-one draft is untouched. It + cannot replace or clear an existing routine. Load the current week into the + editor explicitly before proposing an edit; never substitute profile defaults. +- `unchanged`: the normalized draft already matches the operating week. +- `calendar_migration_required`: older calendar rows are not owned by the bedtime + routine editor; resolve that migration separately. +- `publication_disabled`: the server's separate publication gate is off. + +Show the before/after comparison for the **entire week**, including removed nights, +temperatures, heating phases, wake days/times, vibration, wake window, snooze, +end action and notes. Preserve fields the editor does not expose; do not silently +reset custom snooze settings. An intentionally cleared week must first be saved +as an empty draft and then reviewed. Merely opening a new account cannot clear it. + +## Publish once, then reconcile + +After review, retain the exact request before sending. It includes a random UUID, +the complete reviewed scope, the opaque review revision, `issuedAt`, `expiresAt` +and both explicit acknowledgments: + +```json +{ + "confirmReplaceLiveWeek": true, + "acknowledgeCurrentTargetEffect": true +} +``` + +The window must be positive and at most 60 seconds. The server checks wall time +and elapsed monotonic time again after waiting for the affected side locks. +The UI must explain that saving an operating schedule can affect the current +target. Publication itself does not claim a physical change or trigger a test +alarm. The ordinary scheduler remains the sole owner of scheduled execution. + +The server rechecks the exact reviewed week, draft and identity under the side +locks. A single durable SQLite transaction saves the changed schedules and their +receipt. Either both commit or neither commits. Scheduler registration happens +after commit and after releasing the side locks. The receipt separates these: + +| Field | Meaning | +|---|---| +| `state=saved` | The operating week and receipt committed to local storage | +| `scheduler=pending` | This process is still refreshing the registered jobs | +| `scheduler=refreshed` | The job manager accepted the refresh; this is not physical proof | +| `scheduler=unconfirmed` | The refresh failed, was interrupted, or its completion was not saved | +| `physicalVerification=unverified` | Network/storage acknowledgment cannot establish actual heating or vibration | + +After a timeout, lost response, process restart or sign-in change, GET the original +request ID using the same personal account. Exact POST duplicates also return the +historical receipt without another save or scheduler refresh. A changed payload +under the same UUID returns 409. Every receipt has `replayAllowed=false`. + +Do not silently resend, issue a fresh UUID or republish to repair an unconfirmed +result. Keep the saved plan visible with the unconfirmed scheduler warning, read +back the current review, and use a separately qualified controller recovery path. +A restarted job manager rebuilds from persisted rows, but this receipt does not +retroactively claim that its old refresh succeeded. A 404 during an in-flight +request is not proof that nothing happened; retain the unresolved attempt. + +Device revocation or a browser switch during refresh withholds the private +response. The committed receipt remains available to another authorized device +of the same account. Another person cannot retrieve it. A later side move does +not replay an old publication onto the new zone. Keep client pending metadata +scoped by Pod, household and sleeper and discard stale view responses after +workspace-generation changes. Never fall back to owner-wide routine endpoints. + +```mermaid +flowchart LR + Draft[Save private draft] --> Review[Compare entire operating week] + Review --> Confirm[Confirm current scope and effects] + Confirm --> Commit[Atomic schedule and receipt save] + Commit --> Refresh[Refresh scheduler] + Refresh --> Result[Saved plus separate refresh result] + Lost[Connection lost] --> Lookup[GET original receipt] + Lookup --> Result +``` + +## Validation boundary + +Real SQLite and HTTP tests cover rollback, stale reviews, disabled/unassigned +accounts, browser switching, revocation, independent fixture sides, receipt +isolation, expired requests, unchanged alarm IDs and failures before/after commit. +The built-route browser test checks the disabled boundary and real GET/POST +exports. Assigned accounts in tests are fixtures, not a shipping side-transfer +workflow. No live alarms, temperatures or assignments are changed by these tests. diff --git a/docs/PERSONAL_SLEEP_LEARNING.md b/docs/PERSONAL_SLEEP_LEARNING.md new file mode 100644 index 000000000..b1650b1bf --- /dev/null +++ b/docs/PERSONAL_SLEEP_LEARNING.md @@ -0,0 +1,150 @@ +# Personal sleep learning and reliability + +## What changed + +An overnight routine can override a manually selected temperature when its hold +expires. A starting target is not the entire curve: remove intermediate points +to hold a constant target until wake warming. Keep wake warming outside the +adaptive comfort range. The native editor now explains this and offers an +explicit draft action to remove later phases before reviewing the whole routine. + +The night view previously selected the longest recording. It now totals the +union of all recorded intervals, grouping morning fragments with the prior +evening until local noon. Overlaps count once; gaps do not count. This is +**recorded bed time**, not proof of sleep. The native night view now requests +stages for every available recording and includes vitals and Watch comparisons +across those intervals. Stage epochs are clipped to their recording boundaries; +missing segments remain unknown and failed classification requests are reported. +No historical records are rewritten or padded to match an expected duration. + +MQTT tests may reuse the saved password only when URL, username and TLS mode +exactly match the saved configuration. A new destination requires an explicitly +entered password. Passwords remain write-only. Live biometric MQTT values require +fresh, calibrated person occupancy as well as existing vital quality checks; +fresh signal estimates from an empty bed are not evidence of a sleeper. + +## Research assessment + +- [Free Sleep](https://github.com/throwaway31265/free-sleep) contains a local + controller, schedules and sensor processing. Its documented heart-rate + evaluation is a small dataset, not evidence that an arbitrary personalized + thermal controller improves sleep. HR, respiration, sleep staging and thermal + preference are different estimation problems. +- [SleepyPod Core](https://github.com/sleepypod/core) supports stage rules, + imported Watch references, experiments and model comparison. Its + [stage calibration research](https://github.com/sleepypod/core/tree/main/docs/research/sleep-stage-calibration) + records a small Watch comparison in which personalized rules did not beat the + defaults. Custom models must beat baselines on later, unseen nights; being + personalized alone is not evidence of improvement. +- The inspected repositories and preserved stock-file inventory did not reveal + a documented, reusable Eight Sleep Autopilot weight file. This does not prove + that no proprietary model exists. We must not label our code as the original + Eight Sleep model or claim that loading a stage model installs Autopilot. +- Eight Sleep describes its + [temperature schedule](https://help.eightsleep.com/en_us/what-is-the-temperature-schedule-BkRoPvR66) + and [Autopilot](https://help.eightsleep.com/en_us/why-does-the-pod-need-autopilot-ByLSWo5Sn) + as coordinating personalized temperature adjustments. Vendor outcome claims + do not validate this implementation. +- Apple Health [sleep categories](https://developer.apple.com/documentation/healthkit/hkcategoryvaluesleepanalysis) + distinguish in-bed and sleep stages. In-bed intervals overlap sleep stages; + adding them would double-count. Watch SDNN HRV and Pod window-to-window beat + interval variability are not interchangeable metrics. + +Hundreds of Health samples are not necessarily hundreds of nights, and old +Health records generally lack simultaneous Pod water/target and bedroom readings. +Use those records for history, sleep timing and aligned Watch comparisons; do not +invent missing thermal features or use the same night for training and testing. + +## Contextual comfort experiment + +`context-neighbors-v1` is a small, local, inspectable regression model. It joins +morning warm/comfortable/cold feedback to the preceding night on the same side. +Inputs are actual target temperature, reported water temperature, qualified +bedroom temperature and humidity. Measurements must originate from production +Preview observations with build and hardware identity, fresh device state, +occupied calibrated presence and no mechanical/manual safety exclusions. + +One sample is retained per five-minute interval. A completed 6–12 hour window +needs at least 72 distinct intervals, six hours between actual first/last samples, +90% integrated coverage, no interior gap over ten minutes, and no edge gap over +30 minutes. The owner must confirm the exact report as an actual overnight +session; source edits invalidate that review. Wake warming is excluded using the +captured pre-alarm boundary. Multiple sessions for the same date and target +curves varying by more than 2°F are excluded: a single morning label cannot +reliably attribute discomfort to one of several materially different targets. +Sparse research observations expire after 90 days and are separately bounded; +ordinary diagnostic events retain their existing 30-day window. + +Warm/comfortable/cold become engineering proxy labels -1/0/+1. These are +**requested correction directions**, not measured ideal temperatures. The +20-night minimum is an engineering gate, not a scientifically validated sample +size. The newest quarter (at least five nights) is held out chronologically. +The model uses up to seven nearby contexts and needs at least three neighbors. +It must beat both holding temperature and predicting the training-set average +feedback, by at least 10% and 0.05 in proxy-label mean absolute error. + +The app reports the matched-night counts, validation result and reason for +withholding a suggestion. Missing or unfamiliar conditions produce no proposal. +An eligible suggestion is limited to ±1°F and is **advisory only**. It does not +enter the temperature controller or bypass its activation gates. Validation of +comfort-label prediction does not establish better sleep or medical benefit. + +```mermaid +flowchart LR + Pod[Qualified Pod observations] --> Nights[Completed matched nights] + Room[Fresh bedroom readings] --> Nights + Feedback[Morning comfort check-in] --> Nights + Nights --> Training[Earlier nights: training] + Nights --> Holdout[Later nights: evaluation] + Training --> Model[Context neighbors] + Holdout --> Compare[Compare with simple baselines] + Model --> Compare + Compare --> Report[Advisory report] + Routine[Reviewed routine] --> Authority[Single temperature authority] +``` + +## Model choice and remaining evidence + +The persistent `comfort-ridge-v1` model now fits five regularized coefficients +on the Pod. Its registry stores the exact dataset/review hashes, configuration, +training/held-out dates and baseline comparisons. It keeps the newest quarter +out of the final fit as well as evaluation. Readiness never starts training; +the authenticated Train action requires the current dataset hash. Changed +reviews, source pruning, configuration mismatches and future model timestamps +withhold inference. It is a whole-night comfort-direction research model, not +a proven minute-by-minute controller. See [the training plan](AUTOPILOT_MODEL_PLAN.md) +for the distinction and remaining release gates. + +A local tabular model is appropriate for this small, structured dataset: it is +cheap to evaluate, does not upload health data and can be checked against simple +baselines. An LLM can explain observations and propose reviewable experiments; +it should not infer missing sensor readings or issue actuator commands. Existing +Apple/local and opt-in cloud intelligence remain available. No cloud vendor is +claimed superior without an evaluation using the actual task and data. + +Before activation, collect real Preview nights, import authorized Watch history, +verify side/timezone/source alignment, compare on held-out nights, and review +withheld decisions. Keep user-selected targets and wake alarms reliable while +collecting evidence. A successful software test, broker connection or model +fit is not overnight or physical-hardware acceptance. + +## Two separate collection requirements + +Sleep-stage validation needs Watch stage intervals that overlap the same side's +Pod recordings. A successful Health import, recent Watch heart-rate samples, or +a sleep chart populated by this app does not establish that overlap. Keep +Watch references separate from this app's own Health exports. The direct native +import shows a destination and side review before writing the permitted Watch +samples; importing does not activate a model or change the bed's controls. + +The thermal model instead needs matched Pod/room observations and morning +warm/comfortable/cold check-ins. Historical Watch stages alone cannot supply +those temperature measurements or comfort labels. The current contextual model +is advisory even after its holdout gate passes; it is separate from the bounded +feedback learner used by personal comfort's temperature policy. + +Personal comfort activation additionally requires the attended observations and +seven source-backed reviewed nights described in +[Owner qualification and thermal activation](COMFORT_OWNER_ACTIVATION.md). +Do not report a running sensor service as proof of fresh measurements, a valid +sleep stage, or a successful physical temperature or wake response. diff --git a/docs/PERSONAL_STAGE_MODELS.md b/docs/PERSONAL_STAGE_MODELS.md new file mode 100644 index 000000000..1b571247c --- /dev/null +++ b/docs/PERSONAL_STAGE_MODELS.md @@ -0,0 +1,91 @@ +# Personal sleep-stage models + +The authenticated Core API can retain portable forest or logistic models trained +off the Pod. Web Settings → Sleep stages and native clients use the same +side-scoped library. This feature labels recorded sleep data; it does not train +models on the Pod or change temperatures, base position, alarms or Autopilot. + +## Selection and evidence + +An upload saves a candidate without selecting or activating it. Preview evaluates +an uploaded candidate against recorded nights without changing the selection. +“Use this model” explicitly selects and activates that model for one side. +“Use rules only” disables model inference and retains the library. A legacy PUT +of a replacement model selects it with `active: false`, including when the +previous model was active. Deleting the selection retains library history. + +Models are deduplicated by canonical content hash, not display version. Full +uploaded metrics remain attached to the artifact. These are uploader claims, +not independently verified scores. The preview response computes agreement only +where Watch stages overlap the Pod epochs. Missing reference data produces no +agreement score. Agreement with a Watch is not clinical accuracy or demonstrated +sleep benefit; do not treat a training score as held-out evaluation. + +```mermaid +flowchart LR + Upload[Portable candidate] --> Library[Side-owned model library] + Library --> Preview[Read-only night comparison] + Library --> Select[Explicit selection and activation] + Select --> Night[Recorded-night classification] + Night --> Identity[Actual model hash or rules fallback] +``` + +## Client contract + +All routes require the existing bearer token or browser session. Paths below +are relative to `/api`; the same procedures are available under `sleepStages` +in tRPC. + +| Method and path | Request | Result | +| --- | --- | --- | +| `GET /sleep-stages/models` | `side` query | Valid candidates with `id`, `modelHash`, metadata, `selected`, `active` | +| `POST /sleep-stages/models` | `{side, model}` | Candidate `id` and `version`; no selection change | +| `PUT /sleep-stages/models/selected` | `{side, id, active}` | Explicit selection; another side's ID returns 404 | +| `GET /sleep-stages/model` | `side` query | Selected document, `uploadedAt`, `modelHash`, `active`; null when unavailable | +| `PUT /sleep-stages/model` | `{side, model}`; model may be null | Replace or clear selection; replacement is inactive | +| `PUT /sleep-stages/model/active` | `{side, active}` | Enable a valid selected model or disable it | +| `POST /sleep-stages/model/preview` | `{side, model?, modelId?, sleepRecordId?, nights?}` | Candidate and rules agreement, night blocks; no write | + +Preview accepts an inline candidate or a side-owned library ID, not both. With +neither, it uses the selected document. It accepts up to 30 nights. No matching +Watch epochs yields `agreement: null` and `pooled: null` rather than a fabricated +score. Demo mode exposes an empty library and rejects model mutations/preview. + +`biometrics.getSleepStages` adds `stagedBy`: + +```json +{ + "engine": "model", + "version": "uploaded display version", + "modelHash": "canonical SHA-256", + "reason": "active_model" +} +``` + +Rules responses use a null model version/hash, with `rules_selected`, +`no_model_epochs` or `model_evaluation_failed`. This describes the classifier +that produced this response. Existing `stageView` remains the adopted sensor / +rules-profile provenance; it is not replaced by a requested model selection. +Clients must use `stagedBy` to label the actual result, not infer it from settings. + +## Validation and migration + +Schema migration 0037 adds an empty library and per-side selection table. No +existing night, rule, routine or biometric profile is replaced. The shipped +rules numerical source stays unchanged. Model documents and hashes are +revalidated after reading storage and before inference; corrupt models are +unavailable, and failed/no-epoch inference falls back to rules. + +The portable format supports the four-column `challenger` and ten-column +`podNative` feature contracts, fixed five-minute epochs and four stage classes. +The JSON budget is 4 MiB, with bounded trees, feature dimensions, self-test +vectors and metadata. Feature windows are limited to 48 hours. Partial trailing +epochs and epochs without heart-rate samples are omitted. Smoothing and +transition constraints never bridge missing epochs. Outlier limits come from +the adopted rules profile. A complete-night normalization makes this a +retrospective classifier, not an instantaneous sleep-state sensor. + +Self-test vectors and the shared numerical fixture check format/converter +compatibility. They do not qualify a personal model or prove better sleep. +Hardware qualification, real held-out nights and the seven-night comfort Preview +remain separate deployment gates. diff --git a/docs/PERSONAL_WEB_WORKSPACE.md b/docs/PERSONAL_WEB_WORKSPACE.md new file mode 100644 index 000000000..f11805bf8 --- /dev/null +++ b/docs/PERSONAL_WEB_WORKSPACE.md @@ -0,0 +1,74 @@ +# Personal browser workspace + +`/household/index.html` combines private account preparation with capability-gated +personal bed controls and a complete weekly routine editor. It is separate from +the owner application; it never mounts owner providers or sensor streams. None +of the household runtime flags is enabled by building or merging this client. + +## The everyday flow + +1. Join with your own invitation, save your personal connection key privately, + and connect your account. Joining alone does not assign a side. +2. When personal controls are enabled, review your permitted zones. Solo mode + shows both zones separately; different reported targets say **Mixed targets**. + Assigned mode exposes only your own zone. Measured water temperature, reported + controller target and last requested target have distinct labels. +3. Choose a new target and hold duration, then Apply. One POST is made. An + acknowledgment is not a physical observation. Live values disappear when + stale; normal controls require fresh, non-priming, water-OK observations. +4. Stop temperature and Stop vibration are explicit actions with fresh authority + checks. They remain available after an unknown ordinary outcome if recovery + storage and the current personal scope are available. Stop preempts ordinary + changes or routine publications still waiting in this workspace before POST. + Already-sent requests cannot be recalled; their outcomes require recovery and + physical review. Stop does not erase future saved routines. +5. Prepare your weekly draft. Every phase, end action, custom snooze second, + wake window, vibration setting and note survives editing. Fahrenheit inputs + preserve the Pod's whole-degree settings; live readings honor the display unit. +6. To start from the operating week, explicitly load it into the editor. It is + never automatically replaced with a new account's default draft. Save privately, + compare all seven nights, review removals, and acknowledge both replacement and + possible current-target effects before publishing. The receipt separates a + saved week from confirmed scheduler registration. + +## Browser recovery and privacy + +The HttpOnly personal session stays outside the module. The transport permits +only specific methods on same-origin personal endpoints, refuses redirects, +limits response bytes, and pins the expected sleeper header. Current session, +assignment and configuration are rechecked before each write. + +Before a POST, an IndexedDB transaction reserves an account-bound recovery +reference across tabs. It contains the Pod/household/account/sleeper IDs, request +ID, expiry, operation kind and a canonical receipt fingerprint. It contains no +credential, routine body, temperature setting or receipt body. Unlike native +Keychain storage, ordinary browser storage is not a secret vault: these identifiers +and fingerprints are local metadata and clearing site data removes them. + +Only the current person's references are displayed. Sign-out, revoked sessions, +account changes and page departure clear the in-memory view and abort reads. +References survive so that reconnecting the same person can GET their own result, +including after an assignment change. There is no replay on reload or reconnect. +A 403/409 after publication may follow a successful commit; uncertainty is retained. +Capacity is bounded and unresolved entries are not silently evicted. An expired +unknown entry can be removed only through an explicit personal-review action; that +never turns an unknown outcome into success or failure. + +## Build and verification + +`pnpm build:personal-workspace` bundles `src/household/web/entry.tsx` into the ignored +`public/household/personal-runtime.js`. Both `prebuild` and `predev` run it. After +editing this isolated entry during development, run the command again. The build +rejects hardware, database, streaming, HomeKit and owner server modules; the pure +Zod validation schema is the only server-directory exception. + +Unit tests exercise expiry, authorization changes, late responses, persistence +failure, Stop ordering and exact receipt/whole-week validation. Browser scenarios +exercise real IndexedDB, reload recovery, whole-week review, phone layout and +large text using intercepted personal API responses. They do **not** prove live +hardware behavior. The separate household browser scenario exercises actual +SQLite/HTTP account isolation and deliberately disabled command/publication APIs. + +Live deployment, reviewed two-person activation, privacy for history/Health and +physical zone/alarm qualification remain separate release gates. This client does +not enable them or change the current Pod. diff --git a/docs/PREVIEW_NIGHT_REPORTS.md b/docs/PREVIEW_NIGHT_REPORTS.md new file mode 100644 index 000000000..ddfebef55 --- /dev/null +++ b/docs/PREVIEW_NIGHT_REPORTS.md @@ -0,0 +1,140 @@ +# Reviewing actual Preview nights + +Autopilot → Personal comfort → **Preview night reports** separates the personal +baseline's qualified data dates from the owner's review of actual overnight +Preview sessions. No earlier check-in, imported sleep record, elapsed calendar +day or synthetic test is automatically counted as a Preview night. + +```mermaid +flowchart LR + Inputs[Current routine and observed Pod inputs] --> Preview[Existing Preview evaluator] + Preview --> Journal[Atomic state and observation journal] + Journal --> Report[Per-side report with provenance and gaps] + Report --> Review[Owner reviews exact digest] + Review --> History[Persisted review and source snapshot] + History --> Progress[Current owner-confirmed night count] + Progress --> Gate[Active remains unavailable] +``` + +## What a report says + +A report groups one side's observations by the original routine identity, +start, end and timezone. Optional bed-entry shifts do not change those original +boundaries. It records runtime/build provenance, configuration changes, proposal +and reported-target ranges, eligibility and hold reasons, manual pauses, sensor +readiness, occupied checks and journal gaps. Reported targets are firmware +reports, not measurements of achieved water temperature or proof of movement. + +The interval total sums **only adjacent checks at most 90 seconds apart**, within +the same runtime and calibrated sensor source. Both checks require fresh device +status, occupied classification with a matching calibrated person-level sample +no older than 30 seconds, and known quiet priming/vibration/base state. It does +not bridge restarts, source changes, missing data or gaps. This is sampled +evidence, **not measured sleep duration** or continuous observation. + +A report can be confirmed when its routine window has ended, at least four hours +of those intervals exist, its source is still retained, and there are no clock, +identity or provenance problems. Four hours is an operational data-coverage +criterion, not a clinical definition of a sleep night. The owner must explicitly +confirm actual overnight use and review the report. Sensor-ready and eligible +proposal totals remain separate: legitimate holds can be reviewed without +claiming the sensor policy was ready or any adjustment occurred. Empty, stale or +mechanically uncertain periods cannot satisfy the occupied-interval requirement. + +The runtime must identify itself as production and record a build revision from +`.git-info`. Development, test, missing-build and unidentified origins remain +inspectable but cannot qualify. Controlled fixtures simulate this contract only +in isolated test databases; their results never constitute real night evidence. + +## Owner review and retention + +The default conclusion is **Needs more observation or retest**. To count a report, +choose **Reviewed actual Preview night** and explicitly confirm overnight use. +Optional notes stay in the local configuration database. Reviews do not issue +commands, change settings, grant actuator leases or unlock Active—even at seven. + +Each review binds the SHA-256 digest of the exact raw journal rows and the last +review ID. The transaction rechecks both after report parsing. A changed journal +or competing conclusion requires a refresh. An identical retry returns the +original review. A later retest conclusion removes that report from the count. +Source changes invalidate earlier conclusions rather than transferring them to +new observations. + +- Existing journal limits remain 30 days and 50,000 observations per side. +- The list returns the latest 30 report identities by observation/review time. + Progress describes this retained set, not a lifetime total. Duplicate local + dates and overlapping routine windows count at most once. +- A report read admits at most 20,000 rows; larger reports require investigation + instead of silently truncating evidence. Parsing yields every 256 rows. +- Detail pages contain 200 observations. Summaries show the top 20 reasons and + first 20 gaps, plus the full gap count. Missing rows are never filled in. +- Review history and immutable summary snapshots retain up to 180 days and + 1,000 entries per side, pruned on review writes. Expired source leaves a + historical review visible but cannot contribute to current night evidence. + +Migration **0040** adds indexed report IDs to the existing journal and a review +table. Legacy rows retain null report IDs. Preserve a consistent pre-deployment +database backup: rollback to an earlier binary must restore its matching +database and optional JSON-state schema, not reuse newer migrated data. + +## Native and web API contract + +Use the existing owner Bearer credential or browser session. Reads never start +the runtime or hardware. OpenAPI contains full response schemas. +`GET /api/v1/comfort?side=left` advertises optional +`features.nightReports: true`; absent or false means unavailable. +`features.followBedtime` remains separate. + +| REST route | Input | tRPC procedure | +| --- | --- | --- | +| `GET /api/v1/comfort/reports` | `side` | `comfort.reports` | +| `GET /api/v1/comfort/report` | `side`, 64-hex `id`, optional positive `afterId` | `comfort.report` | +| `POST /api/v1/comfort/report/review` | JSON shown below | `comfort.reviewReport` | + +```json +{ + "side": "left", + "id": "<64-hex report ID from the server>", + "digest": "<64-hex digest from the inspected report>", + "expectedReviewId": null, + "outcome": "needs_retest", + "confirmedActualNight": false, + "note": "Inspect the gaps before another observation session." +} +``` + +Use the latest `review.id` instead of null when replacing a review. `outcome` +accepts `reviewed` or `needs_retest`; `reviewed` requires explicit true +confirmation. Notes are trimmed and limited to 2,000 characters. Side is required +on every call; report lookup is side-scoped. Timestamps are epoch milliseconds; +display dates use the recorded timezone. + +List responses include `apiVersion: 1`, `generatedAt`, `side`, `items`, +`targetNights: 7`, `ownerConfirmedNights`, `activationAvailable: false`. Each item +includes `summary`, nullable `review`, `reviewCurrent` and +`countsAsOwnerConfirmedNight`. Detail adds `observations` and nullable +`nextAfterId`. The list's deduplicated count is authoritative; do not sum item +flags. `summary.state` is `collecting`, `closed` or `source_expired`. + +HTTP 400 indicates invalid input; 404 means no report for that side; 409 requires +refreshing changed evidence or a competing review; 412 means unfinished or +insufficient evidence. Malformed storage fails closed. Missing owner credentials +return 401 on REST and tRPC. Failed refreshes must not appear as fresh progress. +When paging, retain the report digest and discard prior pages if it changes. + +**There is no `/api/native/v1` forwarding layer in this Core revision.** Native +clients must call the authenticated routes above directly and negotiate their +features. `nativeCompatibility` remains null. Optional `timing` fields from +[bed-entry Preview](BED_ENTRY_PREVIEW.md) likewise use the direct comfort API. +Do not infer availability from older migration planning documents. + +## Verification boundary + +SQLite, runtime, API and browser tests exercise sparse/stale/ambiguous evidence, +mechanical interference, restarts, source changes, clock reversal, atomic review +failure, concurrent journal changes, retention, paging, side isolation and exact +review conflicts. Seven synthetic reviewed dates still leave Active unavailable. +Browser screenshots and tests use isolated databases and no Pod commands. They +do not demonstrate physical qualification, actual overnight use, Watch agreement +or improved sleep. Attended migration, hardware qualification and real Preview +nights remain separate release gates. diff --git a/docs/QUIESCED_IMPORT.md b/docs/QUIESCED_IMPORT.md new file mode 100644 index 000000000..b7735eb92 --- /dev/null +++ b/docs/QUIESCED_IMPORT.md @@ -0,0 +1,110 @@ +# Prepare current Free Sleep data for an attended migration + +`scripts/deployment/prepare_data.py` replaces revision-specific preparation +snippets with a reproducible capture and review step. It never starts Core, +selects a boot controller, replaces live data or accepts migration warnings. +It **does stop and restart Free Sleep's two writers**, so run it only during +the approved attended idle window with no impending routine or alarm. + +```mermaid +flowchart LR + Preflight[Plan, service, timezone and idle checks] --> Stop[Stop original writers] + Stop --> Snapshot[Transactional backup and unchanged-source checks] + Snapshot --> Restore[Restart and verify idle Free Sleep] + Restore --> Import[Import into new candidate folder] + Import --> Review[Review translations and rollback evidence] + Snapshot -->|Failure or interruption| Restore +``` + +Use the locked migration interpreter, the exact reviewed deployment/migration +sources and their SQL journals. The backup program remains the companion +Free Sleep repository's `scripts/migration/backup.py`; inspect that revision +and obtain its SHA-256 independently before transfer. The command verifies +that digest before stopping services and again before executing the program. +Do not obtain the expected digest from an unreviewed downloaded file itself. + +```sh +/private/migration-env/bin/python /reviewed/core/scripts/deployment/prepare_data.py capture \ + --plan /persistent/sleepypod-unified/service-plan-REVIEWED \ + --legacy-data /persistent/free-sleep-data \ + --backup-tool /private/reviewed-free-sleep-backup.py \ + --backup-sha256 REVIEWED_64_CHARACTER_SHA256 \ + --output /persistent/sleepypod-unified/new-quiesced-preparation +``` + +The existing plan must match installed, disabled Core units and the original +enabled Free Sleep units. A live/unfinished Core handoff, active Core service, +changed unit definition, active heating/priming/vibration, unavailable timezone, +wrong backup digest, existing destination or overlapping application/data path +fails before downtime. The command takes the same plan lock as trial/activation. + +After both legacy control groups have stopped, capture verifies that settings, +routines, baselines, comfort history and journal are unchanged and match the +backup manifest. Missing optional sources are also compared: an added journal +cannot be silently omitted. If a writer restarts or source changes during +capture, the attempt fails and the new files remain for investigation. + +Free Sleep is restored **before** running the importer. Even a disk-full error +does not put a journal write ahead of the restart attempt. Restoration verifies +the original unit identities, active processes, enabled state and idle API +report. It sends no corrective temperature, power, vibration or base command. +An unexpected physical effect still needs the nearby owner's report. + +The output is private and contains: + +- `backup/`: original data/private archives and their manifest. +- `candidate/`: the full current importer output, including all three databases, + preserved originals and source archives. Credentials stay in the private archive. +- `preparation.json`: durable phase, source/tool identities, capture/restoration + times and review-report hash. `readyForCutover` remains false. +- Private capture/import logs. Do not publish them or the source archives. + +The importer verifies every archive entry/checksum and applies the checked-in +database journals; there are no fixed routine counts, migration numbers or +accepted-warning lists in this command. All translation warnings remain in +`candidate/migration-report.json`. Import failure occurs after Free Sleep has +recovered, and does not remove the captured rollback files. + +## Recovery and subsequent review + +Ordinary exceptions and SIGINT/SIGTERM/SIGHUP enter restoration. A power loss, +uncatchable kill, changed services or service-manager failure can still leave +the operation interrupted. Inspect `preparation.json` and restore only the +original services with the same reviewed plan: + +```sh +/private/migration-env/bin/python /reviewed/core/scripts/deployment/prepare_data.py recover \ + --plan /persistent/sleepypod-unified/service-plan-REVIEWED \ + --output /persistent/sleepypod-unified/new-quiesced-preparation +``` + +Recovery refuses a different plan/service identity or a completed preparation. +It never recaptures, imports, resumes Core or copies a database. Keep the original +installation/SSH and separate installation/Bluetooth recovery archives intact. +No command here proves a restored boot image or physical thermal operation. + +Copy the backup off-host and verify it. Review enabled routines and alarm +warm-up/hold differences, temporary overrides and pending calibration. A +separate explicit data promotion must preserve the current candidate and copy +**all three** database files plus the report and required original data; keep +credential archives outside Core's runtime data. Do not point a service at the +preparation folder or copy over an open SQLite database. Use the documented +[controller handoff](CONTROLLER_HANDOFF.md) only after that review. + +The activation preflight requires the separate history database and its +byte-preserved original. It checks the import checksum, stored provenance, +SQLite integrity and absence of history sidecars. A partial two-database copy, +substituted snapshot or old report without history evidence is refused before +Free Sleep is stopped. The live history that Free Sleep continues writing is +not compared against this dated snapshot. + +Free Sleep resumes writing after capture. The candidate is a dated snapshot, +not synchronization; activation must recheck original configuration, and a new +quiesced capture is needed if the owner wants later history included. This +workflow does not count as an attended Core trial, physical qualification or +any of the seven required Preview nights. + +The deployment test suite uses temporary synthetic data, real importer SQL +and simulated systemd/API responses. It covers capture/import order, preserved +data, drift, interrupted work, disk-full restoration and recovery ownership. +No test starts an actual controller. diff --git a/docs/ROOM_CONTEXT.md b/docs/ROOM_CONTEXT.md new file mode 100644 index 000000000..130ed0431 --- /dev/null +++ b/docs/ROOM_CONTEXT.md @@ -0,0 +1,149 @@ +# Home Assistant room measurements + +Home can now display mapped temperature, humidity, CO₂, fine-particle (PM2.5) +and light measurements. Input is disabled by default. This is a sensor adapter; +it does not pair accessories, change Apple Home pairings, create an Autopilot +rule, or issue commands to a humidifier, purifier, light or bed. + +```mermaid +flowchart LR + Sensor[Source measurement and timestamp] --> HA[Existing Home Assistant entity] + HA -->|Non-retained MQTT| Validate[Mapping, revision, units, clocks and replay checks] + Validate --> History[Bounded local history] + Validate --> Live[Fresh room signals] + Live --> Rules[Owner-created Autopilot rules] + Rules --> Arbiter[Existing temperature arbitration and guards] +``` + +## Setup + +1. Configure the existing MQTT connection in Settings. Use private networking, + authenticated broker accounts and topic ACLs. TLS retains certificate checks + unless the separate self-signed option was deliberately configured. +2. In Home → Sensor mapping and setup, select an existing `sensor.*` entity, + its actual unit, and an attribute containing its source measurement time. + Inspect the integration and Developer tools → States to verify that attribute. + It must advance for new measurements, including unchanged values, and must + not advance just because a cached state was polled or restored. +3. Select ISO 8601 with timezone, Unix seconds or Unix milliseconds to match + that attribute. Set maximum age (30–900 seconds) to the sensor's reporting + cadence. If no trustworthy timestamp exists, leave the sensor unmapped. +4. Save, then generate sensor publishers. Create one empty Home Assistant + automation for each document, choose Edit in YAML and paste the document. + JSON is valid YAML. Each document publishes only that entity, on changes and + every 30 seconds, preserving the original source timestamp. Replace previous + publisher documents after **every** configuration save. +5. Enable mapped room input when the source mapping is verified. A new sample + must arrive before the reading becomes fresh. This does not activate any + Autopilot rule. Review rules in Preview first; hardware qualification and + adaptive-activation gates remain separate. + +Do not substitute `now()`, `last_changed`, `last_updated`, or `last_reported` +for an unavailable measurement timestamp. HA's reported timestamp tracks state +writes rather than physical sampling, and MQTT may replay retained states or +suppress unchanged state writes. [HA state-report semantics](https://contributors.home-assistant.io/blog/2024/03/20/state_reported_timestamp/), +[HA MQTT state updates](https://www.home-assistant.io/integrations/mqtt/#the-last-reported-state-attribute). +An attribute named `last_seen` is only an example, not a universal guarantee. + +## Topic and payload + +Core subscribes to exactly `//room/report`; the current +resolved topic is returned by the owner API after subscription acknowledgment. +The home publisher account should have write permission only on this topic; +Core needs read permission there. Pod telemetry uses the existing outbound +topics. Room input does **not** require `MQTT_COMMANDS_ENABLED`; keep that +actuator permission off unless separately needed and qualified. + +One JSON object per report (all clocks are UTC epoch milliseconds): + +```json +{ + "schemaVersion": 1, + "source": "home_assistant", + "revision": 1, + "metric": "temperature", + "entityId": "sensor.example_bedroom_temperature", + "unit": "°C", + "reportedAt": 1793000000000, + "measuredAt": 1792999999000, + "available": true, + "value": 20 +} +``` + +This is an illustrative historical payload; it must not be replayed as current. +An unavailable report has `available:false`, `value:null`, `measuredAt:null` and +a current publisher `reportedAt`. No HTTP endpoint accepts sensor reports. + +The store rejects unknown fields, wrong revisions/entities/units, non-finite or +out-of-range values, future clocks, publisher age ≥30 seconds, measurement age +at or beyond the configured limit, payloads over 2 KiB, retained/duplicate MQTT +packets, and older/equal timestamps. Repeating a sample with a new publisher +clock cannot extend its expiry. Both source clocks must be synchronized. +At most one accepted report per sensor per five seconds is persisted; an +unavailable transition still withdraws a fresh sample immediately. + +Replay watermarks persist across restarts. A broker disconnect or process +restart clears live eligibility; cached history never repopulates it. A new +measurement is required after reconnect. Malformed messages are ignored rather +than trusted as an availability update; any previous sample keeps its original +expiry. A valid unavailable report withdraws it immediately. + +## Native and web API (version 1) + +All endpoints require the existing owner bearer credential or browser session. +Configuration contains private entity names, but no Home Assistant bearer token +or broker password. Do not export household entity identifiers into public source. + +| Method / path | tRPC | Result | +|---|---|---| +| `GET /api/v1/home/context` | `roomContext.status` | `apiVersion`, `generatedAt`, `revision`, `config`, `connected`, `topic`, `observations` | +| `PUT /api/v1/home/context/configuration` | `roomContext.configure` | Send `{expectedRevision,config}`; returns `{revision,config}`; stale revision is HTTP 409 | +| `GET /api/v1/home/context/history?beforeId=…` | `roomContext.history` | Up to 200 newest rows, stable `nextBeforeId`, original clocks, source entity, normalized unit/value and configuration revision | +| `GET /api/v1/home/context/publisher` | `roomContext.publisher` | `{revision,automations:string[]}`; each string is one JSON/YAML automation; disconnected broker returns HTTP 412 | + +Config: `{enabled,roomName,mappings}`. Each mapping contains `metric`, `entityId`, +`unit`, `timestampAttribute`, `timestampFormat`, `maxAgeSeconds`. Up to five +mappings; entity IDs and metrics must be unique. A full save increments revision, +withdraws live readings and invalidates the previous publisher even if fields +appear unchanged. Read after an ambiguous save; do not blindly retry it. + +Observation status is `fresh`, `disabled`, `disconnected`, `waiting`, +`unavailable`, `stale`, or `clock_invalid`. Only `fresh` includes a live `value`. +`lastValue` is historical context, never a live fallback. Show unit, entity, +`measuredAt` and expiry. `connected` means the subscription was acknowledged, +not that any sensor or accessory is online. Poll while foregrounded, hide live +values on read failure, and expire them locally between polls. The web also +withholds values after 15 seconds without a successful status refresh. + +History retains up to 30 days **or 25,000 accepted reports**, whichever is +smaller. Purging happens on ingestion; reads exclude expired/future rows even +without new data. Latest watermarks remain separately persisted. History is +available for reports/export; historical Autopilot backtest overlays are not +connected to this data source yet. + +## Automation and capability boundaries + +Signals: `room.temperature` (°F), `room.humidity` (%), `room.co2` (ppm), +`room.pm25` (µg/m³), `room.light` (lx). These never replace `ambient.*` readings +from the Pod. The existing rule engine withdraws dependent policies when live +input disappears, including rules with older window samples. Configuring room +input does not add or activate rules and cannot bypass the temperature arbiter, +manual hold, water, priming, freshness or other existing actuator guards. +`features.homeContext.implemented` is true; runtime becomes ready only while at +least one mapped measurement is fresh. It does not certify physical sensors. + +House Control remains the authority for accessory capability mappings: the +saved diffuser mapping supports power only, and the purifier exposes discrete +fan settings. No additional writable capabilities are inferred here. Household +accessory actions and scene templates remain separate, inactive workstreams. + +## Verification boundary + +SQLite and real loopback MQTT tests cover source timestamps, units, replay, +restart, disconnect, availability, retention and policy withdrawal. Production +browser checks exercise authentication, mapping conflicts, disabled/default +state and responsive Home controls. These are synthetic local checks. Real HA +entity timestamp qualification, publisher import, broker ACLs, Pod rollout and +attended hardware checks are still required. Neither an API acknowledgment nor +a successful fixture proves a physical sensor or actuator works. diff --git a/docs/SCHEDULE_LIBRARY.md b/docs/SCHEDULE_LIBRARY.md new file mode 100644 index 000000000..f5144eadd --- /dev/null +++ b/docs/SCHEDULE_LIBRARY.md @@ -0,0 +1,91 @@ +# Schedule library and overnight ownership + +Reviewed integration of upstream `a9d10d456dd250c2060c330715a9c0bbc1238f76`, +adapted to preserve Free Sleep's complete overnight routines and paused rows. + +Named calendar curves hold set points in Fahrenheit, an end action, notes, +selected weekdays for each side, and an active flag. At most one active curve +claims a given side and calendar day. Activating another curve moves overlapping +days atomically; a curve with no remaining days becomes inactive. The web editor +shows those moves before submitting. Alarms remain separate and unchanged. + +```mermaid +flowchart LR + Review[Review curve and day moves] --> Save[Authenticated mutation] + Save --> Guard{Imported overnight ownership?} + Guard -->|Yes| Reject[412 without writes] + Guard -->|No| Transaction[Save claims and schedule rows atomically] + Transaction --> Register[Register current rows with scheduler] + Register --> Result[Return saved state and schedulerApplied] +``` + +## Client contract + +Discover `features.curveLibrary.implemented` using `/api/v1/capabilities`. +All routes require the existing owner credential or browser session. + +| Method and path | Behavior | +| --- | --- | +| `GET /api/curve-templates` | Lists named curves, including `days: { left, right }`, `active` and `endAction`. | +| `PUT /api/curve-templates` | Creates a curve, or updates by optional positive `id`; writes its days and active flag atomically. Returns `{ curve, moved, schedulerApplied }`. | +| `POST /api/curve-templates/active` | Accepts `{ id, active }`; moves overlapping claims when enabled. Returns the same change envelope. | +| `POST /api/curve-templates` | Legacy save by name; retains existing days and active state, rewrites its rows if active. Returns the curve with `schedulerApplied`. | +| `DELETE /api/curve-templates?name=...` | Removes the named curve's active claims and calendar rows. Returns `{ deleted, schedulerApplied }`. | + +The PUT payload includes `name` (1–30 characters), `bedtime`, `wake`, +`points` (2–48 unique `HH:mm` keys with temperatures), `notes`, +`endAction` (`turn_off` or `maintain`), `days`, and `active`. An active request +without days is stored inactive; explicitly enabling a dayless existing curve +returns 400. Duplicate names return 409. The `moved` entries identify affected +curve names, sides, days and whether each curve became inactive. + +These are calendar weekdays, **not bedtime-owned nights**. The server refuses +any rewrite on a side containing imported bedtime ownership or explicit next-day +power offsets. That includes activation, editing an active curve or clearing its +rows. It rolls back both sides, claims and metadata if either side is protected. +Save an inactive template without changing schedules, or use `/api/v1/routines` +to edit an imported night. The web UI retains the complete-bedtime editor for +those sides. Library support does not convert existing overnight routines. + +An edit to an active curve preserves matching rows' enabled states; new points +on a fully paused day also stay paused. An explicit off/on cycle recreates that +curve's rows enabled. Existing global, away and schedule-group guards still +apply. Individual calendar-row edits release that day from the library when +they change its content; enabled-only edits retain its claim. + +`schedulerApplied: false` means the database save committed but scheduler +registration failed. The UI reports the pending registration; inspect controller +diagnostics before relying on that schedule. `true` means the operation's job +registration completed, or no registration changes were needed. It does not +prove a future job will execute or that hardware moved, heated or vibrated. +After delayed scheduler startup, registration reads current rows instead of +reviving deleted rows or overwriting a newer edit. Existing scheduler liveness +reconciliation remains the recovery path. + +Library mutations have no expected-revision input or durable command receipt. +The displayed day-move review is a snapshot; concurrent clients can change +claims before submission. The server's returned `moved` list is authoritative. +Do not promise stale-editor rejection equivalent to `/api/v1/routines`. + +## Migration and validation + +Generated migration 0035 adds library metadata. Migration 0036 leaves a marker +for one-time adoption of compatible unmanaged calendar schedules. Adoption adds +claims without rewriting the schedule rows. It skips entire sides with bedtime +ownership or explicit next-day power offsets. Adoption and marker removal share +a transaction; failure retains the marker for the next start and leaves the +existing schedules usable. Back up databases before upgrading; use the saved +installation and pre-upgrade databases together when rolling back. + +Behavioral coverage includes claim moves, transaction rollback across sides, +paused rows, independent alarms, scheduler failure, concurrent scheduler startup, +demo parity, migration retry, authenticated browser saves and reloads. The +browser runner creates isolated fixture databases; it does not connect to a Pod. + +On 2026-10-25 an offline upgrade of a private backup of the snooze rehearsal +preserved all 14 imported routines and every temperature, power and alarm row. +Integrity and TypeScript ownership checks passed; no imported side was adopted +into the calendar library. Both source database hashes remained unchanged. +The aggregate private record is +`~/Downloads/SleepyPod-Library-Rehearsal-2026-10-25/library-verification.json`. +No controller was started and no hardware command was sent during that rehearsal. diff --git a/docs/SERVICE_RESTART.md b/docs/SERVICE_RESTART.md new file mode 100644 index 000000000..7d261fb6e --- /dev/null +++ b/docs/SERVICE_RESTART.md @@ -0,0 +1,160 @@ +# Service restart contract + +Device → Restart service uses authenticated `POST /api/health/restart-service` +with `{ "unit": "sleepypod.service" }`. The three existing sensor targets remain +supported. The endpoint never invokes the updater, downloads a version, selects +a different controller, or reboots the operating system. + +The response remains `{ ok: boolean, message: string }`. An accepted Core or +broker request means **requested**, not completed or physically verified. +Definite pre-submission refusals return `ok: false`. A lost reply, interrupted +process, unsuccessful systemctl exchange, or unknown broker receipt produces an +error: clients must reconnect and inspect health before considering another +attempt. There is no automatic retry or fallback. Web Device and Health actions +show the returned message and require an explicit reconnect after acceptance or +uncertainty. Checking telemetry does not itself prove successful recovery. + +## Existing installations + +Without `SLEEPYPOD_MAINTENANCE_SOCKET`, the existing sudo transport is retained. +Core uses `sudo -n systemctl --no-block restart sleepypod.service`; module +commands retain their original synchronous form. The standard installer grants +only the exact additional Core command. A pre-execution failure remains a +refusal; an interrupted or unsuccessful child exchange remains uncertain. + +## Isolated deployment supervisor + +Core keeps `NoNewPrivileges=true`. Its optional supervisor runs separately as +root, behind `/run/sleepypod-maintenance/control.sock` (`root:dac`, mode `0660`). +It has no TCP listener, no hardware client, no API key, no shell command parser, +and no updater. It can request only Core and the three named sensor services. +The root service itself uses `NoNewPrivileges`, an empty capability bounding +set, a read-only filesystem, private devices, and a writable private runtime +receipt directory. + +```mermaid +flowchart LR + Owner[Authenticated owner request] --> Core[Unprivileged Core] + Core --> Socket[Private Unix socket] + Socket --> Gate[Peer + invocation + plan + expiry checks] + Gate --> Receipt[Durable attempt] + Receipt --> Systemd[Fixed systemd request] + Systemd --> Reconnect[Reconnect and inspect health] +``` + +Every request has a random ID, the systemd invocation ID, and a lifetime of at +most five seconds. Kernel peer credentials must identify the current Core main +process under `dac`; the exact systemd cgroup and invocation must match. The +selected root-owned plan must match installed unit bytes and have no unexpected +overrides or pending jobs. A Core restart also checks its dependent sensor +units. Validation runs again after writing the attempt receipt. + +The broker shares the selected plan's existing `handoff.lock` with migration, +activation, and replacement. It opens that lock read-only and cannot rewrite +the plan. A handoff trial holds the lock throughout its transition, so restart +requests are intentionally refused during that trial. It submits fixed +`systemctl --no-block --job-mode=fail` commands; Core +uses `try-restart`, so it cannot start an inactive controller during rollback. +Sensor recovery may start an inactive module only while its verified Core parent +is current. Root operator actions outside these tools still require operational +coordination. + +The receipt in `/run/sleepypod-maintenance-state/attempt.json` is written and +flushed before submission, and retained across supervisor restarts. Duplicate +requests never submit again. New attempts have a 60-second cooldown. An +interrupted attempt stays unknown for that Core invocation; reconnecting does +not erase the receipt or authorize replay. After a new Core invocation, the +cooldown still applies. A corrupt or unreadable receipt refuses maintenance. + +If reconciliation is needed, inspect the root-only receipt, systemd jobs, unit +status, and invocation before archiving a resolved receipt. Do not delete an +unknown receipt merely to make retry available. Receipts contain service and +request metadata, not credentials or biometric measurements. + +## Deployment and qualification + +`scripts/deployment/prepare_offline.py` includes the broker and declares +`maintenanceProtocol: 1` in the runtime manifest. Existing bundles without that +declaration are refused, even if a similarly named file is present. Provisioning +checks the broker's hash and the build's source identity against the manifest. + +Add this optional field to the new Core service-plan configuration: + +```json +"maintenanceSocket": "/run/sleepypod-maintenance/control.sock" +``` + +`service_plan.py` places the setting in the reviewed Core unit. Existing plans +without the field render exactly as before. Do not edit the private credential +environment: an overriding maintenance entry there is refused. Prepare and +select this new stopped Core plan using the usual `handoff.py`/`replace_plan.py` +workflow before provisioning its helper. Keep the previous plan and release. + +`maintenance_plan.py` renders the two separate units for review: + +```sh +python3 scripts/deployment/maintenance_plan.py \ + --controller-plan /persistent/sleepypod-unified/service-plan-REVISION \ + --release /persistent/sleepypod-unified/releases/REVISION \ + --output /persistent/sleepypod-unified/maintenance-plan-REVISION +``` + +Rendering does not install or start anything. With the new Core plan stopped +and disabled, and retained Free Sleep active and enabled, run as root: + +```sh +python3 scripts/deployment/maintenance_install.py inspect --plan /persistent/sleepypod-unified/maintenance-plan-REVISION +python3 scripts/deployment/maintenance_install.py install --plan /persistent/sleepypod-unified/maintenance-plan-REVISION +python3 scripts/deployment/maintenance_install.py enable --plan /persistent/sleepypod-unified/maintenance-plan-REVISION +``` + +`inspect` is read-only and shares the existing controller lock. `install` +publishes only absent unit files, records its intent before writes, and leaves +both stopped. `enable` selects only the maintenance socket at boot and starts +its listener. The helper activates on a connection. It does not inherit Core's +environment or owner key. These commands never start Core or stop Free Sleep. + +The installer verifies private root-owned plans, unchanged Core/legacy units, +safe path ownership, and matching release identity. It shares the controller's +existing `handoff.lock` and serializes its own operations. It does not rewrite +the credential file or any controller, sensor, SSH or stock firmware unit. + +To recover an interrupted installation or undo enablement: + +```sh +python3 scripts/deployment/maintenance_install.py rollback --plan /persistent/sleepypod-unified/maintenance-plan-REVISION +``` + +Rollback requires retained Free Sleep ownership too; if Core has been activated, +first use its existing controller rollback procedure. A completed activation +rollback is accepted. Cleanup uses the private installation journal and exact +unit hashes, so it can remove a failed helper even when its runtime or credential +file is unavailable. Changed units, overrides and unverified active processes +are preserved for operator review. Interrupted enablement is reconciled through +rollback rather than replaying the enable command. + +Rollback stops/disables/removes only the two matching maintenance units. It +preserves plans, journals, releases, databases, credentials and broker attempt +receipts. Repeated rollback is safe after verified removal. Use a newly rendered +maintenance-plan directory for a later installation rather than erasing its +journal. Before replacing a Core plan, roll back its bound helper while the old +Core plan is stopped, then provision the matching helper for the new plan. + +The broker requires live qualification on each deployment. Tests use temporary sockets, synthetic process state, +and substituted systemd effects; they cannot establish live restart behavior. +`python3 scripts/deployment/probe_maintenance.py` provides an inert Linux probe +when run as root on a host with the `dac` account. It uses the real Unix socket, +kernel peer credentials, NoNewPrivileges, and a read-only existing lock. Service +properties and submission are deliberately substituted; no service command is +executed. Its temporary `/run` files are removed on exit. This checks IPC and +receipt behavior on the target OS without qualifying a live restart. +Attended qualification must verify requested versus observed restart, Core and +sensor recovery, preserved controller ownership, interrupted replies, and the +rollback path. No unattended temperature, base, vibration, or house automation +is authorized by this maintenance feature. + +References: [systemd socket permissions](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html), +[kernel peer credentials](https://man7.org/linux/man-pages/man7/unix.7.html), +[systemd job modes and try-restart](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html), +[retained runtime directories](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html), +[No New Privileges](https://docs.kernel.org/userspace-api/no_new_privs.html). diff --git a/docs/STAGE_COVERAGE.md b/docs/STAGE_COVERAGE.md new file mode 100644 index 000000000..b1a5456e7 --- /dev/null +++ b/docs/STAGE_COVERAGE.md @@ -0,0 +1,20 @@ +# Recording boundaries in sleep-stage views + +Rule-based staging assigns a provisional duration to each sensor sample. Its last +sample previously extended five minutes beyond its timestamp, including beyond +the recording end. Same-stage chart blocks also joined across arbitrarily large +sensor gaps. Both could overstate classified time. + +Stage views now clip model and rule epochs to the requested recording and the +next epoch. Rule previews and candidate-model comparisons use the same bounds. +Block merging joins only touching or overlapping epochs of the same stage; +missing samples remain visible gaps. These changes do not turn occupancy into +measured sleep or establish that the experimental stage labels are correct. + +The biometric manifest and stage component advance to 1.0.1, pinning both stage +source files. Old evaluation receipts are not silently treated as validation of +the corrected implementation. Existing incompatible personalized profiles retain +their stored identity and use the established explicit fallback/review path. +Before deployment, inspect active profiles and retain the controller rollback. +No sensor calibration, routine, alarm, temperature policy or actuator is changed +by these view corrections. diff --git a/docs/TEMPERATURE_APPLICATIONS.md b/docs/TEMPERATURE_APPLICATIONS.md new file mode 100644 index 000000000..3f6001f5c --- /dev/null +++ b/docs/TEMPERATURE_APPLICATIONS.md @@ -0,0 +1,93 @@ +# Temperature application evidence + +The shared temperature authority now records each attempted target write in +SQLite before calling the hardware client. This is separate from a policy +proposal, the arbitration winner, firmware status and physical observations. +Off and Preview still do not submit personal-comfort leases. Active remains +unavailable; this journal alone does not unlock it. + +```mermaid +flowchart LR + Producers[Manual controls, routines and rules] --> Authority[Shared temperature authority] + Authority --> Attempt[Persist attempt and selected owner] + Attempt --> Hardware[Existing hardware write and state update] + Hardware --> Result[Acknowledged or unknown result] + Result --> History[Authenticated application history] + Preview[Personal comfort Preview] --> Proposals[Separate proposal journal] +``` + +## Interpretation + +- `attempting`: an attempt was saved, but no final result was durably recorded. + It may still be in flight or may have been interrupted. Never display it as + proof that a command is currently running or succeeded. +- `acknowledged`: the existing hardware client call and its state update + completed, and that result was saved. This is **accepted, not physically + verified**. It does not prove the achieved cover temperature or sleep benefit. +- `unknown`: dispatch, its state update or final receipt could not be confirmed. + A command may have partly or fully reached the firmware. The record does not + authorize a retry. + +`targetF` and nullable `durationSec` describe the requested write. They are not +sensor readings. `source` and `requestId` identify the selected owner immediately +before the call, after transport recovery has rechecked arbitration. An +unselected proposal creates no receipt. A fallback power-on or unowned keepalive +has null source and request ID. `reason` distinguishes `manual`, `power_on`, +`reconcile` and `keepalive`. +This covers the shared controller's target-write paths. It is not a complete DAC +traffic log: power-off, raw diagnostic passthrough, pump-stall recovery outside +the shared apply path, base movement and vibration are outside this journal. + +Each actual attempt has a separate UUID; this is an audit record, **not an +idempotency key**. Existing manual, schedule and keepalive behavior is retained. +The journal never starts or replays requests. MQTT and conditional controls keep +their separate request-level deduplication contracts. + +If the initial durable write fails, the temperature command is withheld. Power +off does not depend on this journal and remains available. If the hardware call +succeeds but recording its result fails, the attempt remains unconfirmed; the +controller keeps the successful manual hold and its in-memory target, avoiding +an extra command caused solely by that audit failure. A clock reversal likewise +cannot manufacture an acknowledgment. A hardware or state-update error preserves +the existing error behavior and records uncertainty when storage permits. + +## Owner API + +`GET /api/v1/device/temperature/applications?side=left` and tRPC +`device.temperatureApplications` use the normal owner authentication. Side is +required. Pass optional `beforeId` from `nextBeforeId` to page older records. +Reads do not start the controller or hardware. +Capabilities advertise optional `endpoints.temperatureApplications` and +`features.temperatureApplicationHistory`. Older servers may omit these fields; +clients must then treat the history as unavailable. This does not change the +separate `controlSemantics.receiptScope` for deduplicated versioned controls. + +The response has `apiVersion: 1`, `generatedAt` (epoch milliseconds), `side`, +`items` and nullable `nextBeforeId`. Each item includes monotonically ordered +`id`, UUID `attemptId`, `attemptedAt`, nullable `finishedAt`, `outcome`, +`acknowledgment` (`accepted_not_physically_verified` or `unconfirmed`), `reason`, +`source`, `requestId`, `targetF` and `durationSec`. Times are milliseconds, while +the explicitly named duration is seconds. Pages hold at most fifty records. +Missing credentials return 401; invalid side/cursor returns 400; unavailable or +malformed storage fails rather than masquerading as an empty history. + +Migration **0043** adds the journal. Retention is thirty days and at most 10,000 +records per side, pruned transactionally before admitting a new attempt. Reads +exclude aged records even during idle periods. No earlier commands are backfilled. +Rollback to an earlier binary uses its matching consistent database backup. + +## Remaining work and validation boundary + +This revision provides command evidence, with migrated SQLite, interruption, +storage-failure, arbitration, side-isolation, pagination and authenticated API +tests using synthetic data and mocked hardware. It is not a live Pod test. +The physical installation remains on Free Sleep. + +The [personal execution adapter](COMFORT_APPLICATION_ADAPTER.md) now implements +guarded policy dispatch, durable correction cursors and conservative handling of +uncertain attempts. It is not constructed by production until the separate +owner activation, qualification and reviewed-night admission service is complete. +No Preview night or physical qualification is claimed. The web and native visual +designs are unchanged; clients can adopt this optional read-only history without +changing existing controls. Internally, the recorder also returns its result so +the adapter can distinguish a durable acknowledgment from a failed receipt save. diff --git a/docs/THERMAL_SESSION_EVIDENCE.md b/docs/THERMAL_SESSION_EVIDENCE.md new file mode 100644 index 000000000..f554bbd19 --- /dev/null +++ b/docs/THERMAL_SESSION_EVIDENCE.md @@ -0,0 +1,40 @@ +# Passive control requires a current heat session + +The first bounded Pod 4 migration trial on 2026-10-25 reached authenticated +Core status and live NATS frames, then restored Free Sleep. The subsequent +firmware check found that the left side had started an eight-hour session. +It was explicitly returned to its pre-trial off state and verified off. That +trial is **not accepted as a successful operational migration**. + +Free Sleep had stopped the countdown while firmware retained a non-neutral +target. Core's status mirror inferred power from that target. Once the first +status arrived, passive temperature arbitration selected the recurring target +and wrote a new duration. A fresh report alone was therefore insufficient. + +```mermaid +flowchart LR + Target[Retained target or saved power] --> Withhold[Withhold passive writes] + Countdown[Positive live countdown] --> Evidence[Process-local session evidence] + Command[Acknowledged target and duration] --> Evidence + Evidence --> Arbitration[Existing locked temperature arbitration] + Stop[Neutral target, confirmed expiry or acknowledged stop] --> Revoke[Revoke evidence] +``` + +The production controller now requires both a synchronized firmware report and +per-side heating-session evidence before passive reconciliation can write. +Evidence is shared across server chunks but is not persisted across a restart. +Pump RPM/duty and retained targets cannot establish it. Explicit manual and +scheduled power-on operations remain available through the existing authority. +Fresh command protection prevents delayed status from undoing a new command. + +On firmware that always reports a zero countdown, passive control waits for an +explicit successful temperature/duration command after restart. It does not +guess that an old target is still active. The raw target remains available for +diagnostics; it is not proof of physical heating. Pump-stall protection keeps +its separate evidence and behavior. + +Regression coverage includes fresh-but-ambiguous startup, per-side isolation, +passive loop/Autopilot/Resume, explicit manual control, positive countdown and +expiry, delayed polls, failed duration writes, and partial shutdown failure. +These software checks must be followed by another bounded Pod trial, including +firmware power/countdown comparison before and after restoring Free Sleep. diff --git a/docs/UNIFIED_IMPLEMENTATION.md b/docs/UNIFIED_IMPLEMENTATION.md new file mode 100644 index 000000000..3735f6214 --- /dev/null +++ b/docs/UNIFIED_IMPLEMENTATION.md @@ -0,0 +1,225 @@ +# Unified Sleep foundation + +This fork starts from stable v3.4.0 (`f8948b04586542fab1817aadc72b65a9c2988f8c`). +It remains AGPL-3.0; the existing license and upstream attribution are retained. +The active native companion is in https://github.com/lucaandrea/sleepypod-ios. +The original Free Sleep native app remains in its separate repository; it is not +the current UI reference. See [navigation](UNIFIED_NAVIGATION.md). + +## Implemented locally + +- Separate read-only imported history, with source-labelled web views and direct v1 pagination for native clients. Original records remain byte-preserved; they are not live measurements or Preview-night evidence. See [Imported history](IMPORTED_HISTORY.md). +- Owner authorization across REST, tRPC, raw data, exports, diagnostics and the live sensor socket. See [owner access](OWNER_ACCESS.md). General MCP and raw actuator passthrough default to disabled. +- Legacy Pod 4 capacitance profiles and frame quality/freshness checks, with incomplete readings treated as unavailable. +- SleepyPod presentation: original neutral light/dark tokens and 3D temperature controls. Phone navigation matches the native Temp, Schedule and Sleep destinations, with a Settings entry for secondary controls. Desktop retains direct navigation for Base, Autopilot, Home, System and Settings. The former Free Sleep palette generator has been removed; `app/globals.css` owns the web theme. +- Autopilot's persisted setting is loaded before timers start. Missing/unreadable settings and new installations default to disabled. Existing explicit settings survive the generated migration. +- Persisted personal comfort Preview, with default 70–95°F limits, one-degree steps, 30-minute spacing, six-hour manual-pause semantics and explicit freshness/presence/water/artifact guards. The production runtime records proposals without changing hardware; active comfort remains unavailable. See [Personal comfort Preview](PERSONAL_COMFORT_PREVIEW.md). +- Passive policy proposals join the existing temperature authority without being erased by rule refreshes. Each producer owns its leases; failed freshness/consent guards revoke them, and the controller rechecks after transport recovery. Policy proposals cannot turn an off side on or extend a hardware duration. +- Next.js updated from 16.4.0 to 16.4.3 after review of the official October 14 security update and patch release notes. + +## Validation + +521 targeted tests passed across authorization, endpoint gates, streaming, occupancy, +navigation, startup settings and migrations. Changed TypeScript files pass ESLint. +The production build passes using Node 24 and pnpm 10.34.6. For the isolated build, +both SQLite databases were initialized/migrated before Next's parallel page workers; +the first attempt against new files hit WAL initialization contention. + +The broader pre-push gate subsequently passed TypeScript, full ESLint, both Drizzle +schema checks and 3,507 related tests across 148 files. Authentication fixtures and +a rolling-date fixture were corrected; the piezo processor's locked uv environment +was installed for the Python integration test. API status/diagnostics now report +Autopilot disabled when settings are missing, matching the startup default. + +The subsequent comfort policy/arbitration change passed 184 targeted tests across +the learner, shared controller, database-backed controller instance and rule engine. +These are deterministic software tests, not evidence of improved sleep outcomes. + +Demo screens were inspected in the browser; they are labeled sample data and do not +operate a Pod. This is not deployment, hardware verification or an external security audit. +Production-browser checks reject an invalid synthetic connection key, accept a valid +local test key, and return to the connection gate after signing out. Real Pod credentials +were not used in these checks. + +## Remaining integration gates + +Production Preview wiring, the reversible offline importer, room-context input and +foreground notification delivery now exist in source, as documented below. Native +clients use negotiated direct version 1 APIs; the legacy compatibility adapter has +not shipped. Broker provisioning, actual home integration, hardware qualification +and the active comfort admission/application adapter remain required. Shared +[temperature application receipts](TEMPERATURE_APPLICATIONS.md) now record +attempts and acknowledgments separately from proposals and physical observations. +Active must include the +global Autopilot opt-in in every lease guard; Off/Preview must never submit leases. +Per-night reporting and exact-source owner review are now implemented in +[Preview night reports](PREVIEW_NIGHT_REPORTS.md), with separate real-night gates. + +The working Pod has not been switched to this fork. No base movement, vibration or new +automatic thermal action was issued. Preserve its installation and dedicated SSH service +through migration and restore testing. Seven actual Preview nights are required before +reviewing bounded activation; no nightly evidence is synthesized. + +## Web presentation alignment, 25 October 2026 + +The owner's current client direction uses SleepyPod's original presentation. +The web shell now follows its neutral theme and Temp / Schedule / Sleep phone +navigation; Settings keeps secondary controls, diagnostics and browser sign-out +accessible, with a direct desktop rail. Existing `/more` bookmarks redirect to +Settings. Owner authentication, explicit demo labeling and hardware guards remain. +The full-screen 3D bed has its own Settings entry and ignores navigation swipes; +interactive controls likewise do not become page swipes. The renderer uses the +reviewed local vendor file, with no CDN fetch. The light-mode 2D fallback now +inherits readable foreground colors. + +The production build passed. The full ten-workflow browser run passed, followed +by two focused navigation/rendering checks after visual review: both software +WebGL rendering with external HTTP blocked and the forced 2D fallback passed. +Light/dark 390px screenshots and desktop navigation were inspected. The initial +focused suite passed 271 tests; 37 renderer/bed tests passed after the fallback +fix. These checks use isolated synthetic databases and no physical Pod commands. + +## Mechanical signal qualification + +See [Mechanical data quality](MECHANICAL_DATA_QUALITY.md) for the new observed +quiet-span journal, pre-dispatch exclusions, bounded vital-output quarantine and +automation eligibility. This is software validation; the working Free Sleep +controller remains the rollback owner until the attended migration gates pass. + +## MQTT command delivery + +MQTT now defaults to telemetry only. Explicitly enabled commands reuse the +durable control service with UUID deduplication, deadlines at the hardware write +boundary, non-retained receipts and bounded ingress. Home Assistant templates +generate 30-second requests. See [the protocol](MQTT_COMMANDS.md). This source +change does not enable the broker on the owner's Pod or qualify hardware. +Dedicated broker credentials/ACLs and sensor freshness for external automations +remain separate integration work. + +## Qualified home telemetry + +MQTT now uses source timestamps and per-field availability, current mechanical +proof for live vitals, and calibrated occupancy. Live environmental/pump caches +reject replay backlogs. HomeKit ambient and occupancy sensors fault when missing +or stale. Unsupported clog discovery is withdrawn. See [the contract and +validation limits](HOME_TELEMETRY.md). This is source-level integration; broker +provisioning, actual pairing, sensor qualification and household automations +remain pending. + + +## Personal baseline in sensor-aware Preview + +The comfort panel now offers an explicit Pod sensor guard in Preview. Historical +live estimates require recorded quality and mechanical receipts, consistent +processing/source identity, complete five-minute summaries and equally weighted +qualified local dates. Fresh adjacent current summaries can hold proposals when +they differ substantially from that baseline. The API/journal retain evidence +and expiry; historical scanning yields before taking the hardware side lock. +See [Personal baseline Preview](PERSONAL_BASELINE_PREVIEW.md). + +The seven baseline-data dates are distinct from the seven real Preview nights +and hardware qualification required for activation. Optional [bed-entry timing](BED_ENTRY_PREVIEW.md) +now shifts proposed phases only. Active comfort, actual phase adjustments, +snoring elevation and unattended motion remain unavailable. The +working Pod still runs Free Sleep; this source change does not deploy Core or +change live control. + + +## HomeKit reported-state availability + +Thermostat reads now require fresh post-write DAC observations; absent values +are unavailable rather than fabricated neutral temperatures or OFF modes. +Current mode derives from the reported current level, while explicitly staged +targets remain requests. Telemetry and pump-warning faults combine without +clearing each other. Stable identities and existing command sequencing remain. +See [HomeKit reported state](HOMEKIT_REPORTED_STATE.md) for source semantics and +the remaining actual Apple Home qualification boundary. + +## MQTT transport requirements + +The web form, partial settings updates, test probe and running bridge now reject +plaintext broker URLs when TLS is required. The URL and port remain explicit. +Unreadable saved settings stop new connections instead of discarding a stored +TLS policy. Connection tests use only the password entered for that test. +The settings page explains these behaviors and clears stale test results when +connection inputs change. See [MQTT configuration](integrations.md#configuration). + +Local validation includes actual loopback MQTT, MQTT/TLS and secure WebSocket +handshakes, certificate rejection and zero socket connections for a TLS/URL +mismatch. These fixtures contain no household credentials and do not provision +the real broker, enable Pod control or qualify physical effects. + +## Explicit service restart + +Device settings now uses the allowlisted maintenance endpoint instead of the +software updater. Core restart requests are queued without waiting for the +calling service to stop itself; denied requests and lost replies are distinguished +from acceptance. See [the contract and deployment limits](SERVICE_RESTART.md). +The isolated Pod runtime still needs a scoped supervisor mechanism compatible +with its existing privilege restriction and attended restart/recovery testing. +The optional restricted supervisor now provides the source implementation: a +private Unix socket, current-process and reviewed-plan checks, expiring requests, +durable attempt receipts, and a shared migration lock. Rendering its service +plan does not install or enable it. See [Service restart](SERVICE_RESTART.md) +for provisioning and the remaining physical qualification. +The deployment tool now supports inspect, stopped installation, socket enablement +and rollback for that helper. Core opts in through its reviewed unit, so private +credentials are never rewritten. Failed installs and lost enablement replies +retain a recoverable journal; removal does not require a working release. The +tooling does not replace the pending attended restart and recovery qualification. + +## Automation alert delivery + +Live notification actions now persist an owner-authenticated inbox and return a +durable receipt. The shared web toolbar shows new alerts while the app is open, +read/expired history, explicit acknowledgment and network failures. Dry-run rules +record Activity proposals only. See [Automation notifications](AUTOMATION_NOTIFICATIONS.md) +for the REST/tRPC native contract, retention, deduplication and restart behavior. +Production-browser and real SQLite checks use synthetic alerts and do not operate +hardware. Native integration, APNs/Web Push and live Pod qualification remain +separate work; background push is explicitly unavailable in the API. + +## Home Assistant room input + +Home now supports explicitly mapped, timestamped room temperature, humidity, +CO₂, fine-particle and light measurements over the existing MQTT connection. +Source clocks, revisions, units, expiry and persisted replay watermarks gate +the live signals; broker loss and restart withdraw them. The owner UI and +versioned native API expose mapping, status, bounded history and generated HA +publishers. See [Room context](ROOM_CONTEXT.md) for the contract and qualification +steps. Input is opt-in and does not activate rules or household actions. Synthetic +loopback/browser evidence is separate from deployment and physical acceptance. + +## Full personal check-ins and atomic comfort mode + +The morning journal now preserves restfulness, habits and private notes alongside +comfort feedback. The offline importer restores full original entries with source +provenance, and the web editor uses revision checks, retained drafts and explicit +conflict/deletion handling. Habit comparisons keep current Core and imported +Free Sleep bed-presence sources separate; unknown data is not zero. + +The direct comfort mode operation reads current settings under the write lock, +so a mode switch preserves concurrent limit/sensor updates. Active remains locked. +Native adoption, the future one-occurrence alarm override, target deployment and +physical qualification are separate gates. See [the API and storage contract](MORNING_JOURNAL.md). + +## One-occurrence wake changes + +The Temp alarm card now separates skip/time/restore for one wake occurrence from +weekly schedule editing. The authenticated native-ready contract, persistent +compare-and-swap semantics, temperature spans, durable attempt records and +migration limits are documented in [Next alarms](NEXT_ALARMS.md). Existing +weekly schedules are preserved; actual Pod vibration remains unqualified. + +## Disabled house templates + +Six optional [Home Assistant blueprints](../integrations/home-assistant/README.md) +cover selected wind-down, night-path, morning bed-exit and empty-bed scenes, plus +humidity/PM2.5 review notifications. All start disabled and require an explicit +Allow input inside their action sequences. The scene templates do not command +the Pod; room review uses source timestamps and withdraws expired proposals. +Morning bed exit is not synchronized to a Pod alarm, and automatic appliance +control remains unavailable until device capability and behavior qualification. +Real Home Assistant schemas and script execution are validated locally with +synthetic states and stub services. Actual home import, broker provisioning, +device selection and physical acceptance remain separate gates. diff --git a/docs/UNIFIED_NAVIGATION.md b/docs/UNIFIED_NAVIGATION.md new file mode 100644 index 000000000..dc8758879 --- /dev/null +++ b/docs/UNIFIED_NAVIGATION.md @@ -0,0 +1,28 @@ +# SleepyPod navigation + +The current design follows the owner's updated SleepyPod direction. Phone tabs +are **Temp, Schedule, Sleep**, matching the active `lucaandrea/sleepypod-ios` +project. The older five-destination `free-sleep/ios` shell is not the active +native UI. Future parity checks must inspect `Sleepypod/SleepypodApp.swift` +in its own checkout rather than infer behavior from the original app. + +On phones, Settings and the temperature-stage Settings shortcut keep Autopilot, +Home, System, owner connection controls and configured base controls accessible. +Desktop retains the direct SleepyPod sidebar. Existing `/more` bookmarks redirect +to Settings. Added Free Sleep capabilities use these existing surfaces. + +The bottom tab bar grows with text size and safe-area insets. Its measured height +reserves content space and keeps the fixed 3D bed above the bar. Labels remain +unbroken and keyboard focus remains visible. Settings rows, segmented choices +and temperature-control previews wrap within the available width. Swipes follow the three primary +routes and do not hijack controls or the 3D stage. + +Browser regression coverage checks secondary routes, owner sign-out, light/dark +appearance, 305px and 320px viewports at 200% text with scrollbar space, keyboard navigation and the bed/tab-bar +boundary. Fixed evening/morning clocks and preview scrubbing cover both edges +of the timeline; timestamp labels stay inside the plot and the long preview +button wraps when needed. Offline WebGL and the 2D fallback remain covered. The fallback uses normal +scroll flow, with its toolbar, both side controls and timeline in order rather +than fixed overlapping regions. This is +software/browser verification; it does not establish physical Pod behavior or +complete feature parity with the independent native implementation. diff --git a/docs/WATER_RESPONSE_FORECAST.md b/docs/WATER_RESPONSE_FORECAST.md new file mode 100644 index 000000000..1b2034592 --- /dev/null +++ b/docs/WATER_RESPONSE_FORECAST.md @@ -0,0 +1,144 @@ +# Water response forecasting + +`water-response-ridge-v1` is a local, fitted model of the **reported water +temperature** 15 and 30 minutes ahead, conditional on an unchanged target and +similar room conditions. It runs in TypeScript on the Pod. It is a research +component for a future controller, not a body-temperature estimate, sleep-stage +model or command to change the bed. No cloud API or API key is involved. + +## Why this component exists + +The morning comfort model predicts a warm/cold direction from nightly medians. +That does not describe how the water responds over minutes, or prove that a +particular adjustment improves sleep. These are different learning problems. + +[Eight Sleep describes Autopilot](https://www.eightsleep.com/autopilot/) as using +personal preferences, activity, stages and room conditions for adjustments. Its +published product description supplies neither transferable weights nor a +validated control law for our software. + +The [2025 sleeping thermoregulation study](https://doi.org/10.1016/j.buildenv.2025.112882) +models skin temperature using physiological and bedding parameters and evaluates +ten participants under controlled conditions. Our water sensor does not provide +those inputs. The [sleep comfort model published online in 2025, volume 2026](https://doi.org/10.1016/j.enbenv.2025.07.003) +separately models thermal sensation and discusses bedding microclimates. Neither +paper validates this Pod forecast. They support keeping water response, body +temperature and perceived comfort distinct. Our regression, sample thresholds and +error gates below are explicit engineering choices, not results from those papers. + +## Data and timing contract + +The existing complete-night audit remains the entry point: + +- A morning check-in plus a current actual-night review bound to source hashes. +- Production recordings from the same side, hardware, calibration, routine and + room configuration; fresh occupied measurements without manual pauses or motion. +- A 6–12 hour window, at least six actual recorded hours, at least 90% thermal + coverage, no interior gap over ten minutes or edge gap over 30 minutes. +- The pre-alarm window excludes wake warming. Temperature targets spanning more + than 2°F across the night are excluded by the shared comfort-data audit. + +The forecast does not use the check-in's warm/cold label, but deliberately shares +that reviewed-night cohort in this version. Historical Health totals without +paired thermal observations cannot substitute for recordings. Complete short +nights currently abstain rather than being padded to meet these filters. + +For each whole night, the model uses actual observations about five minutes +apart, with 30 seconds of cadence tolerance. Each forecast origin needs an actual +five-minute predecessor. Origins are at least 15 minutes apart. Labels are +observed water changes 15 or 30 minutes later, within 45 seconds. A window is +excluded if any intervening target changes, a sample is missing, room temperature +changes more than 2°F, or humidity changes more than ten percentage points. Each +night needs twelve eligible origins at both horizons. These future conditions +select the conditional evaluation domain; they are never input features. + +At prediction time the only features are: + +1. An intercept. +2. Current target minus water temperature, divided by 5°F. +3. Current room minus water temperature, divided by 10°F. +4. The actual preceding five-minute water change, divided by 2°F. +5. Current relative humidity minus 50%, divided by 20 percentage points. + +These fixed scales are not fitted to future observations. Each horizon has five +ridge-regression coefficients and predicts water change directly. It does not +roll predictions forward as if they were measured observations. Each night has +equal total fitting weight. Regularization is 0.01 except for the intercept. + +## Whole-night evaluation and abstention + +Require 20–90 distinct complete nights. Reserve the newest quarter (minimum five) +for final evaluation, and the preceding quarter (minimum five) for error-band +calibration. Only earlier nights fit coefficients, the simple-response baseline +and supported conditions. At the minimum this is 10 fitting, 5 calibration and +5 evaluation nights. No epochs from one night cross these partitions. + +Compare both horizons with persistence (hold the latest water reading) and a +one-parameter first-order response toward the current target, fitted only on the +training nights. Error bands use the 90th percentile of each calibration night's +90th-percentile absolute error, with a 0.25°F minimum. They are empirical error +bands, not a guarantee of 90% coverage or medical confidence intervals. + +Both horizons must independently meet all these prespecified gates on later nights: + +- At least 90% of conditions supported by three distinct fitting nights. +- At least 80% of observed outcomes inside the empirical error bands. +- Night-equal mean absolute error at least 10% and 0.1°F below the better baseline. + +The current target must be within 0.25°F of observed fitting targets, and scaled +feature distance must be at most 1.5 for at least three distinct fitting nights. +This prevents unsupported target extrapolation. In particular, fitting at 70°F +does not authorize predicting the effect of an unobserved 105°F wake target. +Any failed horizon, invalid input, changed source/configuration, clock rollback, +reference older than 45 days, or predicted error band outside 50–115°F withholds +both forecasts. New nights alone do not invalidate unchanged source references. +Edited or deleted reference data does invalidate them. + +These thresholds are conservative software gates, not a powered clinical trial. +Repeated tuning against the same holdout would bias results; a new model design +must reserve genuinely unseen evaluation nights. The model does not establish +the causal effect of changing a target, even after passing its forecast gates. + +## API, persistence and interface + +All endpoints retain the controller's owner authentication: + +- `GET /api/v1/comfort/forecast?side=left`: reviewed-night readiness, exclusions, + source identity and saved evaluation. Coefficients and support rows remain local. +- `POST /api/v1/comfort/forecast/train`: explicit fit with the expected dataset + hash; stale inputs return conflict, insufficient nights return precondition failed. +- `GET /api/v1/comfort/forecast/preview?side=left`: conditional forecast using fresh + guarded current readings and an actual matching preceding observation. + +Artifacts, per-night proofs and integrity hashes are stored in `thermal_forecasts` +in the controller database. Fitting checks the dataset again before durable save. +Readiness and inference never write a control request, change a mode or start +training. Duplicate fitting of unchanged data reuses the existing artifact. + +The web Autopilot page presents “How your bed responds,” with separate readiness, +water predictions, empirical error bands and baseline comparisons. An empty +dataset is shown as collecting, with training disabled. Native build 18's model +inventory covers stage estimation and private night anomaly analysis; this new +water forecast card is a separate web addition, not present in that native build. + +```mermaid +flowchart LR + Source[Quiet pre-alarm Pod and room recordings] --> Audit[Complete-night audit and owner review] + Audit --> Fit[Earlier whole nights: ridge fit] + Audit --> Calibration[Separate nights: empirical error bands] + Audit --> Test[Later whole nights: compare two baselines] + Fit --> Preview[Conditional water forecast or abstention] + Calibration --> Preview + Test --> Preview + Preview --> Display[Read-only web cards] +``` + +## Verification boundary + +Software tests include preceding-only features, whole-night leakage checks, +fragments and gaps, unsupported targets, failed comparisons, source edits, clock +changes, persistence, authentication and the real SQLite audit-to-fit path. +Synthetic heat-equation fixtures exercise those paths and are never installed as +personal model weights. Passing these checks does not establish accuracy on the +owner's bed, improved sleep, or authorization for autonomous adjustment. Those +require actual qualified recordings and separately reviewed prospective trials. diff --git a/docs/adr/0019-mqtt-bridge.md b/docs/adr/0019-mqtt-bridge.md index 75c193184..ef751d183 100644 --- a/docs/adr/0019-mqtt-bridge.md +++ b/docs/adr/0019-mqtt-bridge.md @@ -1,5 +1,23 @@ # ADR 0019: MQTT bridge for Home Assistant integration +## Fork update: 25 October 2026 + +The original design below is historical. HTTP and WebSocket owner authorization +are now enforced at their transport boundaries. MQTT has its own broker trust +boundary: dedicated credentials and topic ACLs are required. It defaults to +telemetry only; `MQTT_COMMANDS_ENABLED=true` enables the expiring, durable +control-request path. The raw actuator dispatcher is removed. The restricted +internal adapter shares the app's control receipts and does not grant general +router, base or priming access. See [current protocol](../MQTT_COMMANDS.md). + +The TLS flag now **requires** an explicitly secure URL; it never rewrites a URL +or port. Enabled partial updates, test probes and bridge startup enforce this +requirement. An unreadable settings database stops connection attempts rather +than silently dropping saved policy. Test probes do not reuse stored broker +passwords. See [current configuration semantics](../integrations.md#configuration). +The original transport statement below that the flag switches protocols was +inaccurate; MQTT.js selects transport from the supplied URL. + ## Context The Pod runs a tRPC HTTP API on port 3000 and a WebSocket sensor stream on diff --git a/docs/biometrics-calibration-api.md b/docs/biometrics-calibration-api.md index c6909e213..63a361f2f 100644 --- a/docs/biometrics-calibration-api.md +++ b/docs/biometrics-calibration-api.md @@ -81,6 +81,11 @@ leaves evaluation evidence unchecked. Experiment creation requires an idempotency key. `getExperiment` and `listExperiments` preserve access to its frozen protocol and audit identity. +If the creation reply is lost, retry the exact dataset ID, normalized protocol +and key. A committed match returns the original experiment even after its +dataset inputs are released; it does not reopen or restore those inputs. Reusing +the key for a different request remains a conflict. A new key still requires +valid, retained inputs, and malformed protocols or keys are always rejected. `releaseDataset` is explicit and idempotent. It refuses release while queued, running, cancellation-pending, or interrupted jobs still need the inputs. @@ -335,6 +340,12 @@ an interval ending there belongs to the first. Intervals spanning the midpoint are excluded from both half-window groups without splitting or changing their aggregation; the report retains their excluded count. +With all three numeric objectives and three configurations, the retained +`numericSubgroupSummary` has up to 36 rows: four groups × three metrics × three +pairwise comparisons. The bounded web projection accepts all 36; it must not +truncate a valid multi-metric report to the 12 rows for a single metric. Metric +identity, reference membership and missing scores remain separate in each row. + Each group uses the same all-configuration common timestamp support as the main numeric comparison. References are weighted within a night, then nights receive equal weight. Objective evidence requires the configured minimum paired records diff --git a/docs/biometrics-reference-imports.md b/docs/biometrics-reference-imports.md index 5413a8c94..6448922ba 100644 --- a/docs/biometrics-reference-imports.md +++ b/docs/biometrics-reference-imports.md @@ -2,7 +2,7 @@ Reference imports are comparison data. They never write the pod's `vitals`, change a profile, or control hardware. Agreement with a named reference source -is not clinical accuracy. APIs use the existing LAN trust model. +is not clinical accuracy. This fork requires owner authentication on these APIs. ## Preview and commit @@ -138,9 +138,47 @@ This alignment primitive does not by itself establish validated improvement. The existing `biometrics.importReferenceStages` and `importReferenceVitals` contracts still accept Unix seconds and return `{ written }`. They retain first-write-wins heart-rate behavior and whole-request stage-overlap rejection. +`biometrics.importReferenceSamples` (`POST /api/biometrics/reference-samples`) +follows the same contract for one `metric` per request, `breathing_rate` +(breaths/min, 4–60) or `hrv_sdnn` (ms, 1–500), as `{ timestamp, value }` +samples stored in `reference_samples` and first-write-wins on side, metric, +time and source. The **Upload Apple Watch data** dialog +(`POST /api/biometrics/watch-upload`) feeds all three from a Health export, +using records whose source name matches `Watch`. Known SleepyPod and Free Sleep +source names are excluded even if a CLI caller supplies a broader source filter. +Source names are export metadata, not proof that a physical Watch measured a row. Newly accepted rows and normalized provenance commit atomically; if provenance cannot be saved, the legacy insert rolls back too. +`GET /api/biometrics/reference-samples` accepts `side`, `metric`, ISO `startDate` +and `endDate`, plus optional `limit` (1–20000, default 5000). It returns +`[{ timestamp, value }]`, retaining the newest samples within the inclusive +window and ordering them oldest first. Clients reaching the cap must split the +window and deduplicate boundary timestamps instead of treating a capped result +as a complete history. Watch SDNN is not the Pod's IBI variability index or RMSSD. +Missing samples remain gaps. Numeric bounds above are import filters, not +clinical normal ranges or triggers for bed controls. + +The upload response adds `breathing`, `hrv`, `matchedBreathing` and `matchedHrv` +alongside its existing heart-rate and stage counts. Uploads commit in bounded +batches; interruption can leave earlier batches saved. Retrying the same export +is idempotent for accepted rows. No cloud provider receives this local import. + +This integration reviews upstream `02b12876eea29d608c20fbe10996f8500539c66b` +and the GET contract from `e2515567f7cf8e0a9a65bcaa9d1a946bb1818654`. +It does not enable upstream's personal-model execution. Generated biometrics +migration 0031 adds `reference_samples`; no existing Pod vitals are rewritten. + +On 2026-10-25, a private offline upgrade of the library rehearsal passed SQLite +integrity checks and preserved every existing configuration and biometric row. +The new reference table was empty and both source database hashes were unchanged. +The aggregate record is +`~/Downloads/SleepyPod-Watch-Rehearsal-2026-10-25/watch-verification.json`. +No controller was started and no hardware commands were sent. Production browser +tests upload synthetic XML into isolated databases, verify source exclusion and +unit conversion, repeat the upload, read both metrics, and compare unchanged Pod +vitals. They do not verify the owner's Apple Watch history or sleep outcomes. + Old clients provide no subject identity or source timezone. Such imports are visibly mapped to `legacy_unknown:left` or `legacy_unknown:right`, with that limitation in the original record annotation. Older unlinked rows remain diff --git a/docs/hardware/alarms.md b/docs/hardware/alarms.md index edd24970a..b49c4a852 100644 --- a/docs/hardware/alarms.md +++ b/docs/hardware/alarms.md @@ -55,6 +55,24 @@ inside a window opens it immediately; the record of an early fire lives in memory, so a restart between an early fire and the set time lets the alarm fire again at the set time. See `src/scheduler/wakeWindow.ts`. +## Snooze length and Snooze taper + +Each alarm sets how long a snooze lasts (`snoozeDuration`, 1–30 minutes, +default 5) and an optional **Snooze taper** (`snoozeTaper`: off, 30, 40, 50 +or 60%). With a taper, each later snooze of the same alarm is that much +shorter than the one before — 9 min at 30% gives 9, 6.3, 4.4, 3.1 min… — so +you ease out of bed instead of drifting back to sleep. A snooze never gets +shorter than 5 seconds (`MIN_SNOOZE_SECONDS`). + +The scheduled alarm carries both settings in its `AlarmConfig`, and +`snoozeManager` brings a snoozed alarm back with `snoozeCount` one higher; +`nextSnoozeSeconds` (`src/hardware/snoozeLength.ts`) turns those into the +next snooze's length. Tap gestures and the HomeKit snooze switch use it (an +alarm without these settings — one started from the API — snoozes 5 minutes +from a tap, 9 from HomeKit). `device.snoozeAlarm` still takes an explicit +duration. Tap gestures used to carry their own snooze length; migration +`0034_alarm_snooze_from_gestures` copied each side's onto its alarms. + ## Alarm temperature warm-up An alarm's temperature used to be a set point at the alarm time: the bed only @@ -255,7 +273,8 @@ re-armed by us, and a tap only snoozes if we know an alarm is vibrating. calls `markAlarmEnded`, and an alarm nobody stops ends when its duration runs out — so the flag can't linger for a later tap to "snooze". - A tap configured to snooze clears the alarm and snoozes it through - `snoozeManager` with the snoozed alarm's own settings (the gesture, API + `snoozeManager` with the snoozed alarm's own settings, for the alarm's + snooze length (see Snooze length and Snooze taper above; the gesture, API and HomeKit paths all share it). Any other tap during an alarm just records it ended, since the firmware already stopped it. - Taps come from two places (`src/hardware/gestureDispatch.ts`): the tap diff --git a/docs/integrations.md b/docs/integrations.md index c604c80e6..c5c0a107d 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -31,17 +31,18 @@ pod ship once. | Accessory | Type | Reads from | Writes to | |---|---|---|---| -| `Bed ` | Thermostat (single setpoint) | `deviceStatus.` | `setTemperature` / `setPower` | -| `Bed power` | Switch | `deviceStatus..powered` | `setPower` (preserves last setpoint) | -| `Bed occupancy` | OccupancySensor | `sleep_records` (latest with `leftBedAt IS NULL`) | — | +| `Bed ` | Thermostat (single setpoint) | Fresh reported DAC state; explicit staged target while off | Shared temperature/power controller | +| `Bed occupancy` | OccupancySensor | Fresh calibrated capacitance and movement | — | | `Snooze ` | Switch | `snoozeManager` | `snoozeAlarm` / `cancelSnooze` | | `Prime` | Switch | `primeNotification` (auto-off on completion) | `startPriming` | -| `Pod ambient` | TemperatureSensor | `bed_temp.ambient_temp` (centidegrees → °C) | — | +| `Pod ambient` | TemperatureSensor | Live `bedTemp` / `bedTemp2` ambient cache | — | Thermostat is HomeKit's single-setpoint primitive (the pod hardware exposes one setpoint, not a heat/cool deadband). Mode `off` cuts power; `auto` powers on at the last requested temperature. HomeKit Celsius is converted at the -boundary; the in-app unit preference is unaffected. +boundary; the in-app unit preference is unaffected. Missing or stale reports +return HAP unavailable, with a fault combined with any pump-stall notice. +See [reported-state semantics and qualification limits](HOMEKIT_REPORTED_STATE.md). ### Identity durability @@ -102,53 +103,60 @@ the bridge resolves config in this order: `device_settings` row > env var > buil | UI field | Env var | Default | |---|---|---| | Enable bridge | `MQTT_ENABLED` | `false` | +| Accept commands (host configuration only) | `MQTT_COMMANDS_ENABLED` | `false` | | Broker URL | `MQTT_URL` | _(unset — bridge stays dormant)_ | | Username | `MQTT_USERNAME` | _(none)_ | | Password | `MQTT_PASSWORD` | _(none)_ | | Topic prefix | `MQTT_TOPIC_PREFIX` | `sleepypod` | | HA discovery | `MQTT_HA_DISCOVERY` | `true` | | HA discovery prefix | `MQTT_HA_DISCOVERY_PREFIX` | `homeassistant` | -| TLS | `MQTT_TLS` | `false` | +| Require TLS | `MQTT_TLS` | `false` | | TLS allow self-signed | `MQTT_TLS_INSECURE` | `false` | -The URL scheme decides whether the connection is encrypted: `mqtts://` uses -TLS, `mqtt://` is plain TCP. The TLS toggle does not upgrade an `mqtt://` URL; -it only gates `MQTT_TLS_INSECURE`, which skips certificate verification when -both are on. +The URL scheme selects the transport: `mqtts://` and `wss://` are encrypted; +`mqtt://` and `ws://` are plaintext. **Require TLS** rejects plaintext URLs +before opening a connection. It does not change the URL or port. Enter the +broker's actual secure endpoint when enabling this requirement. A secure URL +still uses TLS when the requirement is off. Unsupported schemes are rejected. + +Enabled settings updates validate the fully resolved configuration, including +inherited database/environment fields. Disabling an invalid configuration remains +possible. If the settings database cannot be read, startup and connection tests +stop rather than falling back to an unknown transport policy. + +Certificate verification stays enabled unless both Require TLS and the separate +host-only `MQTT_TLS_INSECURE` override are on. That override permits unverified +certificates; it is not exposed as an ordinary settings toggle. + +Saving a blank password preserves the saved password. **Test connection** uses +only a password entered for that test; it never silently sends the saved password +to a proposed broker. Re-enter it to test authenticated access. Editing the +endpoint, TLS requirement or credentials clears the previous test result. ### Topics `` defaults to `sleepypod`. `` is the slugified hostname -(override with `MQTT_DEVICE_ID`). All state topics are retained. +(override with `MQTT_DEVICE_ID`). Discovery and connection availability are retained; measurements and execution receipts are not. +See [qualified home telemetry](HOME_TELEMETRY.md) for source timestamps, expiry, null fields and migration. | Topic | Direction | Payload | |---|---|---| | `//availability` | pod → broker | `online` / `offline` (LWT) | -| `//state/device-status` | pod → broker | full deviceStatus JSON | +| `//state/device-status` | pod → broker | fresh reported DAC status; no optimistic overlay | | `//state//climate` | pod → broker | per-side temp / mode | | `//state/water-level` | pod → broker | `low` / `ok` / `unknown` | -| `//state/biometrics/` | pod → broker | latest HR / HRV / BR | +| `//state/biometrics/` | pod → broker | qualified live HR / beat-interval variability / BR | | `//state/environment/ambient` | pod → broker | `{"ts": , "temperature": , "humidity": }` (°C, %) | -| `//cmd/set-temperature` | broker → pod | `{"side","temperature","duration?"}` | -| `//cmd/set-power` | broker → pod | `{"side","powered","temperature?"}` | -| `//cmd/set-alarm` | broker → pod | `{"side","vibrationIntensity","vibrationPattern","duration"}` | -| `//cmd/clear-alarm` | broker → pod | `{"side"}` | -| `//cmd/start-priming` | broker → pod | `{}` | - -Commands route through the same tRPC procedures the iOS app calls, so Zod -input schemas validate every payload — the bridge cannot accidentally -diverge from the app's safety envelope. - -### Example: turn off the left side from any MQTT client - -```bash -mosquitto_pub -h broker.lan \ - -t 'sleepypod/eight-pod/cmd/set-power' \ - -m '{"side":"left","powered":false}' -``` - -State mirrors back on `sleepypod/eight-pod/state/left/climate` within ~1 s. - -See **[ADR 0019](adr/0019-mqtt-bridge.md)** for the design rationale — -why client-not-broker, the credential storage decision, the tRPC dispatch -model, and what's deferred until `protectedProcedure` lands. +| `//state/occupancy/` | pod → broker | calibrated occupied / classification, or unavailable | +| `//cmd/request` | broker → pod | Expiring control request; explicit opt-in required | +| `//receipt/` | pod → broker | Non-retained execution receipt; physical effect unverified | + +Enabling the bridge now enables telemetry only. Command reception separately +requires `MQTT_COMMANDS_ENABLED=true`, dedicated broker credentials and topic +ACLs. The old raw actuator topics are no longer supported. Commands reuse the +app's durable control authority, deadlines and hardware guards; they cannot +invoke priming or base movement through MQTT. + +See [MQTT command delivery](MQTT_COMMANDS.md) for the full envelope, Home +Assistant discovery behavior, receipt lookup, migration and validation limits. +The original [ADR 0019](adr/0019-mqtt-bridge.md) describes the earlier design. diff --git a/docs/research/free-sleep-ha-assessment.md b/docs/research/free-sleep-ha-assessment.md new file mode 100644 index 000000000..83b5f1bd4 --- /dev/null +++ b/docs/research/free-sleep-ha-assessment.md @@ -0,0 +1,45 @@ +# Free Sleep HA compatibility review + +Reviewed 2026-10-30 against +[`DaSonOfPoseidon/free-sleep-ha` at b390441b](https://github.com/DaSonOfPoseidon/free-sleep-ha/tree/b390441b3e27483b17598ec12c0950e7d891ca58) +and this fork's Core API, MQTT discovery and native HomeKit bridge. The upstream +component declares version 0.1.0 and is MIT licensed. + +## Decision + +Keep Core's native MQTT integration and add a dedicated Home Assistant dashboard. +The upstream project is useful for installations running the original Free Sleep +API, but it is not a drop-in integration for this authenticated Sleepypod Core +fork. No upstream code is copied or installed by this change. Its per-side +dashboard and history organization informed an independently authored layout +using built-in Home Assistant cards. + +| Area | Upstream evidence | Core integration decision | +| --- | --- | --- | +| Connection | `api.py` constructs HTTP from host/port; `config_flow.py` has no connection-key field; requests do not add Bearer authentication. | Preserve Core authentication; use the existing MQTT connection. | +| Device API | Calls legacy `/api/deviceStatus`, settings, schedules and metrics paths. | Do not add a broad legacy compatibility surface just to support a second client. | +| Dashboard | Separate side controls, presence/vitals/history and a custom web card. | Add Tonight and Signals views over existing MQTT entities, with authenticated app links for controls. | +| Routines | Legacy schedule service payloads write the Free Sleep weekday schema. | Keep Core's overnight day ownership and concurrency checks; open Core's routine editor. | +| System services | Includes priming, alarm tests, service changes, jobs and raw execute. | Keep those out of the household dashboard; preserve scoped commands and attended qualification. | +| Homebridge | This repository is a Home Assistant custom component, not a Homebridge plugin. | Use native Core HomeKit alongside existing Homebridge, plus the bedroom sensor publisher. | +| Learning and remote access | No new Autopilot model or remote relay is provided by this component. | These remain separate Core/native-app workstreams. | + +Primary reviewed sources: + +- [API client](https://github.com/DaSonOfPoseidon/free-sleep-ha/blob/b390441b3e27483b17598ec12c0950e7d891ca58/custom_components/free_sleep_local/api.py) +- [Configuration flow](https://github.com/DaSonOfPoseidon/free-sleep-ha/blob/b390441b3e27483b17598ec12c0950e7d891ca58/custom_components/free_sleep_local/config_flow.py) +- [Service definitions](https://github.com/DaSonOfPoseidon/free-sleep-ha/blob/b390441b3e27483b17598ec12c0950e7d891ca58/custom_components/free_sleep_local/services.yaml) +- [Custom card](https://github.com/DaSonOfPoseidon/free-sleep-ha/blob/b390441b3e27483b17598ec12c0950e7d891ca58/frontend/free-sleep-card.js) + +## Delivered surface + +[Dashboard installation and connection directions](../../integrations/home-assistant/dashboards/README.md) +document entity mapping, existing authentication, signal meanings and rollback. +The dashboard monitors existing data and does not expand actuator access. It +works without a Pod service restart or a Home Assistant custom-component restart. + +Deployment evidence must separately establish MQTT connection/discovery, resolved +entity IDs, current Homebridge source readings and native HomeKit pairing. A +paired bridge does not establish a successful physical command. Missing HomePod +exports, unavailable vitals, remote access and MQTT actuator qualification must +remain explicit rather than being marked complete by installing the dashboard. diff --git a/docs/research/sleep-stage-calibration/README.md b/docs/research/sleep-stage-calibration/README.md index 7e199d92e..81b5bfafc 100644 --- a/docs/research/sleep-stage-calibration/README.md +++ b/docs/research/sleep-stage-calibration/README.md @@ -56,15 +56,22 @@ builds can use `--from-db` without an export: scripts/import-watch-health.py ~/Desktop/apple_health_export --pod --side left ``` -It posts `SleepAnalysis` segments to `POST /api/biometrics/reference-stages` -and `HeartRate` samples to `POST /api/biometrics/reference-vitals`, in batches -of 2000, for the last 30 days (`--days 0` for everything; `--dry-run` to count -only). Heart rate is first-write-wins on side + time + source, so re-running -on a newer export only adds what is new. Stages are stricter: the pod rejects +It posts `SleepAnalysis` segments to `POST /api/biometrics/reference-stages`, +`HeartRate` samples to `POST /api/biometrics/reference-vitals`, and +`RespiratoryRate` (`breathing_rate`, breaths/min) and +`HeartRateVariabilitySDNN` (`hrv_sdnn`, ms) samples to +`POST /api/biometrics/reference-samples`, in batches of 2000, for the last 30 +days (`--days 0` for everything; `--dry-run` to count only). Records the +sleepypod iOS app wrote back into Health (source `sleepypod`, `sleepypod +design`) are the pod's own readings and are always skipped, whatever +`--source` says. Heart rate, breathing rate and HRV are first-write-wins on +side + time + source (+ metric), so re-running on a newer export only adds +what is new. Stages are stricter: the pod rejects a batch whose segment overlaps a stored one with different boundaries (say a night the iOS app already posted); the script skips that batch with a warning, -still imports heart rate, and exits 1. Watch heart rate is kept -in its own `reference_vitals` table and never mixed into the pod's `vitals`, +still imports the vitals, and exits 1. Watch heart rate is kept in its own +`reference_vitals` table, breathing rate and HRV in `reference_samples`, never +mixed into the pod's `vitals`, which feed occupancy, sleep staging and the chart baselines. ## 3. Build the dataset diff --git a/eslint.config.ts b/eslint.config.ts index 5c7857037..6ce2658ea 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -47,6 +47,7 @@ export default defineConfig([ 'next-env.d.ts', 'out/**', 'public/vendor/**', + 'public/household/personal-runtime.js', // Generated bundle; its TypeScript source is linted above. ]), /** Global JS overrides */ diff --git a/integrations/home-assistant/HOMEPOD_SENSOR_EXPORT.md b/integrations/home-assistant/HOMEPOD_SENSOR_EXPORT.md new file mode 100644 index 000000000..a01f7116b --- /dev/null +++ b/integrations/home-assistant/HOMEPOD_SENSOR_EXPORT.md @@ -0,0 +1,120 @@ +# HomePod climate reports through Apple Home + +This exporter connects Apple Home's HomePod temperature/humidity readings to the +existing bedroom report contract. It does not use Apple's private APIs or remove +HomePod/Homebridge pairings. It is a reporting path, not a Pod controller. + +```mermaid +flowchart LR + Timer[HA minute timer] --> Helper[Dedicated virtual helper] + Helper --> Bridge[Helper-only HomeKit Bridge] + Bridge --> Shortcut[Apple Home automation] + Shortcut -->|temperature and humidity| Webhook[Private local HA webhook] + Webhook -->|cached observations only| MQTT[Fixed bedroom report topic] + MQTT --> Pod[Authenticated Pod controller] + Pod --> Clients[Web and native last-reported readings] +``` + +## Install the Home Assistant side + +1. Keep the Pod's comfort policy Off/Preview. Connect the Pod to your private + MQTT broker, with actuator commands disabled. Obtain its exact + `mqtt.topic` and configuration `revision` from `GET /api/v1/home/bedroom`. + Broker credentials belong in server configuration, never in the Apple + automation. Restrict publisher access to the bedroom report topic. +2. Create an **Input boolean** helper named **Sleepypod HomePod Sample**. + Expose only that entity through a dedicated HomeKit Bridge in **include** + mode. Do not include entire physical accessory domains. Pair that bridge + into the existing Apple Home using an iPhone/iPad; Mac Home cannot add an + accessory. [Apple's pairing guidance](https://support.apple.com/guide/home/add-accessories-hmead877ec9c/mac), + [Home Assistant bridge filters](https://www.home-assistant.io/integrations/homekit/#configure-filter). +3. Import `blueprints/sensor-export/apple_home_cache.yaml`. Create one + automation per HomePod, with a distinct random private webhook ID of at + least 32 characters. Enter the exact temperature/humidity source IDs, + the temperature source's configured unit, the current revision, and the + bedroom MQTT report topic. Leave **Enable sensor reporting** off until + the Apple automation is ready. No Pod owner key is used here. +4. Import `blueprints/sensor-export/apple_home_poll.yaml`, select the dedicated + helper and leave **Enable periodic requests** off during setup. When + enabled, it pulses that virtual helper once per minute. Home Assistant + preserves the automation's chosen on/off state across restarts. + +Each blueprint uses Home Assistant's supported automation and MQTT actions. +The receiver accepts POST JSON through a `local_only` webhook, suppresses saved +traces, fixes its source IDs/topic/revision in configuration, and publishes +non-retained QoS 0 reports. The caller cannot choose a command, freshness class, +measurement clock, revision, or source ID. Keep webhook IDs private and do not +publish their URLs. [Webhook reference](https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger), +[MQTT reference](https://www.home-assistant.io/integrations/mqtt/). + +## Create one Apple Home automation per HomePod + +Use the existing Home with those HomePods. Select **An Accessory Is Controlled**, +choose **Sleepypod HomePod Sample**, and trigger when it turns on, at any time. +At the accessory-action screen choose **Convert to Shortcut**. +[Apple's home automation instructions](https://support.apple.com/guide/shortcuts/create-a-new-home-automation-apd2a290f633/ios). + +Read **Current Temperature** and **Current Relative Humidity** from the same +HomePod with **Get the State of Home**. Identify the two devices separately; +two generically named “Temperature Sensor” tiles are not sufficient to establish +which HomePod is which. Check the accessory details or give the sensor tiles +distinct names before selecting them. Do not average or substitute another +device's value in the shortcut. + +Add **Get Contents of URL**, using that HomePod's private local webhook URL: +`http://:/api/webhook/`. +Select **POST**, **JSON** request body, with these fields: + +| Key | JSON type | Value | +| --- | --- | --- | +| `schemaVersion` | Number | `1` | +| `temperature` | Number | The temperature action's numeric output | +| `temperatureUnit` | Text | Exactly `°C` or `°F`, matching that action's output | +| `humidity` | Number | The humidity action's numeric output, from 0 to 100 | + +If the temperature action returns a Measurement, explicitly convert it to the +chosen temperature unit and obtain its numeric magnitude. Verify the resulting +body with the real Home action; do not assume the phone's display unit or send +formatted text such as “73°F” as a number. A missing/failed sensor must not become +zero or a previously saved shortcut variable. Null/invalid readings are withdrawn; +unknown units or extra fields reject the envelope. + +Enable the matching receiver, then test the Apple automation once. Check that +the Pod receives two **cached** reports with the correct IDs and sensible unit +conversion. Repeat for the second HomePod. Enable periodic requests only after +both paths are verified. No physical accessories are part of the helper pulse. + +## Qualification and failure checks + +- Compare each received value and unit with the corresponding Apple Home tile. + Confirm all four sources update over several timer cycles in both clients. +- Stop the timer and disconnect a HomePod separately. Record how the Home app's + sensor read behaves. If Apple returns a cached number, the report remains + cached; an HTTP success does not prove a new physical measurement. +- Restart HA and the Pod, verify the bridge pairing persists, and check that + old/retained messages are not promoted to live observations. Test with the + phone away: the automation must run on the home hub without the Mac. +- After editing bedroom source configuration, update each export's revision. + Old-revision reports are rejected; they cannot silently enter a new mapping. +- Verify that disabling either automation stops it, malformed requests cannot + select other sources/topics, and no thermal/base/vibration command is sent. + +Until the actual Apple Home path passes freshness qualification, these exports +always have `timeBasis: cached` and `measuredAt: null`; neither client nor Autopilot +may label them as current qualified measurements. Backend receipt time describes +when the export arrived, not when HomePod measured the room. Missing exports stay +empty. A successful Home Assistant schema/action test is not live HomePod proof. + +## Validation + +Run the pinned real Home Assistant engine tests: + +```sh +uv run --project scripts/home-assistant --frozen python -m unittest discover -s scripts/home-assistant -p 'test_*.py' -v +``` + +Tests cover real blueprint/trigger schemas, default-off and manual-run guards, +scoped MQTT messages, explicit Celsius/Fahrenheit conversion, zero humidity, +unavailable/nonfinite/boolean values, rejected caller-controlled metadata, and +the helper-only polling sequence. Test services are inert and never contact a +real broker, HomePod, or Pod. diff --git a/integrations/home-assistant/README.md b/integrations/home-assistant/README.md new file mode 100644 index 000000000..7cffac4a1 --- /dev/null +++ b/integrations/home-assistant/README.md @@ -0,0 +1,122 @@ +# House automation templates + +For an overview of the bed using existing MQTT entities, install the +[Tonight and Signals dashboard](dashboards/README.md). It includes authenticated +app links for routines and controls and keeps house automations opt-in. The +[Free Sleep HA compatibility review](../../docs/research/free-sleep-ha-assessment.md) +explains why Core uses its native integration rather than the legacy component. + +These six Home Assistant blueprints are an opt-in starting point for qualified +house integrations. Installing a blueprint does not create an automation or +select a device. Every created automation starts **disabled**, and its separate +**Allow** input defaults to **off**. That input is checked inside the action +sequence as well as the normal conditions, so **Run actions** cannot skip it. + +| Blueprint | Behavior after review and explicit activation | +| --- | --- | +| [Wind-down](blueprints/wind_down.yaml) | Activate a selected scene at a chosen Home Assistant local time. This is a separate house schedule, not the Pod's bedtime routine. | +| [Nighttime path](blueprints/night_path.yaml) | Activate a dim-light scene after a known occupied-to-empty transition lasts five seconds within the night window. | +| [Morning scene](blueprints/morning_scene.yaml) | Activate a scene after a known occupied-to-empty transition lasts thirty seconds within the morning window. This follows bed exit; it is **not** alarm synchronization. | +| [Empty bed](blueprints/empty_bed.yaml) | Activate a scene after both distinct sides have been continuously known empty for five minutes. | +| [Humidity review](blueprints/humidity_review.yaml) | Show one local review notification when a fresh measurement is outside the chosen band; withdraw it when recovered or expired. | +| [Air-quality review](blueprints/air_quality_review.yaml) | The same review behavior for PM2.5. Does not command a purifier. | + +For HomePod temperature/humidity ingestion, see the separate +[Apple Home sensor-export setup](HOMEPOD_SENSOR_EXPORT.md). Its two reporting +blueprints use a virtual helper and cached-only MQTT reports; they do not run +house scenes or Pod actuators. + +The four scene templates call only the selected `scene.turn_on`. They do not +publish MQTT commands, change temperature, move the base, vibrate, prime or power +the Pod. Keep Pod climate and actuators out of those scenes. Use a scene containing +only the intended, tested house accessories. There is no delayed restoration that +could overwrite a later manual change. The room reviews never call appliance +services: power-only diffusers and discrete purifier speeds must be qualified +before implementing appliance automation. + +```mermaid +flowchart LR + Core[Qualified Core occupancy] --> Gate[Known transition and time window] + Clock[Explicit house schedule] --> Gate + Gate --> Permit[Owner Allow input and enabled automation] + Permit --> Scene[Selected house scene] + Room[Room measurement with source timestamp] --> Fresh[Units, range and expiry] + Fresh --> Review[Local review notification] + Stale[Expired or unavailable source] --> Withdraw[Withdraw review] +``` + +## Import and configure + +Requires Home Assistant 2026.10 or later. Follow the official +[blueprint import workflow](https://www.home-assistant.io/docs/automation/using_blueprints/): + +1. In **Settings → Automations & scenes → Blueprints → Import blueprint**, use a + GitHub URL for one YAML file from a reviewed commit of this repository. +2. Create an automation from it and explicitly choose existing entities/scenes. + Leave **Allow** off. Do not remove Apple Home or Homebridge pairings to obtain + entities. The template does not provision broker accounts or pair devices. +3. For occupancy, use this fork's calibrated MQTT binary sensors after validating + occupied, empty and unavailable behavior on the actual cover. Its discovery + marks missing/stale readings unavailable and expires silent publishers. See + [home telemetry](../../docs/HOME_TELEMETRY.md). Unavailable is never empty; + unknown/unavailable-to-empty recovery does not trigger a scene. +4. Review the target scene using its normal controls, verify both platforms' + timezones, then qualify one automation at a time while attended. Enable its + **Allow** input and the automation itself only for that qualification. +5. Inspect Home Assistant traces and the physical accessory outcome separately. + A successful service call does not prove a lamp, shade or speaker changed. + +`initial_state: false` deliberately disables these qualification templates after +**every** automation reload or Home Assistant restart. After household acceptance, +an owner may take control of a blueprint automation and remove that line to use +Home Assistant's persisted enabled state. Removing it is a separate deployment +decision; this package never makes that change. See +[automation initial state](https://www.home-assistant.io/docs/automation/yaml/#automation-initial-state). + +State-trigger hold timers reset across reloads/restarts; there is no catch-up. +The morning template cannot synchronize lights to a moved or skipped Pod alarm. +That requires a separately qualified wake-event integration. HomePod audio must +use a supported, owner-tested scene; these files do not expose its audio stream. + +## Room review timestamps + +Choose a real sensor with the exact unit `%` or `µg/m³`. Supply an attribute +whose integration documents **source measurement time**, including repeated +measurements with the same value. A Home Assistant receipt clock does not prove +a new physical measurement. `last_changed`, `last_updated` and `last_reported` +are rejected; do not replace them with `now()` or a derived clock. + +ISO timestamps and numeric Unix seconds/milliseconds are supported. Missing, +malformed, future, expired, non-finite, wrong-unit and out-of-range data withdraw +the review instead of becoming zero. A state change or 30-second timer reevaluates +the original timestamp; it never rejuvenates cached data. Notifications use a +stable ID per automation, so repeated checks update one notice. The next timer +withdraws an expired notice within 30 seconds while Home Assistant and the +automation remain running. Disabled/stopped automations cannot retract an old +notice; the message therefore carries its source time and expiry. Dismiss it +manually if stopping the automation. + +The initial bands are editable demonstration settings, not clinical thresholds, +air-quality assurances or a recommendation to run an appliance. These review +notifications are separate from the Pod's [room-context input](../../docs/ROOM_CONTEXT.md) +and do not enable comfort adaptation. + +## Validation and remaining gates + +Run on a development computer, **not on the Pod**: + +```sh +uv run --project scripts/home-assistant --frozen python -m unittest discover \ + -s scripts/home-assistant -p 'test_*.py' -v +``` + +The locked test environment uses Home Assistant 2026.10.4 on Python 3.14. It +loads the real blueprint/automation/trigger schemas and executes Home Assistant's +script engine against synthetic states and stub scene/notification services. +The suite covers disabled/manual-run guards, time windows, known transitions, +two-side empty holds, source clocks, stale recovery and notification withdrawal. +No household data, real MQTT broker or physical devices are used in those tests. + +Actual import, credentials/ACLs/TLS, source timestamp semantics, device selection, +Homebridge access, paired accessories, house restart behavior and physical +qualification remain deployment gates. No templates are enabled by a Git merge. diff --git a/integrations/home-assistant/blueprints/air_quality_review.yaml b/integrations/home-assistant/blueprints/air_quality_review.yaml new file mode 100644 index 000000000..fc32ff4dc --- /dev/null +++ b/integrations/home-assistant/blueprints/air_quality_review.yaml @@ -0,0 +1,127 @@ +# Disabled at startup; review inputs and qualify devices before enabling. +blueprint: + name: SleepyPod — Room PM2.5 review + description: Create or update one local Home Assistant notification when a fresh source measurement + is outside the selected review band. Withdraw it when the value recovers, expires, or becomes unavailable. + This is a review prompt, not medical advice or automatic appliance control. Diffuser power and purifier + fan capabilities must be qualified separately. Starts disabled. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Allow local review notifications + selector: + boolean: {} + default: false + description: Leave off until you review the selected devices and qualify the sensors. It is checked + inside actions too; Run actions cannot bypass it. The automation also starts disabled after every + reload/restart. + sensor: + name: Room sensor + selector: + entity: + filter: + domain: sensor + device_class: pm25 + timestamp_attribute: + name: Source measurement timestamp attribute + selector: + text: {} + description: 'Required: verify this attribute advances on actual measurements, including unchanged + values. Never enter last_changed, last_updated, last_reported, or a clock generated by Home Assistant.' + timestamp_format: + name: Source timestamp format + selector: + select: + options: + - iso8601 + - unix_seconds + - unix_milliseconds + default: iso8601 + maximum_age: + name: Maximum measurement age + selector: + number: + min: 30 + max: 900 + unit_of_measurement: s + mode: box + default: 120 + lower: + name: Lower review threshold + selector: + number: + min: 0 + max: 1000 + unit_of_measurement: µg/m³ + mode: box + default: 0 + upper: + name: Upper review threshold + selector: + number: + min: 0 + max: 1000 + unit_of_measurement: µg/m³ + mode: box + default: 15 +initial_state: false +mode: single +max_exceeded: silent +variables: + allow_actions: !input 'allow_actions' + sensor: !input 'sensor' + timestamp_attribute: !input 'timestamp_attribute' + timestamp_format: !input 'timestamp_format' + maximum_age: !input 'maximum_age' + lower: !input 'lower' + upper: !input 'upper' + reading: '{{ states(sensor) | float(none) }}' + measured_at: |- + {% set raw = state_attr(sensor, timestamp_attribute) %} + {% if timestamp_attribute in ['last_changed', 'last_updated', 'last_reported'] %} + {{ none }} + {% elif timestamp_format == 'iso8601' and raw is string %} + {{ as_timestamp(raw, none) }} + {% elif timestamp_format in ['unix_seconds', 'unix_milliseconds'] and raw is number and raw is not boolean %} + {{ raw / 1000 if timestamp_format == 'unix_milliseconds' else raw }} + {% else %} + {{ none }} + {% endif %} + fresh: |- + {{ has_value(sensor) and reading is number and reading is not boolean + and 0 <= reading <= 1000 and state_attr(sensor, 'unit_of_measurement') == 'µg/m³' + and measured_at is number and measured_at is not boolean + and maximum_age is number and 30 <= maximum_age <= 900 + and 0 <= now().timestamp() - measured_at < maximum_age + and 0 <= lower < upper <= 1000 }} + notification_id: '{{ ''sleepypod_room_'' ~ this.entity_id }}' +triggers: +- trigger: state + entity_id: !input 'sensor' +- trigger: time_pattern + seconds: /30 +conditions: +- condition: template + value_template: '{{ allow_actions is sameas true }}' +actions: +- condition: template + value_template: '{{ allow_actions is sameas true }}' +- choose: + - conditions: + - condition: template + value_template: '{{ fresh and (reading < lower or reading > upper) }}' + sequence: + - action: persistent_notification.create + data: + notification_id: '{{ notification_id }}' + title: Room PM2.5 review + message: |- + Room reading: {{ reading }} µg/m³. Review band: {{ lower }}–{{ upper }} µg/m³. + Source measurement: {{ measured_at | timestamp_local }}. + This proposal expires after {{ maximum_age }} seconds without a new source sample; the next 30-second check withdraws it. No appliance or Pod command was sent. + default: + - action: persistent_notification.dismiss + data: + notification_id: '{{ notification_id }}' diff --git a/integrations/home-assistant/blueprints/empty_bed.yaml b/integrations/home-assistant/blueprints/empty_bed.yaml new file mode 100644 index 000000000..d7c0a766d --- /dev/null +++ b/integrations/home-assistant/blueprints/empty_bed.yaml @@ -0,0 +1,95 @@ +# Disabled at startup; review inputs and qualify devices before enabling. +blueprint: + name: SleepyPod — Empty-bed scene + description: Run a selected scene only after a qualified occupied-to-empty transition has remained empty + for five minutes and both sides have been continuously known empty for five minutes. No action at + startup or on unavailable-to-empty transitions. It never switches the Pod off. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Allow this automation + selector: + boolean: {} + default: false + description: Leave off until you review the selected devices and qualify the sensors. It is checked + inside actions too; Run actions cannot bypass it. The automation also starts disabled after every + reload/restart. + left_occupancy: + name: Left qualified occupancy + selector: + entity: + filter: + domain: binary_sensor + device_class: occupancy + description: Use the calibrated, expiring occupancy entity published by this Core fork after MQTT + and actual occupancy have been qualified. Occupancy is not sleep. Unknown/unavailable transitions + do not trigger actions. + right_occupancy: + name: Right qualified occupancy + selector: + entity: + filter: + domain: binary_sensor + device_class: occupancy + description: Use the calibrated, expiring occupancy entity published by this Core fork after MQTT + and actual occupancy have been qualified. Occupancy is not sleep. Unknown/unavailable transitions + do not trigger actions. + scene: + name: Scene + selector: + entity: + filter: + domain: scene + description: Select a locally tested Home Assistant scene containing only the intended lights, shades, + or supported audio. No scene or device is selected automatically. This never commands the Pod. +initial_state: false +mode: single +max_exceeded: silent +variables: + allow_actions: !input 'allow_actions' + left_occupancy: !input 'left_occupancy' + right_occupancy: !input 'right_occupancy' +triggers: +- trigger: state + entity_id: + - !input 'left_occupancy' + - !input 'right_occupancy' + from: 'on' + to: 'off' + for: + minutes: 5 +conditions: +- condition: template + value_template: |- + {{ allow_actions is sameas true and trigger is defined and trigger.platform == 'state' + and trigger.from_state is not none and trigger.from_state.state == 'on' + and trigger.to_state is not none and trigger.to_state.state == 'off' + and left_occupancy != right_occupancy + and is_state(left_occupancy, 'off') and is_state(right_occupancy, 'off') }} +- condition: state + entity_id: + - !input 'left_occupancy' + - !input 'right_occupancy' + state: 'off' + for: + minutes: 5 +actions: +- condition: template + value_template: |- + {{ allow_actions is sameas true and trigger is defined and trigger.platform == 'state' + and trigger.from_state is not none and trigger.from_state.state == 'on' + and trigger.to_state is not none and trigger.to_state.state == 'off' + and left_occupancy != right_occupancy + and is_state(left_occupancy, 'off') and is_state(right_occupancy, 'off') }} +- condition: state + entity_id: + - !input 'left_occupancy' + - !input 'right_occupancy' + state: 'off' + for: + minutes: 5 +- action: scene.turn_on + target: + entity_id: !input 'scene' diff --git a/integrations/home-assistant/blueprints/humidity_review.yaml b/integrations/home-assistant/blueprints/humidity_review.yaml new file mode 100644 index 000000000..1832357a0 --- /dev/null +++ b/integrations/home-assistant/blueprints/humidity_review.yaml @@ -0,0 +1,127 @@ +# Disabled at startup; review inputs and qualify devices before enabling. +blueprint: + name: SleepyPod — Room humidity review + description: Create or update one local Home Assistant notification when a fresh source measurement + is outside the selected review band. Withdraw it when the value recovers, expires, or becomes unavailable. + This is a review prompt, not medical advice or automatic appliance control. Diffuser power and purifier + fan capabilities must be qualified separately. Starts disabled. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Allow local review notifications + selector: + boolean: {} + default: false + description: Leave off until you review the selected devices and qualify the sensors. It is checked + inside actions too; Run actions cannot bypass it. The automation also starts disabled after every + reload/restart. + sensor: + name: Room sensor + selector: + entity: + filter: + domain: sensor + device_class: humidity + timestamp_attribute: + name: Source measurement timestamp attribute + selector: + text: {} + description: 'Required: verify this attribute advances on actual measurements, including unchanged + values. Never enter last_changed, last_updated, last_reported, or a clock generated by Home Assistant.' + timestamp_format: + name: Source timestamp format + selector: + select: + options: + - iso8601 + - unix_seconds + - unix_milliseconds + default: iso8601 + maximum_age: + name: Maximum measurement age + selector: + number: + min: 30 + max: 900 + unit_of_measurement: s + mode: box + default: 120 + lower: + name: Lower review threshold + selector: + number: + min: 0 + max: 100 + unit_of_measurement: '%' + mode: box + default: 35 + upper: + name: Upper review threshold + selector: + number: + min: 0 + max: 100 + unit_of_measurement: '%' + mode: box + default: 55 +initial_state: false +mode: single +max_exceeded: silent +variables: + allow_actions: !input 'allow_actions' + sensor: !input 'sensor' + timestamp_attribute: !input 'timestamp_attribute' + timestamp_format: !input 'timestamp_format' + maximum_age: !input 'maximum_age' + lower: !input 'lower' + upper: !input 'upper' + reading: '{{ states(sensor) | float(none) }}' + measured_at: |- + {% set raw = state_attr(sensor, timestamp_attribute) %} + {% if timestamp_attribute in ['last_changed', 'last_updated', 'last_reported'] %} + {{ none }} + {% elif timestamp_format == 'iso8601' and raw is string %} + {{ as_timestamp(raw, none) }} + {% elif timestamp_format in ['unix_seconds', 'unix_milliseconds'] and raw is number and raw is not boolean %} + {{ raw / 1000 if timestamp_format == 'unix_milliseconds' else raw }} + {% else %} + {{ none }} + {% endif %} + fresh: |- + {{ has_value(sensor) and reading is number and reading is not boolean + and 0 <= reading <= 100 and state_attr(sensor, 'unit_of_measurement') == '%' + and measured_at is number and measured_at is not boolean + and maximum_age is number and 30 <= maximum_age <= 900 + and 0 <= now().timestamp() - measured_at < maximum_age + and 0 <= lower < upper <= 100 }} + notification_id: '{{ ''sleepypod_room_'' ~ this.entity_id }}' +triggers: +- trigger: state + entity_id: !input 'sensor' +- trigger: time_pattern + seconds: /30 +conditions: +- condition: template + value_template: '{{ allow_actions is sameas true }}' +actions: +- condition: template + value_template: '{{ allow_actions is sameas true }}' +- choose: + - conditions: + - condition: template + value_template: '{{ fresh and (reading < lower or reading > upper) }}' + sequence: + - action: persistent_notification.create + data: + notification_id: '{{ notification_id }}' + title: Room humidity review + message: |- + Room reading: {{ reading }} %. Review band: {{ lower }}–{{ upper }} %. + Source measurement: {{ measured_at | timestamp_local }}. + This proposal expires after {{ maximum_age }} seconds without a new source sample; the next 30-second check withdraws it. No appliance or Pod command was sent. + default: + - action: persistent_notification.dismiss + data: + notification_id: '{{ notification_id }}' diff --git a/integrations/home-assistant/blueprints/morning_scene.yaml b/integrations/home-assistant/blueprints/morning_scene.yaml new file mode 100644 index 000000000..fcc9b302b --- /dev/null +++ b/integrations/home-assistant/blueprints/morning_scene.yaml @@ -0,0 +1,82 @@ +# Disabled at startup; review inputs and qualify devices before enabling. +blueprint: + name: SleepyPod — Morning bed-exit scene + description: Activate a selected morning scene after you leave the bed in the morning window. This responds + to bed exit; it is not synchronized to the wake alarm and cannot replace it. It does not wake you + or move the base. Starts disabled; missing occupancy never counts as empty. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Allow this automation + selector: + boolean: {} + default: false + description: Leave off until you review the selected devices and qualify the sensors. It is checked + inside actions too; Run actions cannot bypass it. The automation also starts disabled after every + reload/restart. + occupancy: + name: Qualified SleepyPod occupancy + selector: + entity: + filter: + domain: binary_sensor + device_class: occupancy + description: Use the calibrated, expiring occupancy entity published by this Core fork after MQTT + and actual occupancy have been qualified. Occupancy is not sleep. Unknown/unavailable transitions + do not trigger actions. + scene: + name: Scene + selector: + entity: + filter: + domain: scene + description: Select a locally tested Home Assistant scene containing only the intended lights, shades, + or supported audio. No scene or device is selected automatically. This never commands the Pod. + window_start: + name: Window starts (Home Assistant timezone) + selector: + time: {} + default: 05:00:00 + window_end: + name: Window ends (Home Assistant timezone) + selector: + time: {} + default: '12:00:00' +initial_state: false +mode: single +max_exceeded: silent +variables: + allow_actions: !input 'allow_actions' + occupancy: !input 'occupancy' +triggers: +- trigger: state + entity_id: !input 'occupancy' + from: 'on' + to: 'off' + for: + seconds: 30 +conditions: +- condition: template + value_template: |- + {{ allow_actions is sameas true and trigger is defined and trigger.platform == 'state' + and trigger.from_state is not none and trigger.from_state.state == 'on' + and trigger.to_state is not none and trigger.to_state.state == 'off' + and is_state(occupancy, 'off') }} +- condition: time + after: !input 'window_start' + before: !input 'window_end' +actions: +- condition: template + value_template: |- + {{ allow_actions is sameas true and trigger is defined and trigger.platform == 'state' + and trigger.from_state is not none and trigger.from_state.state == 'on' + and trigger.to_state is not none and trigger.to_state.state == 'off' + and is_state(occupancy, 'off') }} +- condition: time + after: !input 'window_start' + before: !input 'window_end' +- action: scene.turn_on + target: + entity_id: !input 'scene' diff --git a/integrations/home-assistant/blueprints/night_path.yaml b/integrations/home-assistant/blueprints/night_path.yaml new file mode 100644 index 000000000..9f263fea1 --- /dev/null +++ b/integrations/home-assistant/blueprints/night_path.yaml @@ -0,0 +1,82 @@ +# Disabled at startup; review inputs and qualify devices before enabling. +blueprint: + name: SleepyPod — Nighttime path scene + description: Activate a selected dim-light scene after a qualified occupied-to-empty transition during + the night window. It does not turn the scene off later or restore lights over a manual change. Starts + disabled; missing occupancy never counts as empty. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Allow this automation + selector: + boolean: {} + default: false + description: Leave off until you review the selected devices and qualify the sensors. It is checked + inside actions too; Run actions cannot bypass it. The automation also starts disabled after every + reload/restart. + occupancy: + name: Qualified SleepyPod occupancy + selector: + entity: + filter: + domain: binary_sensor + device_class: occupancy + description: Use the calibrated, expiring occupancy entity published by this Core fork after MQTT + and actual occupancy have been qualified. Occupancy is not sleep. Unknown/unavailable transitions + do not trigger actions. + scene: + name: Scene + selector: + entity: + filter: + domain: scene + description: Select a locally tested Home Assistant scene containing only the intended lights, shades, + or supported audio. No scene or device is selected automatically. This never commands the Pod. + window_start: + name: Window starts (Home Assistant timezone) + selector: + time: {} + default: '21:00:00' + window_end: + name: Window ends (Home Assistant timezone) + selector: + time: {} + default: 05:00:00 +initial_state: false +mode: single +max_exceeded: silent +variables: + allow_actions: !input 'allow_actions' + occupancy: !input 'occupancy' +triggers: +- trigger: state + entity_id: !input 'occupancy' + from: 'on' + to: 'off' + for: + seconds: 5 +conditions: +- condition: template + value_template: |- + {{ allow_actions is sameas true and trigger is defined and trigger.platform == 'state' + and trigger.from_state is not none and trigger.from_state.state == 'on' + and trigger.to_state is not none and trigger.to_state.state == 'off' + and is_state(occupancy, 'off') }} +- condition: time + after: !input 'window_start' + before: !input 'window_end' +actions: +- condition: template + value_template: |- + {{ allow_actions is sameas true and trigger is defined and trigger.platform == 'state' + and trigger.from_state is not none and trigger.from_state.state == 'on' + and trigger.to_state is not none and trigger.to_state.state == 'off' + and is_state(occupancy, 'off') }} +- condition: time + after: !input 'window_start' + before: !input 'window_end' +- action: scene.turn_on + target: + entity_id: !input 'scene' diff --git a/integrations/home-assistant/blueprints/sensor-export/apple_home_cache.yaml b/integrations/home-assistant/blueprints/sensor-export/apple_home_cache.yaml new file mode 100644 index 000000000..369026af1 --- /dev/null +++ b/integrations/home-assistant/blueprints/sensor-export/apple_home_cache.yaml @@ -0,0 +1,116 @@ +blueprint: + name: SleepyPod — Apple Home climate export + description: >- + Receive one HomePod's temperature and humidity from an Apple Home automation. + Publish only cached observations to a fixed bedroom MQTT topic. This cannot + authorize Pod commands or qualify a measurement as fresh. Create a separate + instance and private webhook ID for each HomePod. No owner key is required. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Enable sensor reporting + default: false + selector: + boolean: {} + webhook_id: + name: Private webhook ID + description: Generate a new random value of at least 32 characters. Keep it private, like a password. + selector: + text: + type: password + report_topic: + name: Pod bedroom report topic + description: Copy the exact bedroom/report topic from the authenticated Pod status. No wildcards. + selector: + text: {} + configuration_revision: + name: Bedroom configuration revision + description: Use the Pod's current revision. Update after changing the source configuration. + selector: + number: + min: 1 + max: 2147483647 + mode: box + step: 1 + temperature_source: + name: Temperature source ID + selector: + text: {} + temperature_unit: + name: Configured temperature source unit + description: Match the Pod source configuration. Incoming values carry their own explicit unit. + default: °C + selector: + select: + options: [°C, °F] + humidity_source: + name: Humidity source ID + selector: + text: {} +mode: single +max_exceeded: silent +trace: + stored_traces: 0 +variables: + allow_actions: !input allow_actions + private_webhook: !input webhook_id + report_topic: !input report_topic + configuration_revision: !input configuration_revision + temperature_source: !input temperature_source + temperature_unit: !input temperature_unit + humidity_source: !input humidity_source +triggers: + - trigger: webhook + webhook_id: !input webhook_id + allowed_methods: [POST] + local_only: true +conditions: [] +actions: + # Keep guards inside the actions so a manual run cannot fabricate a report. + - condition: template + value_template: >- + {{ allow_actions is sameas true and trigger is defined and + trigger.platform == 'webhook' and trigger.json is mapping and + private_webhook is string and private_webhook | length >= 32 and + report_topic is string and report_topic | regex_match('^[A-Za-z0-9_/-]{1,200}/bedroom/report$') and + configuration_revision is number and configuration_revision is not boolean and + 1 <= configuration_revision <= 2147483647 and configuration_revision == configuration_revision | int and + temperature_source is string and temperature_source | regex_match('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$') and + humidity_source is string and humidity_source | regex_match('^[A-Za-z0-9][A-Za-z0-9_.:-]{0,119}$') and + temperature_source != humidity_source and temperature_unit in ['°C', '°F'] and + trigger.json | list | sort == ['humidity', 'schemaVersion', 'temperature', 'temperatureUnit'] and + trigger.json.schemaVersion is integer and trigger.json.schemaVersion == 1 and + trigger.json.temperatureUnit in ['°C', '°F'] }} + - variables: + reported_at: '{{ (as_timestamp(now()) * 1000) | int }}' + temperature_c: >- + {% set value = trigger.json.temperature %} + {% if value is number and value is not boolean %} + {% set celsius = (value - 32) * 5 / 9 if trigger.json.temperatureUnit == '°F' else value %} + {{ celsius if -40 <= celsius <= 60 else none }} + {% else %}{{ none }}{% endif %} + relative_humidity: >- + {% set value = trigger.json.humidity %} + {{ value if value is number and value is not boolean and 0 <= value <= 100 else none }} + - repeat: + for_each: + - source: '{{ temperature_source }}' + value: '{{ temperature_c * 9 / 5 + 32 if temperature_c is number and temperature_unit == "°F" else temperature_c }}' + - source: '{{ humidity_source }}' + value: '{{ relative_humidity }}' + sequence: + - action: mqtt.publish + data: + topic: '{{ report_topic }}' + qos: 0 + retain: false + payload: >- + {{ {'schemaVersion': 1, 'revision': configuration_revision | int, + 'sourceId': repeat.item.source, 'available': repeat.item.value is number, + 'value': repeat.item.value, 'observedAt': reported_at, + 'measuredAt': none, 'timeBasis': 'cached'} | to_json }} + # Suppress overlapping/repeated shortcuts; the Pod independently enforces per-source limits. + - delay: + seconds: 5 diff --git a/integrations/home-assistant/blueprints/sensor-export/apple_home_poll.yaml b/integrations/home-assistant/blueprints/sensor-export/apple_home_poll.yaml new file mode 100644 index 000000000..20d99616f --- /dev/null +++ b/integrations/home-assistant/blueprints/sensor-export/apple_home_poll.yaml @@ -0,0 +1,49 @@ +blueprint: + name: SleepyPod — Request Apple Home climate samples + description: >- + Pulse one dedicated virtual input_boolean every minute. Expose only that + helper through HomeKit Bridge, then use its On event to run the Apple Home + sensor-export shortcut. This does not switch a physical device or the Pod. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Enable periodic requests + default: false + selector: + boolean: {} + request_helper: + name: Dedicated sample-request helper + selector: + entity: + filter: + domain: input_boolean +mode: single +max_exceeded: silent +variables: + allow_actions: !input allow_actions + request_helper: !input request_helper +triggers: + - trigger: time_pattern + minutes: /1 +conditions: [] +actions: + - condition: template + value_template: >- + {{ allow_actions is sameas true and trigger is defined and trigger.platform == 'time_pattern' + and request_helper is string and request_helper | regex_match('^input_boolean[.][a-z0-9_]+$') + and states(request_helper) in ['on', 'off'] }} + - action: input_boolean.turn_off + target: + entity_id: !input request_helper + - delay: + seconds: 1 + - action: input_boolean.turn_on + target: + entity_id: !input request_helper + - delay: + seconds: 1 + - action: input_boolean.turn_off + target: + entity_id: !input request_helper diff --git a/integrations/home-assistant/blueprints/wind_down.yaml b/integrations/home-assistant/blueprints/wind_down.yaml new file mode 100644 index 000000000..012d7ddd9 --- /dev/null +++ b/integrations/home-assistant/blueprints/wind_down.yaml @@ -0,0 +1,49 @@ +# Disabled at startup; review inputs and qualify devices before enabling. +blueprint: + name: SleepyPod — Wind-down scene + description: Run a selected scene at your chosen Home Assistant local time. This is a separate house + schedule, not a Pod bedtime routine. Starts disabled. No catch-up after outages. + domain: automation + homeassistant: + min_version: 2026.10.0 + input: + allow_actions: + name: Allow this automation + selector: + boolean: {} + default: false + description: Leave off until you review the selected devices and qualify the sensors. It is checked + inside actions too; Run actions cannot bypass it. The automation also starts disabled after every + reload/restart. + scene: + name: Scene + selector: + entity: + filter: + domain: scene + description: Select a locally tested Home Assistant scene containing only the intended lights, shades, + or supported audio. No scene or device is selected automatically. This never commands the Pod. + time: + name: Wind-down time (Home Assistant timezone) + selector: + time: {} + default: '21:30:00' +initial_state: false +mode: single +max_exceeded: silent +variables: + allow_actions: !input 'allow_actions' +triggers: +- trigger: time + at: !input 'time' +conditions: +- condition: template + value_template: '{{ allow_actions is sameas true and trigger is defined and trigger.platform == ''time'' + }}' +actions: +- condition: template + value_template: '{{ allow_actions is sameas true and trigger is defined and trigger.platform == ''time'' + }}' +- action: scene.turn_on + target: + entity_id: !input 'scene' diff --git a/integrations/home-assistant/dashboards/README.md b/integrations/home-assistant/dashboards/README.md new file mode 100644 index 000000000..de5ec01b0 --- /dev/null +++ b/integrations/home-assistant/dashboards/README.md @@ -0,0 +1,81 @@ +# Sleepypod dashboard + +The [Tonight and Signals dashboard](sleepypod.yaml) uses Core's existing MQTT +discovery entities. It needs no HACS integration, custom JavaScript, external +fonts, new credentials or controller restart. It does not create duplicate +accessories or send bed commands. + +Tonight shows each side's reported thermal state, bed presence, water-loop +temperature and Pod ambient readings. Signals shows vitals when qualified, pump +diagnostics and twelve-hour histories. Buttons open the authenticated Sleepypod +app for controls, routines, room sensors, Autopilot and diagnostics. + +## Install + +1. Enable Core's MQTT bridge and Home Assistant discovery using your existing + authenticated broker. Confirm the Sleepypod device appears in Home Assistant. +2. Create a **new dashboard from scratch** in Settings → Dashboards. Choose a + sidebar name and a URL containing a hyphen, such as `sleepypod-bedroom`. +3. Open that dashboard, choose Edit dashboard → menu → Raw configuration editor, + and paste `sleepypod.yaml`. Do not replace an existing household dashboard. +4. Replace `http://sleepypod.local:3000` with the Pod's verified address or an + authenticated private HTTPS gateway. The sample hostname is a placeholder; + this file does not configure mDNS or remote access. Never include a connection + key in a URL. Direct LAN links only work where that LAN is reachable. +5. Map every entity ID to the actual discovered entities. The example uses the + default `sleepypod_eight_pod` prefix, but Home Assistant can rename IDs or add + suffixes. Check Settings → Devices & services → Entities, or add the device's + suggested cards temporarily and inspect their raw configuration. In particular, + the beat-interval entity name differs from its internal `hrv` unique ID. +6. Save, exit editing, and inspect both views. Check that all 19 entities resolve; + an existing entity can legitimately be **Unavailable**. That is different from + **Entity not found**, which means the mapping needs correction. Verify each app + link and the layout on a narrow screen. + +The file is ordinary dashboard YAML using built-in Home Assistant cards. See the +official [dashboard](https://www.home-assistant.io/dashboards/dashboards/) and +[tile-card](https://www.home-assistant.io/dashboards/tile/) documentation. + +## Data and control boundaries + +- Left and right identify hardware sides, not people. Sleeper assignments stay + in Core; this dashboard does not assign either side to a household member. +- Occupancy is not measured sleep, sleep stages or identity. Missing telemetry + stays unavailable. No template substitutes zero for a missing sensor. +- Climate temperatures are firmware-reported values. Water-loop temperatures + and room readings are distinct. The climate mode `heat` means thermal control + is active and can include cooling. See [telemetry](../../../docs/HOME_TELEMETRY.md). +- The standard dashboard only monitors MQTT. It does not expose climate sliders, + alarm tests, priming, raw firmware commands, reboot or update services. Controls + stay in the existing authenticated app. Enabling MQTT writes is a separate + deployment requiring scoped broker access and the checks in + [MQTT commands](../../../docs/MQTT_COMMANDS.md). +- Vitals require qualified signals. Unavailable vitals are not proof of sensor + failure; base/mechanical qualification may withhold them. +- Saving this dashboard does not enable any house automation. See the reviewed + [blueprints](../README.md) for opt-in scene workflows. + +## Homebridge and Apple Home + +Core already has a native HomeKit bridge. Pair that bridge once with Apple Home, +where it can coexist with Homebridge accessories. Do not install an MQTT-to- +HomeKit thermostat plugin for the same bed or re-export its HA climate entities +through another HomeKit bridge; those paths create duplicate accessories. + +Homebridge room data travels in the other direction: the existing bedroom +publisher supplies qualified source readings or explicitly cached values to +Core. Check **Sleepypod → Home** for provider, freshness and eligibility. A recent +cache read does not establish a new physical measurement. Apple Home's HomePod +sensors still need the separate [export workflow](../HOMEPOD_SENSOR_EXPORT.md). + +```mermaid +flowchart LR + Pod[Sleepypod Core] -->|MQTT discovery and readings| HA[Home Assistant dashboard] + HA -->|Open authenticated app| App[Sleepypod controls and routines] + Homebridge[Homebridge room accessories] -->|Qualified readings or labeled cache| Pod + Pod <-->|Native HomeKit bridge| Home[Apple Home] + Homebridge <-->|Existing bridge| Home +``` + +Removing this newly created dashboard reverses the UI installation. It does not +remove MQTT entities, histories, HomeKit pairings or Pod routines. diff --git a/integrations/home-assistant/dashboards/sleepypod.yaml b/integrations/home-assistant/dashboards/sleepypod.yaml new file mode 100644 index 000000000..1b424ac71 --- /dev/null +++ b/integrations/home-assistant/dashboards/sleepypod.yaml @@ -0,0 +1,292 @@ +# Core MQTT dashboard; change entity IDs and the Pod URL for your installation. +title: Sleepypod +views: +- title: Tonight + path: tonight + icon: mdi:bed-king-outline + type: sections + max_columns: 3 + header: + card: + type: markdown + text_only: true + content: '# Your bed, connected + + Live Pod readings in Home Assistant. Open Sleepypod for temperature controls, routines and alarms.' + badges: + - type: entity + entity: sensor.sleepypod_eight_pod_water_level + name: Water + show_name: true + color: blue + - type: entity + entity: sensor.sleepypod_eight_pod_ambient_temperature + name: Pod ambient + show_name: true + color: orange + - type: entity + entity: sensor.sleepypod_eight_pod_ambient_humidity + name: Pod humidity + show_name: true + color: teal + sections: + - type: grid + cards: + - type: heading + heading: Left side + icon: mdi:bed + - type: tile + entity: climate.sleepypod_eight_pod_left_side + name: Left thermal state + state_content: + - state + - current_temperature + color: blue + tap_action: + action: url + url_path: http://sleepypod.local:3000/en + icon_tap_action: + action: none + - type: tile + entity: binary_sensor.sleepypod_eight_pod_left_bed_occupancy + name: Left bed presence + color: teal + tap_action: + action: more-info + icon_tap_action: + action: none + - type: entity + entity: sensor.sleepypod_eight_pod_left_pump_loop_temp + name: Water loop + icon: mdi:water-thermometer + tap_action: + action: more-info + - type: markdown + content: Bed presence is not sleep or identity. Reported thermal state is separate from the measured + water loop. “Heat” means active thermal control and may include cooling. + - type: grid + cards: + - type: heading + heading: Right side + icon: mdi:bed + - type: tile + entity: climate.sleepypod_eight_pod_right_side + name: Right thermal state + state_content: + - state + - current_temperature + color: purple + tap_action: + action: url + url_path: http://sleepypod.local:3000/en + icon_tap_action: + action: none + - type: tile + entity: binary_sensor.sleepypod_eight_pod_right_bed_occupancy + name: Right bed presence + color: teal + tap_action: + action: more-info + icon_tap_action: + action: none + - type: entity + entity: sensor.sleepypod_eight_pod_right_pump_loop_temp + name: Water loop + icon: mdi:water-thermometer + tap_action: + action: more-info + - type: markdown + content: Bed presence is not sleep or identity. Reported thermal state is separate from the measured + water loop. “Heat” means active thermal control and may include cooling. + - type: grid + cards: + - type: heading + heading: Make yourself comfortable + icon: mdi:moon-waning-crescent + - type: button + name: Open bed controls + icon: mdi:bed-king-outline + show_state: false + tap_action: + action: url + url_path: http://sleepypod.local:3000/en + hold_action: + action: none + - type: button + name: Routines & alarms + icon: mdi:alarm + show_state: false + tap_action: + action: url + url_path: http://sleepypod.local:3000/en/schedule + hold_action: + action: none + - type: button + name: Bedroom sensors + icon: mdi:home-thermometer-outline + show_state: false + tap_action: + action: url + url_path: http://sleepypod.local:3000/en/home + hold_action: + action: none + - type: button + name: Autopilot & learning + icon: mdi:creation + show_state: false + tap_action: + action: url + url_path: http://sleepypod.local:3000/en/autopilot + hold_action: + action: none + - type: markdown + content: '### One place for your settings + + These links open the authenticated Sleepypod app. This dashboard monitors MQTT data and does not + send bed commands or edit schedules. + + + **Left and right are bed sides, not assigned people.** Your sleeper arrangement stays in Sleepypod.' + - type: markdown + content: '### When a reading is unavailable + + Missing or unqualified signals stay unavailable. They are never replaced with zero or “empty.” + Pod ambient readings can differ from the Govee room sensor; open **Bedroom sensors** for its source + and freshness.' +- title: Signals + path: signals + icon: mdi:chart-timeline-variant + type: sections + max_columns: 3 + header: + card: + type: markdown + text_only: true + content: '# Understand the signals + + History is reported telemetry. Bed presence is not sleep, and beat-interval variability is not + Apple Watch HRV.' + sections: + - type: grid + cards: + - type: heading + heading: Left signals + icon: mdi:heart-pulse + - type: tile + entity: sensor.sleepypod_eight_pod_left_heart_rate + name: Heart rate + color: red + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: sensor.sleepypod_eight_pod_left_breathing_rate + name: Breathing rate + color: teal + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: sensor.sleepypod_eight_pod_left_beat_interval_variability + name: Beat-interval variability + color: purple + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: sensor.sleepypod_eight_pod_left_pump_rpm + name: Pump speed + color: blue + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: binary_sensor.sleepypod_eight_pod_left_pump_stall_warning + name: Pump stall notice + color: amber + tap_action: + action: more-info + icon_tap_action: + action: none + - type: grid + cards: + - type: heading + heading: Right signals + icon: mdi:heart-pulse + - type: tile + entity: sensor.sleepypod_eight_pod_right_heart_rate + name: Heart rate + color: red + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: sensor.sleepypod_eight_pod_right_breathing_rate + name: Breathing rate + color: teal + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: sensor.sleepypod_eight_pod_right_beat_interval_variability + name: Beat-interval variability + color: purple + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: sensor.sleepypod_eight_pod_right_pump_rpm + name: Pump speed + color: blue + tap_action: + action: more-info + icon_tap_action: + action: none + - type: tile + entity: binary_sensor.sleepypod_eight_pod_right_pump_stall_warning + name: Pump stall notice + color: amber + tap_action: + action: more-info + icon_tap_action: + action: none + - type: grid + cards: + - type: heading + heading: Last 12 hours + icon: mdi:history + - type: history-graph + title: Bed presence + hours_to_show: 12 + entities: + - entity: binary_sensor.sleepypod_eight_pod_left_bed_occupancy + name: Left + - entity: binary_sensor.sleepypod_eight_pod_right_bed_occupancy + name: Right + - type: history-graph + title: Water-loop temperature + hours_to_show: 12 + entities: + - entity: sensor.sleepypod_eight_pod_left_pump_loop_temp + name: Left + - entity: sensor.sleepypod_eight_pod_right_pump_loop_temp + name: Right + - type: markdown + content: A gap in a graph means missing history. Pump stall notices are software warnings, not a + leak test or proof that the hardware is healthy. Use the app for diagnostic details. + - type: button + name: Open diagnostics + icon: mdi:stethoscope + show_state: false + tap_action: + action: url + url_path: http://sleepypod.local:3000/en/system + hold_action: + action: none diff --git a/modules/common/biometrics/adaptive-calibration-epoch-v1.json b/modules/common/biometrics/adaptive-calibration-epoch-v1.json new file mode 100644 index 000000000..e5e5320e8 --- /dev/null +++ b/modules/common/biometrics/adaptive-calibration-epoch-v1.json @@ -0,0 +1,22 @@ +[ + { + "format": "capSense", + "names": ["out", "cen", "in"], + "means": [1025, 575, 950], + "threshold": 300, + "referenceMean": 1.16, + "inside": [1035, 570, 952], + "boundary": [1050, 600, 975], + "outside": [1050, 600, 975.01] + }, + { + "format": "capSense2", + "names": ["A", "B", "C"], + "means": [10, 20, 30], + "threshold": 6, + "referenceMean": 1.16, + "inside": [10.25, 19.75, 30.25], + "boundary": [10.5, 20.5, 30.5], + "outside": [10.5, 20.5, 30.5001] + } +] diff --git a/modules/common/biometrics/build_identity.py b/modules/common/biometrics/build_identity.py index aad7c47a0..526ca1843 100644 --- a/modules/common/biometrics/build_identity.py +++ b/modules/common/biometrics/build_identity.py @@ -16,7 +16,7 @@ 'common/__init__.py', 'common/cbor_raw.py', 'common/biometrics/__init__.py', 'common/biometrics/build_identity.py', 'common/biometrics/config.py', 'common/biometrics/manifest.v1.json', - 'common/biometrics/frames.py', 'common/biometrics/gates.py', + 'common/biometrics/live_input.py', 'common/biometrics/mechanical.py', 'common/biometrics/frames.py', 'common/biometrics/gates.py', 'common/biometrics/processing.py', 'common/biometrics/routing.py', 'common/biometrics/signal.py', 'common/biometrics/raw_artifacts.py', 'common/biometrics/raw_records.py', 'common/biometrics/retained_replay.py', diff --git a/modules/common/biometrics/live_input.py b/modules/common/biometrics/live_input.py new file mode 100644 index 000000000..fc4080d3e --- /dev/null +++ b/modules/common/biometrics/live_input.py @@ -0,0 +1,100 @@ +"""Shared input boundary for live estimation and prospective comparison.""" +from dataclasses import dataclass +import math +import time +from typing import Optional, Tuple + +import numpy as np + +from .frames import decode_int32_samples + +FIRMWARE_TIMESTAMP_BASIS = "firmware_event_time_v1" +MAX_SOURCE_AGE_S = 10.0 +MAX_FRAME_GAP_S = 1.5 # piezo-dual carries one second of samples per frame + + +class LiveFrameClock: + """Reject backlog and clock errors; mark gaps so rolling buffers restart. + + An accepted timestamp is the firmware frame's time, not its arrival time. + The watermark survives rejected frames so a duplicate cannot become fresh + on the next attempt. A disconnected stream must warm up again on recovery. + """ + def __init__(self, wall=time.time, monotonic=time.monotonic): + self.wall = wall + self.monotonic = monotonic + self.timestamp = None + self.last_arrival = None + self.discontinuous = True + self.problem = None + self._broken = True + + def __call__(self): + if self.timestamp is None: + raise RuntimeError("No accepted live firmware timestamp") + return self.timestamp + + def break_continuity(self): + self._broken = True + + def admit(self, timestamp): + now, arrival = self.wall(), self.monotonic() + reason = None + if (type(timestamp) not in (int, float) or not 0 < timestamp <= 253_402_300_799 + or not math.isfinite(timestamp) or not math.isfinite(now) or not math.isfinite(arrival)): + reason = "Live piezo frame has no valid firmware timestamp." + elif timestamp > now: + reason = "Live piezo frame is ahead of the Pod clock." + elif now - timestamp > MAX_SOURCE_AGE_S: + reason = "Live piezo frame is too old; waiting for current sensor data." + elif self.timestamp is not None and timestamp <= self.timestamp: + reason = "Live piezo frame is duplicated or out of order." + if reason: + self.problem = reason + self.break_continuity() + return False + self.discontinuous = (self._broken or self.timestamp is None + or timestamp - self.timestamp > MAX_FRAME_GAP_S + or self.last_arrival is None + or arrival < self.last_arrival) + self.timestamp, self.last_arrival = timestamp, arrival + self.problem = None + self._broken = False + return True + + +@dataclass(frozen=True) +class LiveFrameResult: + status: str + samples: Optional[Tuple[np.ndarray, np.ndarray]] = None + + +def reset_live_buffers(processors): + """Discard unfinished estimates across gaps, retaining completed candidates.""" + for processor in processors: + processor.install_runtime(processor.parameters, processor.run_context, reset_side=True) + + +def prepare_live_frame(record, clock, processors, pump_gate, mechanical=None): + """Apply one timestamp, channel and pump boundary in both production paths.""" + if not clock.admit(record.get("ts")): + reset_live_buffers(processors) + return LiveFrameResult("timestamp_rejected_frames") + if clock.discontinuous: + reset_live_buffers(processors) + if mechanical is not None: + if not mechanical.admit(clock()): + clock.break_continuity() + reset_live_buffers(processors) + return LiveFrameResult('mechanical_gated_frames') + if mechanical.changed: + reset_live_buffers(processors) + left = decode_int32_samples(record.get("left1", b"")) + right = decode_int32_samples(record.get("right1", b"")) + status = ("empty_channel_frames" if left.size == 0 or right.size == 0 + else "pump_gated_frames" if pump_gate.check(left, right) else None) + if status: + clock.break_continuity() + reset_live_buffers(processors) + return LiveFrameResult(status) + return LiveFrameResult("accepted_frames", (left, right)) diff --git a/modules/common/biometrics/manifest.v1.json b/modules/common/biometrics/manifest.v1.json index fdef253cd..f361d774b 100644 --- a/modules/common/biometrics/manifest.v1.json +++ b/modules/common/biometrics/manifest.v1.json @@ -1,6 +1,6 @@ { "id": "sleepypod-biometric-bundle", - "version": "1.0.0", + "version": "1.0.1", "schemaVersion": 1, "canonicalization": "finite-binary64-json-v1", "sampleRateHz": 500, @@ -605,7 +605,7 @@ }, "sleep_stages": { "algorithmId": "retrospective-stage-rules-v1", - "version": "1.0.0", + "version": "1.0.1", "runtime": "node", "requiredInputs": [ "vitals", @@ -625,19 +625,22 @@ }, "sourceHashes": { "modules/common/biometrics/signal.py": "fff73469815673c8daebba9e2c9e7bcf97708939ad7a5639a52f0a53c9d75c18", - "modules/piezo-processor/main.py": "75db2ea2f005fd537d994b306332c1abf97c0c28abb496a8d3af03816ccf8550", - "src/lib/sleep-stages.ts": "b1800e49ce8525d314333da6a29a63131a002d160bb294fa97b24df6e655e228", - "modules/piezo-processor/runtime_adapter.py": "0a22a2fa05aae6569bfc4b53fba80d75e89f702f021ccb61f82ce64e284c5bb8", + "modules/piezo-processor/main.py": "0447dfaf0956f5dfd72aeb95f10e5b02654c81ec4a91a826f75935a04d154635", + "src/lib/sleep-stages.ts": "421a85f6c039407bf300f9b92080c8958cb7f1733e7b781c1a1318b6f5352398", + "modules/piezo-processor/runtime_adapter.py": "f280dabdc76959208af9ffb3caf34167a4a784fba08d00e41b59e3c535c05b6f", "modules/common/biometrics/runtime.py": "dbe327ce6e6ae315cad83439903e4c5d6a5c594468086739504caa8ed028a944", "modules/common/biometrics/gates.py": "c46285ff1cdf95bd4184823fabd6ceb66b005f7c410ee87880de4e563edd6e7c", "modules/common/biometrics/routing.py": "04872bab0f797e0bedd6e7114e686bbf7a5b4110ed2052c9b160e5aa1f7d0839", "modules/common/biometrics/processing.py": "2d04538499498f3b4fa9fa7383d1d3e6c0f66c1db3455bfd67ede53be7d080ef", "modules/common/biometrics/frames.py": "99427d69cf63613c96e45f2f280b53a6e7db18997a28e579cd5e412684db259f", - "modules/common/biometrics/replay.py": "50c70408dca033dcf17d5bf6a1aa7afc9ed531d09aa3151eb61941f7c504639f" + "modules/common/biometrics/replay.py": "29ec711d935e76e7118da7e0b417c1d8d5273b12832a11acc13ae37e4d52e95c", + "modules/common/biometrics/live_input.py": "bc0a08648e56bad782dc0d280e4a70e757b5edd665d0745482bb79a5ecabbcbf", + "modules/common/biometrics/mechanical.py": "9b893fdae76f7ca57b82fbdc3ca13d75e58b71606e35c7c3368300e73ed8b62c", + "src/lib/stage-coverage.ts": "14c9cb92801f9fd10f9f094f9919ee5aece570b56a2b3fd1e3b52ce299c8e0c4" }, "dependencyHashes": { - "modules/piezo-processor/uv.lock": "2803397e553a32588071d6be84c2bce5326552da03076261f48afce0ba919869", - "pnpm-lock.yaml": "bf4c8635e3f34acd1637bdc33fab0c1a9ee1287dcad827d60c8b9b4027118a41" + "modules/piezo-processor/uv.lock": "ceb7356c660093dc79c3116884caa1c57f1ebaf3e544bd490476a9862e601c35", + "pnpm-lock.yaml": "fbb69a2ff032363959518ea0b1fb442383411e846db0f06431d6dc4503909fd7" }, "legacySourceHashes": { "modules/piezo-processor/main.py": "c98bdb3d6ee38b5de5d36760238978a8fefe2153a0cdf3e82ea1872d2d0da34f", diff --git a/modules/common/biometrics/mechanical.py b/modules/common/biometrics/mechanical.py new file mode 100644 index 000000000..fe36a97ec --- /dev/null +++ b/modules/common/biometrics/mechanical.py @@ -0,0 +1,196 @@ +"""Observed mechanical context and bounded, immutable vital-output quarantine. + +Vibration evidence is command-derived, not a physical vibration measurement. +No hardware control and no inference that an absent base is stationary. +""" +from copy import deepcopy +from dataclasses import replace +import json +import math +import sqlite3 +from uuid import UUID + +VERSION = 'mechanical-context-v1' +FRESH_MS = 10_000 +MAX_PENDING = 16 +MAX_WAIT_MS = 120_000 +REASONS = {'priming', 'vibration_command', 'base_motion', 'device_unavailable', + 'base_unavailable', 'alarm_state_unavailable', 'settling', + 'controller_restart', 'controller_stopped', 'clock_regression', 'observation_gap'} + + +def _time(value): + if type(value) is not int or not 0 <= value <= 9_007_199_254_740_991: + raise ValueError('Invalid mechanical timestamp') + return value + + +def _uuid(value): + if not isinstance(value, str) or str(UUID(value)) != value: + raise ValueError('Invalid mechanical identity') + + +def validate_snapshot(value): + """Validate independently of TypeScript: worker inputs are not trusted.""" + if value is None: + return None + if not isinstance(value, dict) or set(value) != {'state', 'spans'}: + raise ValueError('Invalid mechanical snapshot') + state = value['state'] + fields = {'version', 'epoch', 'publishedAtMs', 'confirmedThroughMs', 'validUntilMs', + 'blockedUntilMs', 'cleanSinceMs', 'spanId', 'reasons', 'commandAtMs', 'commandUntilMs'} + if not isinstance(state, dict) or set(state) != fields or state['version'] != VERSION: + raise ValueError('Invalid mechanical state') + _uuid(state['epoch']) + for key in ('publishedAtMs', 'validUntilMs', 'blockedUntilMs', 'commandUntilMs'): + _time(state[key]) + for key in ('confirmedThroughMs', 'cleanSinceMs', 'commandAtMs'): + if state[key] is not None: + _time(state[key]) + if (state['cleanSinceMs'] is None) != (state['spanId'] is None): + raise ValueError('Mechanical span identity mismatch') + if state['spanId'] is not None: + _uuid(state['spanId']) + if (not isinstance(state['reasons'], list) or len(state['reasons']) > 12 + or any(not isinstance(reason, str) or reason not in REASONS for reason in state['reasons'])): + raise ValueError('Invalid mechanical reasons') + confirmed = state['confirmedThroughMs'] + if confirmed is not None and confirmed > state['publishedAtMs']: + raise ValueError('Future mechanical observation') + if state['cleanSinceMs'] is None: + if not state['reasons']: + raise ValueError('Missing mechanical reason') + elif (state['reasons'] or confirmed is None + or not state['blockedUntilMs'] <= state['cleanSinceMs'] <= confirmed + or state['validUntilMs'] != confirmed + FRESH_MS): + raise ValueError('Invalid quiet evidence') + spans = value['spans'] + if not isinstance(spans, list) or len(spans) > 64: + raise ValueError('Mechanical history exceeds bound') + for span in spans: + if not isinstance(span, dict) or set(span) != {'id', 'epoch', 'startMs', 'endMs'}: + raise ValueError('Invalid mechanical span') + _uuid(span['id']) + _uuid(span['epoch']) + if _time(span['startMs']) > _time(span['endMs']): + raise ValueError('Reversed mechanical span') + return deepcopy(value) + + +def read_snapshot(connection, now_ms): + """Read a coherent bounded SQLite snapshot; never create a missing schema.""" + try: + _time(now_ms) + # SAVEPOINT works whether the caller already owns a transaction or not. + connection.execute('SAVEPOINT mechanical_read') + try: + row = connection.execute('SELECT CASE WHEN length(CAST(payload AS BLOB)) <= 8192 ' + 'THEN payload END FROM biometric_mechanical_state WHERE id=1').fetchone() + if row is None: + return None + state = json.loads(row[0]) + rows = connection.execute('SELECT id, epoch, start_ms, end_ms FROM biometric_mechanical_spans ' + 'WHERE end_ms >= ? AND end_ms <= ? ORDER BY end_ms DESC, id DESC LIMIT 64', + (now_ms - 600_000, now_ms)).fetchall() + return validate_snapshot({'state': state, 'spans': [dict(zip(('id', 'epoch', 'startMs', 'endMs'), row)) for row in rows]}) + finally: + connection.execute('RELEASE mechanical_read') + except (sqlite3.Error, ValueError, TypeError, KeyError): + return None + + +class MechanicalGate: + """Hold source-time evidence; processing a cached status cannot extend it.""" + def __init__(self): + self.snapshot = None + self.now_ms = 0 + self.generation = None + self.changed = True + self.problem = 'Mechanical context unavailable' + + def update(self, snapshot, now_ms): + try: + _time(now_ms) + snapshot = validate_snapshot(snapshot) + except ValueError: + self.snapshot = None + self.generation = None + raise + if snapshot is not None: + state = snapshot['state'] + previous = self.snapshot['state'] if self.snapshot else None + if (now_ms < self.now_ms or state['publishedAtMs'] > now_ms + or now_ms - state['publishedAtMs'] >= FRESH_MS + or (previous and state['publishedAtMs'] < previous['publishedAtMs']) + or any(span['endMs'] > now_ms for span in snapshot['spans'])): + snapshot = None + self.snapshot, self.now_ms = snapshot, now_ms + + def admit(self, timestamp): + state = self.snapshot['state'] if self.snapshot else None + generation = None + if (state and state['cleanSinceMs'] is not None and not state['reasons'] + and state['confirmedThroughMs'] is not None + and state['confirmedThroughMs'] <= self.now_ms < state['validUntilMs'] + and state['validUntilMs'] <= state['confirmedThroughMs'] + FRESH_MS + and state['blockedUntilMs'] <= state['cleanSinceMs'] <= state['confirmedThroughMs'] + and any(span['id'] == state['spanId'] and span['epoch'] == state['epoch'] + and span['startMs'] == state['cleanSinceMs'] and span['endMs'] == state['confirmedThroughMs'] + for span in self.snapshot['spans']) + and type(timestamp) in (int, float) and math.isfinite(timestamp) + and timestamp * 1000 - 1000 >= state['cleanSinceMs']): + generation = state['epoch'], state['spanId'] + self.changed = generation != self.generation + self.generation = generation + self.problem = None if generation else 'Mechanical context unavailable or interference window active' + return generation is not None + + def proof(self, candidate): + if not self.snapshot: + return None + duration = max(30 if candidate.heart_rate is not None else 0, + 60 if candidate.breathing_rate is not None else 0, + 300 if candidate.hrv is not None else 0) + if not duration: + return None + # Firmware frame boundary convention is not independently qualified. + # Include one second on both edges, never shift its event timestamp. + start = math.floor(candidate.ts.timestamp() * 1000) - duration * 1000 - 1000 + end = math.ceil(candidate.ts.timestamp() * 1000) + 1000 + for span in self.snapshot['spans']: + if span['startMs'] <= start and span['endMs'] >= end: + return f'mechanical_context_v1:{span["epoch"]}:{span["id"]}:{start}:{end}' + return None + + +class MechanicalWriter: + """Hold completed candidates until observations cover their entire window. + + At most 16 candidates for at most two minutes. A DB retry retains values, + event timestamp, logical destination, configuration and source channel. + Expiry is a counted exclusion, never a later timestamp or replacement value. + """ + def __init__(self, gate, writer): + self.gate, self.writer = gate, writer + self.pending = [] + self.excluded = 0 + + def submit(self, candidate, destination=None): + self.tick() + if len(self.pending) >= MAX_PENDING: + return False + self.pending.append((deepcopy(candidate), destination, self.gate.now_ms)) + self.tick() + return True + + def tick(self): + remaining = [] + for candidate, destination, queued_at in self.pending: + if not 0 <= self.gate.now_ms - queued_at < MAX_WAIT_MS: + self.excluded += 1 + continue + proof = self.gate.proof(candidate) + proven = replace(candidate, flags=tuple(candidate.flags or ()) + (proof,)) if proof else None + if proven is None or not self.writer(proven, destination): + remaining.append((candidate, destination, queued_at)) + self.pending = remaining diff --git a/modules/common/biometrics/replay.py b/modules/common/biometrics/replay.py index ccd490ce0..18eb73f82 100644 --- a/modules/common/biometrics/replay.py +++ b/modules/common/biometrics/replay.py @@ -11,6 +11,8 @@ from .config import config_hash, validate_config from .frames import decode_int32_samples from .gates import FrzHealthPumpState, PumpGate +from .live_input import FIRMWARE_TIMESTAMP_BASIS, LiveFrameClock, prepare_live_frame +from .mechanical import MechanicalGate, MechanicalWriter, validate_snapshot from .processing import StatefulSideProcessor from .routing import SingleSleeperRouter from .signal import ProcessingParameters @@ -25,6 +27,7 @@ class RecordedContext: left_occupied: Optional[bool] = None right_occupied: Optional[bool] = None pump_context_known: bool = False + mechanical: Optional[dict] = None def __post_init__(self): if self.active_sides not in (None, (), ('left',), ('right',), SIDES): @@ -34,6 +37,7 @@ def __post_init__(self): raise ValueError('Invalid recorded occupancy') if type(self.pump_context_known) is not bool: raise ValueError('Invalid pump context availability') + object.__setattr__(self, 'mechanical', validate_snapshot(self.mechanical)) @dataclass(frozen=True) @@ -94,22 +98,32 @@ def __init__(self, configs: Mapping[str, dict], *, retained_start_ms: int, self._closed = False self._context = RecordedContext() self._sink = sink - if clock_mode not in ('recorded_event_time', 'observed_arrival_time'): + if clock_mode not in ('recorded_event_time', 'observed_arrival_time', FIRMWARE_TIMESTAMP_BASIS): raise ValueError('Invalid processing clock mode') + self.clock_mode = clock_mode self.limitations = {'cold_start_without_serialized_state'} + if clock_mode == FIRMWARE_TIMESTAMP_BASIS: + self.limitations.add('recorded_arrival_replaces_local_monotonic_clock') if clock_mode == 'recorded_event_time': self.limitations.add('event_time_replaces_arrival_time') if score_start_ms - retained_start_ms < 300_000: self.limitations.add('less_than_five_minutes_preroll') self.counts = {'records': 0, 'accepted_frames': 0, 'empty_channel_frames': 0, 'pump_gated_frames': 0, 'preroll_outputs': 0, 'scored_outputs': 0} + if clock_mode == FIRMWARE_TIMESTAMP_BASIS: + self.counts['timestamp_rejected_frames'] = 0 + self.counts['mechanical_gated_frames'] = 0 clock = lambda: self._now_ms / 1000 + self.frame_clock = LiveFrameClock(wall=clock, monotonic=clock) + processing_clock = self.frame_clock if clock_mode == FIRMWARE_TIMESTAMP_BASIS else clock self.pumps = FrzHealthPumpState(clock=clock) self.gate = PumpGate(clock=clock) - self.router = SingleSleeperRouter(self, self._write, clock=clock) + self.mechanical = MechanicalGate() + self.guarded_writer = MechanicalWriter(self.mechanical, self._write) + self.router = SingleSleeperRouter(self, self.guarded_writer.submit if clock_mode == FIRMWARE_TIMESTAMP_BASIS else self._write, clock=clock) self.processors = {} for side in SIDES: - processor = StatefulSideProcessor(side, clock=clock, + processor = StatefulSideProcessor(side, clock=processing_clock, writer=lambda candidate: self._write(candidate, None), candidate_factory=ReplayCandidate, pump_state=self.pumps, parameters=self.parameters[side]) @@ -132,7 +146,10 @@ def _occupied(self, side): return self._context.left_occupied if side == 'left' else self._context.right_occupied def _write(self, candidate, destination): - timestamp_ms = round(candidate.ts.timestamp() * 1000) + # The live SQLite writer stores integer Unix seconds. Exact joins must + # use that same quantization, even when firmware supplies fractions. + timestamp_ms = (int(candidate.ts.timestamp()) * 1000 if self.clock_mode == FIRMWARE_TIMESTAMP_BASIS + else round(candidate.ts.timestamp() * 1000)) if self.score_start_ms <= timestamp_ms < self.score_end_ms: # Like live provenance, a held candidate retains its computed side # even when the recorded mode changes before its sink is flushed. @@ -155,6 +172,7 @@ def feed(self, timestamp_ms: int, record: dict, context: RecordedContext): if isinstance(payload, (bytes, bytearray, memoryview)) and len(payload) > MAX_CHANNEL_BYTES: raise ValueError('Replay channel exceeds bounded frame size') self._now_ms, self._context = timestamp_ms, context + self.mechanical.update(context.mechanical, timestamp_ms) self.counts['records'] += 1 if context.active_sides is None: self.limitations.add('missing_sleeper_mode') @@ -162,20 +180,30 @@ def feed(self, timestamp_ms: int, record: dict, context: RecordedContext): self.limitations.add('missing_occupancy_context') if not context.pump_context_known: self.limitations.add('missing_pump_context') + if context.mechanical is None: + self.limitations.add('missing_mechanical_context') self.router.tick() + self.guarded_writer.tick() if record.get('type') == 'frzHealth': self.pumps.update(record) return if record.get('type') != 'piezo-dual': return - left = decode_int32_samples(record.get('left1', b'')) - right = decode_int32_samples(record.get('right1', b'')) - if left.size == 0 or right.size == 0: - self.counts['empty_channel_frames'] += 1 - return - if self.gate.check(left, right): - self.counts['pump_gated_frames'] += 1 - return + if self.clock_mode == FIRMWARE_TIMESTAMP_BASIS: + frame = prepare_live_frame(record, self.frame_clock, tuple(self.processors.values()), self.gate, self.mechanical) + if frame.samples is None: + self.counts[frame.status] += 1 + return + left, right = frame.samples + else: + left = decode_int32_samples(record.get('left1', b'')) + right = decode_int32_samples(record.get('right1', b'')) + if left.size == 0 or right.size == 0: + self.counts['empty_channel_frames'] += 1 + return + if self.gate.check(left, right): + self.counts['pump_gated_frames'] += 1 + return for side in SIDES: processor = self.processors[side] logical = self.home_side() or side @@ -191,4 +219,7 @@ def close(self): if not self._closed: self._now_ms = self.score_end_ms self.router.flush() + self.guarded_writer.tick() + if self.guarded_writer.pending: + self.limitations.add('unconfirmed_mechanical_windows_at_finish') self._closed = True diff --git a/modules/common/biometrics/shadow.py b/modules/common/biometrics/shadow.py index 6ed1a5259..724a4efc7 100644 --- a/modules/common/biometrics/shadow.py +++ b/modules/common/biometrics/shadow.py @@ -10,6 +10,7 @@ import time from .config import canonical_json +from .live_input import FIRMWARE_TIMESTAMP_BASIS from .replay import MAX_CHANNEL_BYTES, RecordedContext, ReplayReading, WaveformReplay @@ -40,7 +41,7 @@ def __init__(self, configs, *, side, start_ms, end_ms, maximum_records, self.state, self.reason = 'running', None self._processor = WaveformReplay(deepcopy(configs), retained_start_ms=start_ms, score_start_ms=start_ms, score_end_ms=end_ms, sink=self._capture, - clock_mode='observed_arrival_time') + clock_mode=FIRMWARE_TIMESTAMP_BASIS) def _capture(self, reading): if reading.side != self.side: @@ -86,9 +87,10 @@ def _execute(self, action): self._staged = [] def feed(self, observed_at_ms, record, context): - """Observed arrival time matches the live core's clock semantics. - Preserve source event time in record metadata, but never use it as the - live processing clock. Both channels/context feed the isolated state. + """Use firmware time for estimates and recorded arrival for admission. + + Arrival also drives pump/router timers; its difference from production's + local monotonic clock is retained as an explicit comparison limitation. """ def action(): if self._records >= self.maximum_records: @@ -112,7 +114,8 @@ def action(): encoded = canonical_json({'observedAtMs': observed_at_ms, 'record': identity, 'context': {'activeSides': list(context.active_sides) if context.active_sides is not None else None, 'leftOccupied': context.left_occupied, 'rightOccupied': context.right_occupied, - 'pumpContextKnown': context.pump_context_known}}).encode() + 'pumpContextKnown': context.pump_context_known, + 'mechanical': context.mechanical}}).encode() self._digest = hashlib.sha256(self._digest + encoded).digest() self._processor.feed(observed_at_ms, frozen, context) self._records += 1 @@ -129,7 +132,7 @@ def stop(self, reason='cancelled'): self._staged = [] def health(self): - return {'state': self.state, 'reason': self.reason, 'records': self._records, + return {'timestampBasis': FIRMWARE_TIMESTAMP_BASIS, 'state': self.state, 'reason': self.reason, 'records': self._records, 'outputs': self._outputs, 'cpu_ms': self._cpu_ns / 1_000_000, 'input_hash': self._digest.hex(), 'counts': dict(self._processor.counts), 'limitations': tuple(sorted(self._processor.limitations))} diff --git a/modules/common/biometrics/shadow_worker.py b/modules/common/biometrics/shadow_worker.py index aa566ca6f..a33d533e0 100644 --- a/modules/common/biometrics/shadow_worker.py +++ b/modules/common/biometrics/shadow_worker.py @@ -13,6 +13,7 @@ from .replay import MAX_CHANNEL_BYTES, RecordedContext from .shadow import ProspectiveShadow from .processing import HRV_WINDOW_S +from .live_input import FIRMWARE_TIMESTAMP_BASIS MAX_LINE_BYTES = 128 * 1024 @@ -74,7 +75,7 @@ def sink(output): candidate = output.reading.candidate pending.append({'sequence': output.sequence, 'inputHash': output.input_hash, 'side': output.reading.side, 'sourceSide': candidate.run_context.source_side, - 'timestampMs': output.reading.timestamp_ms, 'configHash': candidate.run_context.config_hash, + 'timestampMs': output.reading.timestamp_ms, 'timestampBasis': FIRMWARE_TIMESTAMP_BASIS, 'configHash': candidate.run_context.config_hash, 'readings': [ {'metric': 'heart_rate', 'unit': 'bpm', 'windowMs': None, 'value': candidate.heart_rate, 'quality': candidate.quality_score}, {'metric': 'breathing_rate', 'unit': 'breaths/min', 'windowMs': None, 'value': candidate.breathing_rate, 'quality': candidate.quality_score}, @@ -99,12 +100,13 @@ def sink(output): exact(message, 'type sequence observedAtMs record context') if message['type'] != 'frame' or type(message['sequence']) is not int or message['sequence'] != expected_sequence: raise ValueError('Out-of-order shadow protocol frame') - exact(message['context'], 'activeSides leftOccupied rightOccupied pumpContextKnown') + exact(message['context'], 'activeSides leftOccupied rightOccupied pumpContextKnown' + + (' mechanical' if 'mechanical' in message['context'] else '')) context = message['context'] if context['activeSides'] is not None and not isinstance(context['activeSides'], list): raise ValueError('Invalid shadow active sides') recorded = RecordedContext(tuple(context['activeSides']) if context['activeSides'] is not None else None, - context['leftOccupied'], context['rightOccupied'], context['pumpContextKnown']) + context['leftOccupied'], context['rightOccupied'], context['pumpContextKnown'], context.get('mechanical')) if not isinstance(message['record'], dict): raise ValueError('Invalid shadow record') record = dict(message['record']) diff --git a/modules/common/calibration_identity.py b/modules/common/calibration_identity.py new file mode 100644 index 000000000..39d74f905 --- /dev/null +++ b/modules/common/calibration_identity.py @@ -0,0 +1,46 @@ +"""Stable calibration epochs for bounded adaptive capacitance drift. + +The epoch anchors the baseline rather than its latest published means. Its +L1 drift budget bounds the change in summed deviation to one quarter of the +presence-entry threshold. This is an engineering acceptance bound, not a +claim of occupancy accuracy; live presence checks still apply. +""" + +import math +import re +import uuid + +DRIFT_FRACTION = 0.25 + + +def _finite(value): + return type(value) in (int, float) and math.isfinite(value) + + +def ensure_calibration_epoch(previous, sensor_format, means, threshold, + reference_mean): + """Retain a valid anchor within its cumulative budget; otherwise rotate.""" + if (sensor_format not in ("capSense", "capSense2") + or len(means) != 3 or not all(_finite(value) for value in means) + or not _finite(threshold) or threshold <= 0 + or not _finite(reference_mean)): + raise ValueError("Invalid capacitance calibration identity") + if isinstance(previous, dict): + anchors = previous.get("means") + valid = (type(previous.get("version")) is int + and previous["version"] == 1 + and isinstance(previous.get("id"), str) + and re.fullmatch(r"[a-f0-9]{32}", previous["id"]) is not None + and previous.get("format") == sensor_format + and _finite(previous.get("threshold")) + and previous["threshold"] == threshold + and _finite(previous.get("referenceMean")) + and previous["referenceMean"] == reference_mean + and isinstance(anchors, list) and len(anchors) == 3 + and all(_finite(value) for value in anchors)) + if valid and sum(abs(current - anchor) for current, anchor + in zip(means, anchors)) <= threshold * DRIFT_FRACTION: + return {**previous, "means": list(anchors)} + return {"version": 1, "id": uuid.uuid4().hex, "format": sensor_format, + "means": list(means), "threshold": threshold, + "referenceMean": reference_mean} diff --git a/modules/common/test_build_identity.py b/modules/common/test_build_identity.py index 6cafee1e3..8d31cbc00 100644 --- a/modules/common/test_build_identity.py +++ b/modules/common/test_build_identity.py @@ -14,6 +14,7 @@ def test_identity_is_deterministic_and_contains_sources_and_installed_versions() assert first['hash'] == config_hash(first['identity']) assert set(first['identity']['sources']) == set(build_identity.SOURCE_FILES) assert 'common/biometrics/build_identity.py' in first['identity']['sources'] + assert 'common/biometrics/live_input.py' in first['identity']['sources'] assert first['identity']['dependencies']['numpy']['version'] == importlib.metadata.version('numpy') assert first['identity']['dependencies']['scipy']['version'] == importlib.metadata.version('scipy') root = Path(build_identity.__file__).resolve().parents[2] @@ -55,3 +56,13 @@ def read_text(self, name): monkeypatch.setattr(importlib.metadata, 'distribution', lambda name: Missing()) with pytest.raises(ValueError, match='unavailable'): build_identity.replay_build_identity() + + +def test_changed_shared_live_input_invalidates_replay_and_shadow_qualification(monkeypatch): + from common.biometrics.shadow_build import shadow_build_identity + before_replay = build_identity.replay_build_identity()['hash'] + before_shadow = shadow_build_identity()['hash'] + original = build_identity._digest_file + monkeypatch.setattr(build_identity, '_digest_file', lambda path: '0' * 64 if path.name == 'live_input.py' else original(path)) + assert build_identity.replay_build_identity()['hash'] != before_replay + assert shadow_build_identity()['hash'] != before_shadow diff --git a/modules/common/test_calibration_identity.py b/modules/common/test_calibration_identity.py new file mode 100644 index 000000000..5f3ef7ece --- /dev/null +++ b/modules/common/test_calibration_identity.py @@ -0,0 +1,67 @@ +"""Writer-side epoch invariants; the same fixture exercises the Node reader.""" +import copy +import json +from pathlib import Path + +import pytest + +from common.calibration_identity import ensure_calibration_epoch + +CASES = json.loads((Path(__file__).parent / "biometrics" / + "adaptive-calibration-epoch-v1.json").read_text()) + + +def update(previous, case, means=None, **changes): + return ensure_calibration_epoch(previous, changes.get("format", case["format"]), + means or case["means"], + changes.get("threshold", case["threshold"]), + changes.get("referenceMean", case["referenceMean"])) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case["format"]) +def test_cumulative_drift_uses_fixed_anchor_across_publications(case): + epoch = update(None, case) + restored = json.loads(json.dumps(epoch)) + for means in (case["inside"], case["boundary"], case["inside"]): + restored = update(restored, case, means) + assert restored == epoch + changed = update(restored, case, case["outside"]) + assert changed["id"] != epoch["id"] + assert changed["means"] == case["outside"] + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case["format"]) +def test_recalibration_and_parameter_changes_cannot_reuse_epoch(case): + epoch = update(None, case) + assert update(None, case)["id"] != epoch["id"] + assert update(epoch, case, threshold=case["threshold"] + 1)["id"] != epoch["id"] + assert update(epoch, case, referenceMean=1.17)["id"] != epoch["id"] + corrupt = copy.deepcopy(epoch) + corrupt["version"] = True + assert update(corrupt, case)["id"] != epoch["id"] + corrupt["version"] = 2 + assert update(corrupt, case)["id"] != epoch["id"] + corrupt["version"] = 1 + corrupt["id"] = "invalid" + assert update(corrupt, case)["id"] != epoch["id"] + + +@pytest.mark.parametrize("means,threshold,reference", [ + ([1, 2, float("nan")], 300, 1.16), + ([1, 2, float("inf")], 300, 1.16), + ([True, 2, 3], 300, 1.16), + ([1, 2], 300, 1.16), + ([1, 2, 3], 0, 1.16), + ([1, 2, 3], True, 1.16), + ([1, 2, 3], 300, float("nan")), +]) +def test_unusable_calibration_cannot_create_epoch(means, threshold, reference): + with pytest.raises(ValueError): + ensure_calibration_epoch(None, "capSense", means, threshold, reference) + + +def test_returned_anchor_does_not_alias_previous_publication(): + epoch = update(None, CASES[0]) + published = update(epoch, CASES[0]) + published["means"][0] += 1 + assert epoch["means"] == CASES[0]["means"] diff --git a/modules/common/test_mechanical.py b/modules/common/test_mechanical.py new file mode 100644 index 000000000..63e4956f0 --- /dev/null +++ b/modules/common/test_mechanical.py @@ -0,0 +1,158 @@ +"""Whole-window exclusion, immutable retries and bounded failure recovery.""" +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime, timezone +import json +from pathlib import Path +import sqlite3 +from types import SimpleNamespace + +import pytest + +from common.biometrics.mechanical import ( + MechanicalGate, MechanicalWriter, read_snapshot, validate_snapshot, MAX_PENDING, +) +from common.biometrics.live_input import LiveFrameClock, prepare_live_frame +from common.tests_support.mechanical import quiet_snapshot, SPAN + +NOW = 1_700_000_000_000 + + +@dataclass +class Reading: + ts: datetime + heart_rate: float = 60 + breathing_rate: float = 12 + hrv: float = 30 + flags: tuple = () + run_context: tuple = ('original_config', 'right', 'left') + + +def candidate(at=NOW): + return Reading(datetime.fromtimestamp(at / 1000, timezone.utc)) + + +def test_full_window_delays_output_preserving_values_time_and_destination_across_retry(): + gate, outputs = MechanicalGate(), [] + writable = [False] + writer = MechanicalWriter(gate, lambda row, side: outputs.append((row, side)) or writable[0]) + gate.update(quiet_snapshot(NOW), NOW) + reading = candidate() + assert writer.submit(reading, 'left') + assert not outputs # Latest telemetry ends at t; candidate includes the t+1 frame edge. + reading.heart_rate = 199 # Caller mutations cannot rewrite a held observation. + gate.update(quiet_snapshot(NOW + 2000), NOW + 2000) + writer.tick() + assert len(writer.pending) == 1 + writable[0] = True + gate.update(quiet_snapshot(NOW + 3000), NOW + 3000) + writer.tick() + assert not writer.pending + assert len(outputs) == 2 and outputs[0] == outputs[1] + saved, side = outputs[1] + assert saved.heart_rate == 60 and saved.ts == candidate().ts and side == 'left' + assert saved.run_context == reading.run_context + assert saved.flags[0].endswith(f':{NOW - 301000}:{NOW + 1000}') + + +def test_current_quiet_does_not_qualify_a_window_that_crossed_interference(): + gate = MechanicalGate() + writer = MechanicalWriter(gate, lambda *_: pytest.fail('Contaminated interval was written')) + gate.update(quiet_snapshot(NOW, start=NOW - 10000), NOW) + assert writer.submit(candidate()) + for offset in (5000, 30000, 119000): + gate.update(quiet_snapshot(NOW + offset, start=NOW - 10000), NOW + offset) + writer.tick() + gate.update(quiet_snapshot(NOW + 120000), NOW + 120000) + writer.tick() + assert writer.pending == [] and writer.excluded == 1 + + +def test_closed_span_can_complete_an_older_clean_candidate_without_accepting_new_frames(): + gate, output = MechanicalGate(), [] + writer = MechanicalWriter(gate, lambda row, _: output.append(row) or True) + gate.update(quiet_snapshot(NOW), NOW) + writer.submit(candidate()) + snapshot = quiet_snapshot(NOW + 3000, end=NOW + 2000) + snapshot['state'].update(cleanSinceMs=None, spanId=None, reasons=['base_motion']) + gate.update(snapshot, NOW + 3000) + assert not gate.admit((NOW + 3000) / 1000) + writer.tick() + assert len(output) == 1 + + +@pytest.mark.parametrize('change', [None, 'future', 'stale', 'unknown', 'blocked', 'reversed', 'missing_span']) +def test_missing_or_unavailable_mechanical_context_never_admits_frames(change): + gate = MechanicalGate() + snapshot = quiet_snapshot(NOW) + if change is None: + snapshot = None + elif change == 'future': + snapshot['state']['publishedAtMs'] += 1 + elif change == 'stale': + snapshot['state']['publishedAtMs'] -= 10000 + elif change == 'unknown': + snapshot['state']['reasons'] = ['base_unavailable'] + elif change == 'blocked': + snapshot['state']['blockedUntilMs'] = NOW + 1000 + elif change == 'reversed': + snapshot['state']['confirmedThroughMs'] = NOW + 1 + else: + snapshot['state'].update(spanId=None, cleanSinceMs=None) + try: + gate.update(snapshot, NOW) + except ValueError: + # Invalid externally supplied worker context terminates the attempt. + assert change in ('blocked', 'reversed', 'unknown', 'missing_span', 'stale') + assert not gate.admit(NOW / 1000) + + +def test_gaps_and_new_spans_reset_unfinished_processor_buffers(): + gate = MechanicalGate() + gate.update(quiet_snapshot(NOW), NOW) + assert gate.admit(NOW / 1000) and gate.changed + assert gate.admit(NOW / 1000) and not gate.changed + snapshot = quiet_snapshot(NOW + 1000, span='3959cffb-c029-42c5-9f79-3d36bda042b4') + gate.update(snapshot, NOW + 1000) + assert gate.admit((NOW + 1000) / 1000) and gate.changed + resets = [] + processor = SimpleNamespace(parameters=None, run_context=None, install_runtime=lambda *args, **kwargs: resets.append(kwargs)) + clock = LiveFrameClock(wall=lambda: NOW / 1000, monotonic=lambda: 1) + gate.update(None, NOW + 2000) + frame = prepare_live_frame({'ts': NOW / 1000}, clock, (processor,), SimpleNamespace(check=lambda *_: False), gate) + assert frame.status == 'mechanical_gated_frames' and resets[-1]['reset_side'] + + +def test_queue_is_bounded_and_clock_rollback_does_not_retime_pending_estimates(): + gate = MechanicalGate() + gate.update(None, NOW) + writer = MechanicalWriter(gate, lambda *_: pytest.fail('Missing evidence was written')) + for _ in range(MAX_PENDING): + assert writer.submit(candidate()) + assert not writer.submit(candidate()) + gate.update(None, NOW - 1) + writer.tick() + assert not writer.pending and writer.excluded == MAX_PENDING + + +def test_reader_uses_real_migration_and_withholds_missing_or_corrupt_state(): + connection = sqlite3.connect(':memory:') + assert read_snapshot(connection, NOW) is None + migration = Path(__file__).parents[2] / 'src/db/biometrics-migrations/0032_mechanical_signal_context.sql' + connection.executescript(migration.read_text()) + snapshot = quiet_snapshot(NOW) + connection.execute('INSERT INTO biometric_mechanical_state VALUES (1,?)', (json.dumps(snapshot['state']),)) + span = snapshot['spans'][0] + connection.execute('INSERT INTO biometric_mechanical_spans VALUES (?,?,?,?)', tuple(span[key] for key in ('id', 'epoch', 'startMs', 'endMs'))) + assert read_snapshot(connection, NOW) == snapshot + connection.execute("UPDATE biometric_mechanical_state SET payload='{}'") + assert read_snapshot(connection, NOW) is None + oversized = deepcopy(snapshot) + oversized['spans'] *= 65 + with pytest.raises(ValueError): + validate_snapshot(oversized) + malformed = deepcopy(snapshot) + malformed['state']['publishedAtMs'] = True + with pytest.raises(ValueError): + validate_snapshot(malformed) + connection.close() diff --git a/modules/common/tests_support/__init__.py b/modules/common/tests_support/__init__.py new file mode 100644 index 000000000..00ee6417d --- /dev/null +++ b/modules/common/tests_support/__init__.py @@ -0,0 +1 @@ +"""Synthetic test evidence only; never imported by a deployed processor.""" diff --git a/modules/common/tests_support/mechanical.py b/modules/common/tests_support/mechanical.py new file mode 100644 index 000000000..f16838eac --- /dev/null +++ b/modules/common/tests_support/mechanical.py @@ -0,0 +1,12 @@ +"""Deterministic observed-time fixtures, with no hardware claims.""" +EPOCH = 'd669e2b4-0b92-4c92-9110-17a840e66c11' +SPAN = '154d214f-c095-4aa0-80d5-dc97665b39df' + + +def quiet_snapshot(now, start=1_699_999_000_000, end=None, span=SPAN): + end = now if end is None else end + return {'state': {'version': 'mechanical-context-v1', 'epoch': EPOCH, + 'publishedAtMs': now, 'confirmedThroughMs': end, 'validUntilMs': end + 10000, + 'blockedUntilMs': 0, 'cleanSinceMs': start, 'spanId': span, 'reasons': [], + 'commandAtMs': None, 'commandUntilMs': 0}, + 'spans': [{'id': span, 'epoch': EPOCH, 'startMs': start, 'endMs': end}]} diff --git a/modules/piezo-processor/main.py b/modules/piezo-processor/main.py index 7a55ada15..b5c4fb271 100644 --- a/modules/piezo-processor/main.py +++ b/modules/piezo-processor/main.py @@ -48,11 +48,15 @@ from common.bed_presence import BedPresence import numpy as np from runtime_adapter import RuntimeCoordinator, RunContext +from common.biometrics.live_input import ( + LiveFrameClock, FIRMWARE_TIMESTAMP_BASIS, prepare_live_frame, reset_live_buffers, +) from common.biometrics.frames import decode_int32_samples as _int32_samples, LOST_SAMPLE from common.biometrics.processing import ( StatefulSideProcessor, HR_WINDOW_S, BREATHING_WINDOW_S, HRV_WINDOW_S, ) from common.biometrics.routing import SingleSleeperRouter, VITALS_INTERVAL_S +from common.biometrics.mechanical import MechanicalGate, MechanicalWriter, read_snapshot, VERSION as MECHANICAL_VERSION from common.biometrics.gates import ( FrzHealthPumpState as _FrzHealthPumpState, PumpGate as _PumpGate, PresenceDetector, PUMP_ENERGY_MULTIPLIER, PUMP_CORRELATION_MIN, @@ -241,9 +245,9 @@ def write(self, holder: "DBHolder", side: Optional[str] = None) -> bool: class SingleSleeperVitals(SingleSleeperRouter[VitalsCandidate]): """Bind shared arbitration to the live database/provenance writer.""" def __init__(self, holder: "DBHolder", mode: SingleSleeperMode, - clock: Callable[[], float] = time.monotonic): + clock: Callable[[], float] = time.monotonic, writer=None): self._holder = holder - super().__init__(mode, lambda candidate, side: candidate.write(holder, side=side), + super().__init__(mode, writer or (lambda candidate, side: candidate.write(holder, side=side)), clock=clock) @@ -288,17 +292,19 @@ def __init__(self, fs: float = SAMPLE_RATE, *, clock=None): # --------------------------------------------------------------------------- class SideProcessor(StatefulSideProcessor): - """Live adapter binds shared state to wall time and canonical DB writes.""" + """Bind shared state to the supplied clock and canonical DB writes.""" def __init__(self, side: str, db_holder: DBHolder, pump_state: Optional[FrzHealthPumpState] = None, - parameters: ProcessingParameters = DEFAULT_PARAMETERS): + parameters: ProcessingParameters = DEFAULT_PARAMETERS, + clock: Optional[Callable[[], float]] = None): self.db_holder = db_holder - super().__init__(side, clock=lambda: time.time(), + super().__init__(side, clock=clock if clock is not None else lambda: time.time(), writer=lambda candidate: candidate.write(db_holder), candidate_factory=VitalsCandidate, pump_state=pump_state, parameters=parameters, autocorr_quality=lambda samples: _autocorr_quality(samples)) + # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- @@ -319,14 +325,17 @@ def main() -> None: db_holder = DBHolder(open_biometrics_db()) pump_gate = PumpGate() pump_state = FrzHealthPumpState() - left = SideProcessor("left", db_holder, pump_state=pump_state) - right = SideProcessor("right", db_holder, pump_state=pump_state) + frame_clock = LiveFrameClock(wall=lambda: time.time(), monotonic=lambda: time.monotonic()) + left = SideProcessor("left", db_holder, pump_state=pump_state, clock=frame_clock) + right = SideProcessor("right", db_holder, pump_state=pump_state, clock=frame_clock) left._other = right right._other = left # One side in away mode: a single sleeper — the away side's readings # (rolled over, leg across) are merged into the home side's series. bed_mode = SingleSleeperMode(SLEEPYPOD_DB) - vitals_router = SingleSleeperVitals(db_holder, bed_mode) + mechanical = MechanicalGate() + guarded_writer = MechanicalWriter(mechanical, lambda candidate, side: candidate.write(db_holder, side=side)) + vitals_router = SingleSleeperVitals(db_holder, bed_mode, writer=guarded_writer.submit) left.sink = right.sink = vitals_router.submit # Vitals only while the bed reads occupied — on the side they're stored # under, which is the home side with one side away. @@ -334,16 +343,32 @@ def main() -> None: left.bed_occupied = lambda: bed.occupied(bed_mode.home_side() or "left") right.bed_occupied = lambda: bed.occupied(bed_mode.home_side() or "right") left.home_side = right.home_side = bed_mode.home_side - runtime = RuntimeCoordinator(db_holder, {"left": left, "right": right}) + runtime = RuntimeCoordinator(db_holder, {"left": left, "right": right}, + timestamp_basis=FIRMWARE_TIMESTAMP_BASIS, mechanical_context=MECHANICAL_VERSION) runtime_problem = None + input_problem = None # Source selected once at startup: NatsFollower on new-firmware pods (NATS # reachable), else the unchanged .RAW tailer. Same decoded-record contract. follower = create_follower(RAW_DATA_DIR, _shutdown, poll_interval=0.01) report_health("healthy", "piezo-processor v2 started") + last_snapshot_at, snapshot = None, None + + def poll(): + nonlocal last_snapshot_at, snapshot + now = int(time.time() * 1000) + # Idle follower callbacks can run at 100 Hz; bound shared SQLite reads. + if last_snapshot_at is None or now < last_snapshot_at or now - last_snapshot_at >= 250: + snapshot = read_snapshot(db_holder.conn, now) + last_snapshot_at = now + mechanical.update(snapshot, now) + vitals_router.tick() + guarded_writer.tick() + try: - for record in follower.read_records(on_poll=vitals_router.tick): + for record in follower.read_records(on_poll=poll): + poll() rtype = record.get("type") # Surface genuinely-new firmware types once (blanketReadings, log, @@ -363,24 +388,27 @@ def main() -> None: if rtype != "piezo-dual": continue - # Each record contains ~500 int32 samples per channel - l_samples = _int32_samples(record.get("left1", b"")) - r_samples = _int32_samples(record.get("right1", b"")) - - if l_samples.size == 0 or r_samples.size == 0: - continue - - # Pump gating — drop entire record if pump detected or guard active - if pump_gate.check(l_samples, r_samples): + frame = prepare_live_frame(record, frame_clock, (left, right), pump_gate, mechanical) + if frame.status == "timestamp_rejected_frames": + if frame_clock.problem != input_problem: + input_problem = frame_clock.problem + report_health("degraded", input_problem) + elif frame.status == 'mechanical_gated_frames': + if mechanical.problem != input_problem: + input_problem = mechanical.problem + report_health('degraded', input_problem) + if frame.samples is None: continue + l_samples, r_samples = frame.samples - frame_time_ms = int(time.time() * 1000) + frame_time_ms = int(frame_clock() * 1000) runtime.before_frame(bed_mode.home_side(), bed.occupied, frame_time_ms) left.ingest(l_samples) right.ingest(r_samples) runtime.after_frame(frame_time_ms) - if runtime.problem != runtime_problem: + if runtime.problem != runtime_problem or input_problem is not None: runtime_problem = runtime.problem + input_problem = None report_health("degraded" if runtime_problem else "healthy", "Biometric configuration fallback: " + runtime_problem if runtime_problem else "Biometric runtime configuration available") @@ -391,6 +419,10 @@ def main() -> None: sys.exit(1) finally: vitals_router.flush() + guarded_writer.tick() + if guarded_writer.pending or guarded_writer.excluded: + log.warning('Mechanical context: %d unqualified candidates excluded; %d pending candidates discarded on shutdown', + guarded_writer.excluded, len(guarded_writer.pending)) db_holder.conn.close() log.info("Shutdown complete") diff --git a/modules/piezo-processor/pyproject.toml b/modules/piezo-processor/pyproject.toml index 5c84f2cda..171b9824d 100644 --- a/modules/piezo-processor/pyproject.toml +++ b/modules/piezo-processor/pyproject.toml @@ -5,6 +5,8 @@ requires-python = ">=3.9,<3.11" dependencies = [ "cbor2>=5.9.0", "heartpy>=1.2.7", + # HeartPy 1.2.7 imports pkg_resources at runtime; newer setuptools removes it. + "setuptools>=70,<82", "nats-py>=2.6.0", "numpy>=2.0.2", "scipy>=1.13.1,<1.16", diff --git a/modules/piezo-processor/runtime_adapter.py b/modules/piezo-processor/runtime_adapter.py index aa0276cec..4cb2b9c63 100644 --- a/modules/piezo-processor/runtime_adapter.py +++ b/modules/piezo-processor/runtime_adapter.py @@ -5,10 +5,12 @@ import sqlite3 import time from uuid import uuid4 +from typing import Optional from common.biometrics.config import config_hash, manifest from common.biometrics.runtime import BoundarySelection, RuntimeSnapshot, read_latest_snapshot from common.biometrics.signal import ProcessingParameters +from common.biometrics.mechanical import VERSION as MECHANICAL_VERSION log = logging.getLogger(__name__) COMPONENTS = ("heart_rate", "breathing_rate", "hrv_index") @@ -22,20 +24,27 @@ class RunContext: bundle_json: str config_hash: str started_at_ms: int + timestamp_basis: str = "processing_clock" + mechanical_context: Optional[str] = None def ensure(self, connection): """Called inside the observation transaction, including after DB reset.""" + identity = {"sourceSide": self.source_side, "timestampBasis": self.timestamp_basis, + "rawIdentity": "not_retained", "sensorCalibration": "not_consumed"} + if self.mechanical_context is not None: + if self.mechanical_context != MECHANICAL_VERSION: + raise ValueError('Unsupported mechanical context') + identity['mechanicalContext'] = self.mechanical_context connection.execute( "INSERT OR IGNORE INTO biometric_processing_runs " "(id, side, kind, bundle, config_hash, calibration_hash, input_identity, started_at_ms) " "VALUES (?, ?, 'live', ?, ?, NULL, ?, ?)", (self.id, self.side, self.bundle_json, self.config_hash, - json.dumps({"sourceSide": self.source_side, "timestampBasis": "processing_clock", - "rawIdentity": "not_retained", "sensorCalibration": "not_consumed"}), + json.dumps(identity), self.started_at_ms)) -def make_context(side, source_side, snapshot, now_ms): +def make_context(side, source_side, snapshot, now_ms, timestamp_basis="processing_clock", mechanical_context=None): shipped = manifest() resolved = snapshot.resolved if snapshot else shipped["defaults"] bundle = {"schemaVersion": 1, "manifestHash": config_hash(shipped), @@ -43,7 +52,7 @@ def make_context(side, source_side, snapshot, now_ms): "revisionId": snapshot.revision_id if snapshot else None, "components": shipped["components"], "sourceHashes": shipped["sourceHashes"]} return RunContext(str(uuid4()), side, source_side, json.dumps(bundle), - config_hash(resolved), now_ms) + config_hash(resolved), now_ms, timestamp_basis, mechanical_context) def restore_applied(connection, side): @@ -72,10 +81,13 @@ def restore_applied(connection, side): class RuntimeCoordinator: - def __init__(self, holder, processors, clock=time.monotonic, wall=time.time): + def __init__(self, holder, processors, clock=time.monotonic, wall=time.time, + timestamp_basis="processing_clock", mechanical_context=None): self.holder = holder self.processors = processors self.clock, self.wall = clock, wall + self.timestamp_basis = timestamp_basis + self.mechanical_context = mechanical_context self.selections = {side: BoundarySelection(side) for side in ("left", "right")} self.initialized = False self.enabled = False @@ -143,7 +155,7 @@ def before_frame(self, home, occupied, now_ms): key = (side, snapshot) if getattr(processor, "runtime_key", None) != key: parameters = ProcessingParameters.from_config(snapshot.resolved if snapshot else manifest()["defaults"]) - processor.install_runtime(parameters, make_context(side, source_side, snapshot, now_ms), + processor.install_runtime(parameters, make_context(side, source_side, snapshot, now_ms, self.timestamp_basis, self.mechanical_context), reset_side=getattr(processor, "runtime_key", (None,))[0] != side) processor.runtime_key = key if ready: diff --git a/modules/piezo-processor/test_live_frames.py b/modules/piezo-processor/test_live_frames.py new file mode 100644 index 000000000..542323e91 --- /dev/null +++ b/modules/piezo-processor/test_live_frames.py @@ -0,0 +1,160 @@ +"""Arrival time must not turn replayed or discontinuous input into fresh vitals.""" +from types import SimpleNamespace + +import numpy as np +import pytest + +from common.biometrics.live_input import LiveFrameClock, FIRMWARE_TIMESTAMP_BASIS +from common.tests_support.mechanical import quiet_snapshot + + +@pytest.fixture +def timeline(): + clock = SimpleNamespace(wall=1_792_962_000.0, monotonic=1000.0) + return clock, LiveFrameClock(lambda: clock.wall, lambda: clock.monotonic) + + +@pytest.mark.parametrize("timestamp", [None, True, "1792962000", -1, 0, + float("nan"), float("inf"), 10 ** 400]) +def test_rejects_missing_or_malformed_firmware_time(timeline, timestamp): + _, frames = timeline + assert not frames.admit(timestamp) + assert frames.problem + with pytest.raises(RuntimeError, match="No accepted"): + frames() + + +def test_retains_event_time_instead_of_arrival_and_bounds_latency(timeline): + clock, frames = timeline + assert frames.admit(clock.wall - 10) + assert frames() == clock.wall - 10 + assert frames.discontinuous + clock.wall += 1 + clock.monotonic += 1 + assert frames.admit(clock.wall - 10) + assert not frames.discontinuous + assert not frames.admit(clock.wall - 10.001) + assert "too old" in frames.problem + assert not frames.admit(clock.wall + .001) + assert "ahead" in frames.problem + + +def test_duplicate_and_reversed_frames_cannot_reset_the_watermark(timeline): + clock, frames = timeline + assert frames.admit(clock.wall) + assert not frames.admit(clock.wall) + assert not frames.admit(clock.wall - 1) + assert not frames.admit(clock.wall) + clock.wall += 1 + clock.monotonic += 1 + assert frames.admit(clock.wall) + assert frames.discontinuous + clock.wall += 1 + clock.monotonic += 1 + assert frames.admit(clock.wall) + assert not frames.discontinuous + + +@pytest.mark.parametrize("event_gap,arrival_gap", [(2, 1), (1, -1)]) +def test_source_gaps_and_arrival_clock_discontinuities_reset_windows(timeline, event_gap, arrival_gap): + clock, frames = timeline + assert frames.admit(clock.wall) + clock.wall += event_gap + clock.monotonic += arrival_gap + assert frames.admit(clock.wall) + assert frames.discontinuous + + +def test_slow_processing_preserves_complete_consecutive_source_frames(timeline): + clock, frames = timeline + assert frames.admit(clock.wall) + clock.wall += 3 + clock.monotonic += 3 + # Three seconds of processing did not lose either of the queued frames. + assert frames.admit(clock.wall - 2) + assert not frames.discontinuous + assert frames.admit(clock.wall - 1) + assert not frames.discontinuous + + +def test_dropped_or_pump_gated_frame_requires_a_new_window(timeline): + clock, frames = timeline + assert frames.admit(clock.wall) + frames.break_continuity() + clock.wall += 1 + clock.monotonic += 1 + assert frames.admit(clock.wall) + assert frames.discontinuous + + +def test_estimate_keeps_firmware_time_and_reset_clears_all_unfinished_buffers(timeline): + import main + clock, frames = timeline + assert frames.admit(clock.wall - 7) + processor = main.SideProcessor("left", main.DBHolder(None), clock=frames) + processor._presence.update = lambda *args, **kwargs: True + processor._quality = lambda samples: 1.0 + processor._estimator.estimate = lambda *args: SimpleNamespace( + heart_rate=60.0, hrv_index=30.0, breathing_rate=12.0, hr_raw=60.0, hr_score=1.0) + candidates = [] + processor.sink = lambda candidate: candidates.append(candidate) or True + processor.ingest(np.ones(main.SAMPLE_RATE * 10)) + assert candidates[0].ts.timestamp() == clock.wall - 7 + tracker = processor._hr_tracker + main.reset_live_buffers((processor,)) + assert len(processor._hr_buf) == len(processor._br_buf) == len(processor._hrv_buf) == 0 + assert processor._hr_tracker is not tracker + assert processor._last_med_std == processor._last_acr_qual == 0 + processor.ingest(np.ones(main.SAMPLE_RATE)) + assert len(candidates) == 1 # no old samples can complete the new estimate + + +def test_main_rejects_backlog_and_resets_before_accepting_recovery(monkeypatch, tmp_path): + import main + clock = SimpleNamespace(wall=1_792_962_000.0, monotonic=1000.0) + monkeypatch.setattr(main.time, "time", lambda: clock.wall) + monkeypatch.setattr(main.time, "monotonic", lambda: clock.monotonic) + monkeypatch.setattr(main, "BIOMETRICS_DB", tmp_path / "biometrics.db") + monkeypatch.setattr(main, "open_biometrics_db", lambda: SimpleNamespace(close=lambda: None)) + monkeypatch.setattr(main, "report_health", lambda *args: None) + monkeypatch.setattr(main, "read_snapshot", lambda _, now: quiet_snapshot(now)) + monkeypatch.setattr(main, "KNOWN_RECORD_TYPES", {"piezo-dual"}) + processors = [] + + class Processor: + def __init__(self, side, holder, **kwargs): + self.side, self.clock = side, kwargs["clock"] + self.parameters, self.run_context = None, None + self.buffer, self.seen = [], [] + processors.append(self) + + def install_runtime(self, parameters, context, reset_side): + assert reset_side + self.buffer.clear() + + def ingest(self, samples): + self.buffer.append(self.clock()) + self.seen.append(list(self.buffer)) + + def coordinator(holder, selected, **kwargs): + assert kwargs["timestamp_basis"] == FIRMWARE_TIMESTAMP_BASIS + return SimpleNamespace(problem=None, before_frame=lambda *args: None, after_frame=lambda *args: None) + + monkeypatch.setattr(main, "SideProcessor", Processor) + monkeypatch.setattr(main, "RuntimeCoordinator", coordinator) + monkeypatch.setattr(main, "SingleSleeperVitals", lambda *args, **kwargs: SimpleNamespace(submit=lambda *args: True, tick=lambda: None, flush=lambda: True)) + monkeypatch.setattr(main.PumpGate, "check", lambda *args: clock.monotonic == 1003) + + class Follower: + def read_records(self, on_poll): + payload = np.zeros(500, dtype="= 5 + else: + assert counts['mechanical_gated_frames'] > 500 + assert candidate.health()['timestampBasis'] == FIRMWARE_TIMESTAMP_BASIS + assert 'recorded_arrival_replaces_local_monotonic_clock' in candidate.health()['limitations'] diff --git a/modules/piezo-processor/test_shadow_worker.py b/modules/piezo-processor/test_shadow_worker.py index da212089d..c94dc31f2 100644 --- a/modules/piezo-processor/test_shadow_worker.py +++ b/modules/piezo-processor/test_shadow_worker.py @@ -11,6 +11,7 @@ import pytest from test_shadow import START, configs, frames +from common.tests_support.mechanical import quiet_snapshot from common.biometrics.config import config_hash from common.biometrics.shadow_worker import MAX_LINE_BYTES, run @@ -30,7 +31,7 @@ def encode(messages): def frame(sequence, timestamp, record): return {'type': 'frame', 'sequence': sequence, 'observedAtMs': timestamp, 'record': {key: base64.b64encode(value).decode() if isinstance(value, bytes) else value for key, value in record.items()}, - 'context': {'activeSides': ['left'], 'leftOccupied': True, 'rightOccupied': False, 'pumpContextKnown': True}} + 'context': {'activeSides': ['left'], 'leftOccupied': True, 'rightOccupied': False, 'pumpContextKnown': True, 'mechanical': quiet_snapshot(timestamp)}} def test_real_worker_protocol_produces_candidate_only_outputs_and_exits_cleanly(tmp_path): diff --git a/modules/piezo-processor/test_waveform_replay.py b/modules/piezo-processor/test_waveform_replay.py index d9403da0c..36fedba35 100644 --- a/modules/piezo-processor/test_waveform_replay.py +++ b/modules/piezo-processor/test_waveform_replay.py @@ -126,7 +126,7 @@ def test_recorded_home_change_resets_both_channels_and_selects_fixed_logical_con assert len(processor._hr_buf) == 500 # New logical side starts a fresh buffer. -def test_replay_numerics_match_live_adapter_assembly_for_identical_recorded_inputs(monkeypatch): +def test_retained_replay_matches_legacy_arrival_clock_adapter_for_identical_inputs(monkeypatch): import main from types import SimpleNamespace diff --git a/modules/piezo-processor/uv.lock b/modules/piezo-processor/uv.lock index 545501601..b4886dc7f 100644 --- a/modules/piezo-processor/uv.lock +++ b/modules/piezo-processor/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.9, <3.11" resolution-markers = [ "python_full_version >= '3.10'", @@ -449,6 +449,7 @@ dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "scipy", version = "1.13.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "setuptools" }, ] [package.metadata] @@ -458,6 +459,7 @@ requires-dist = [ { name = "nats-py", specifier = ">=2.6.0" }, { name = "numpy", specifier = ">=2.0.2" }, { name = "scipy", specifier = ">=1.13.1,<1.16" }, + { name = "setuptools", specifier = ">=70,<82" }, ] [[package]] @@ -590,6 +592,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, ] +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + [[package]] name = "six" version = "1.17.0" diff --git a/modules/sleep-detector/main.py b/modules/sleep-detector/main.py index 624ccf952..1f9c927ad 100644 --- a/modules/sleep-detector/main.py +++ b/modules/sleep-detector/main.py @@ -89,6 +89,7 @@ capsense_threshold, capsense2_channel_values, ) +from common.calibration_identity import ensure_calibration_epoch from session_limits import ABSENCE_TIMEOUT_S, MAX_SESSION_S, MIN_SESSION_S # --------------------------------------------------------------------------- @@ -605,7 +606,7 @@ def __init__(self, fmt: str, means: dict, threshold: float, ref_mean: Optional[float] = None, source: str = "bootstrap", profile_seen_at: Optional[float] = None, absence_ready: bool = False, occupied_load: Optional[float] = None, collapse_since: Optional[float] = None, - collapse_dwell_s: float = 0.0): + collapse_dwell_s: float = 0.0, qualification_epoch: Optional[dict] = None): self.fmt = fmt self.means = {ch: float(v) for ch, v in means.items()} self.threshold = float(threshold) @@ -631,6 +632,10 @@ def __init__(self, fmt: str, means: dict, threshold: float, self._load_avg: Optional[float] = None self._load_obs_s = 0.0 self._load_last_ts: Optional[float] = None + self._qualification_epoch = qualification_epoch + # Normalize or rotate before persistence. A restart preserves the + # epoch only while its anchor still describes this adaptive baseline. + self.to_params() @property def exit_threshold(self) -> float: @@ -639,26 +644,28 @@ def exit_threshold(self) -> float: @classmethod def from_profile(cls, params: dict, fmt: str, created_at: Optional[float]): """Seed from a calibration profile, or None if it doesn't match fmt.""" - channels = params.get("channels") or {} - if fmt == "capSense2": - if params.get("format") != "capSense2": - return None - names = ("A", "B", "C") - threshold = float(params.get("threshold", CAPSENSE2_PRESENCE_THRESHOLD)) - ref_mean = (params.get("ref") or {}).get("mean") - else: - if params.get("format") == "capSense2": - return None - names = ("out", "cen", "in") - threshold = capsense_threshold(params) - ref_mean = None try: + channels = params.get("channels") or {} + if fmt == "capSense2": + if params.get("format") != "capSense2": + return None + names = ("A", "B", "C") + threshold = float(params.get("threshold", CAPSENSE2_PRESENCE_THRESHOLD)) + ref_mean = (params.get("ref") or {}).get("mean") + else: + if params.get("format") == "capSense2": + return None + names = ("out", "cen", "in") + threshold = capsense_threshold(params) + ref_mean = None means = {ch: float(channels[ch]["mean"]) for ch in names} - except (KeyError, TypeError, ValueError): + return cls(fmt, means, threshold, ref_mean=ref_mean, + source=params.get("source", "profile"), + profile_seen_at=created_at, absence_ready=params.get("absence_ready") is True, + qualification_epoch=(params.get("qualification_epoch") + if params.get("source") == "adaptive" else None)) + except (KeyError, TypeError, ValueError, OverflowError): return None - return cls(fmt, means, threshold, ref_mean=ref_mean, - source=params.get("source", "profile"), - profile_seen_at=created_at, absence_ready=params.get("absence_ready") is True) @classmethod def from_state(cls, state: Optional[dict]): @@ -675,7 +682,8 @@ def from_state(cls, state: Optional[dict]): profile_seen_at=state.get("profile_seen_at"), absence_ready=state.get("absence_ready") is True, occupied_load=occupied_load, collapse_since=collapse_since, - collapse_dwell_s=collapse_dwell_s) + collapse_dwell_s=collapse_dwell_s, + qualification_epoch=state.get("qualification_epoch")) except (KeyError, TypeError, ValueError): return None @@ -683,7 +691,8 @@ def snapshot(self) -> dict: return {"format": self.fmt, "means": self.means, "threshold": self.threshold, "ref_mean": self.ref_mean, "profile_seen_at": self.profile_seen_at, "absence_ready": self.absence_ready, "occupied_load": self.occupied_load, - "collapse_since": self.collapse_since, "collapse_dwell_s": self.collapse_dwell_s} + "collapse_since": self.collapse_since, "collapse_dwell_s": self.collapse_dwell_s, + "qualification_epoch": self.to_params()["qualification_epoch"]} def values(self, record: dict, side: str) -> Optional[dict]: """This format's per-channel values for a frame, or None if unusable.""" @@ -808,6 +817,7 @@ def track(self, ts: float, values: dict, occupied: bool) -> None: def reseed(self, values: dict, source: str) -> None: self.means = {ch: float(v) for ch, v in values.items()} self.source = source + self._qualification_epoch = None self.absence_ready = False self._absence_reference = sum(self.means.values()) self._absence_drop_since = self._absence_rise_since = None @@ -831,6 +841,13 @@ def to_params(self) -> dict: for ch, m in self.means.items()}} params["source"] = "adaptive" params["absence_ready"] = self.absence_ready + names = ("A", "B", "C") if self.fmt == "capSense2" else ("out", "cen", "in") + means = [params["channels"][channel]["mean"] for channel in names] + reference = params.get("ref", {}).get("mean", 1.16) + self._qualification_epoch = ensure_calibration_epoch( + self._qualification_epoch, self.fmt, means, self.threshold, reference) + params["qualification_epoch"] = {**self._qualification_epoch, + "means": list(self._qualification_epoch["means"])} return params diff --git a/modules/sleep-detector/test_main.py b/modules/sleep-detector/test_main.py index 99c664a91..23c481ab5 100644 --- a/modules/sleep-detector/test_main.py +++ b/modules/sleep-detector/test_main.py @@ -9,6 +9,7 @@ import logging import sqlite3 import sys +import json from datetime import datetime, timezone from pathlib import Path from unittest.mock import patch @@ -30,7 +31,7 @@ # common.calibration and common.side_mode are stdlib-only, so load the real # modules rather than stubbing them — the baseline and single-sleeper tests # depend on their behaviour. -for _name in ("calibration", "side_mode"): +for _name in ("calibration", "calibration_identity", "side_mode"): _spec = importlib.util.spec_from_file_location( f"common.{_name}", Path(__file__).resolve().parent.parent / "common" / f"{_name}.py") _stubs[f"common.{_name}"] = importlib.util.module_from_spec(_spec) @@ -42,6 +43,63 @@ from main import sanitize_ts, MIN_VALID_WALL_CLOCK_TS # noqa: E402 +class TestAdaptiveQualificationEpoch: + """Routine drift must not invalidate every qualification/Preview night.""" + + def baseline(self): + return main.AdaptiveBaseline("capSense", {"out": 1025, "cen": 575, "in": 950}, 300) + + def test_publications_state_restore_and_profile_restore_keep_anchor(self): + baseline = self.baseline() + epoch = baseline.to_params()["qualification_epoch"] + baseline.means["out"] += 10 + baseline.means["cen"] -= 5 + published = baseline.to_params() + assert published["qualification_epoch"] == epoch + restored = main.AdaptiveBaseline.from_state(json.loads(json.dumps(baseline.snapshot()))) + assert restored.to_params()["qualification_epoch"] == epoch + from_profile = main.AdaptiveBaseline.from_profile(published, "capSense", 1793300000) + assert from_profile.to_params()["qualification_epoch"] == epoch + + def test_manual_recalibration_reseed_and_large_cumulative_drift_rotate(self): + baseline = self.baseline() + epoch = baseline.to_params()["qualification_epoch"] + manual = baseline.to_params() + manual.pop("source") + recalibrated = main.AdaptiveBaseline.from_profile(manual, "capSense", 1793300000) + assert recalibrated.to_params()["qualification_epoch"]["id"] != epoch["id"] + baseline.reseed(dict(baseline.means), "exit-rebase") + reseeded = baseline.to_params()["qualification_epoch"] + assert reseeded["id"] != epoch["id"] + for increment in range(1, 4): + baseline.means["out"] += 25 + assert baseline.to_params()["qualification_epoch"] == reseeded + baseline.means["out"] += 0.01 + assert baseline.to_params()["qualification_epoch"]["id"] != reseeded["id"] + + def test_legacy_saved_state_gets_one_persisted_epoch(self): + state = self.baseline().snapshot() + state.pop("qualification_epoch") + restored = main.AdaptiveBaseline.from_state(state) + epoch = restored.to_params()["qualification_epoch"] + next_restart = main.AdaptiveBaseline.from_state(restored.snapshot()) + assert next_restart.to_params()["qualification_epoch"] == epoch + + def test_published_parameters_cannot_mutate_internal_anchor(self): + baseline = self.baseline() + published = baseline.to_params() + published["qualification_epoch"]["means"][0] += 50 + assert baseline.to_params()["qualification_epoch"]["means"][0] == 1025 + + def test_unusable_profile_is_ignored_instead_of_crashing_detector(self): + profile = self.baseline().to_params() + profile["threshold"] = float("nan") + assert main.AdaptiveBaseline.from_profile(profile, "capSense", 1793300000) is None + profile = self.baseline().to_params() + profile["channels"]["out"]["mean"] = float("inf") + assert main.AdaptiveBaseline.from_profile(profile, "capSense", 1793300000) is None + + class TestSanitizeTs: """sleep_records id=30 had entered_bed_at=3 (1970-01-01 00:00:03 UTC). Root cause: a fresh RAW file post-restart can carry tiny relative ts diff --git a/package.json b/package.json index ee79c37d4..04e70c474 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "type": "module", "private": true, "scripts": { - "prebuild": "node scripts/generate-git-info.mjs && lingui compile && node scripts/build/demo-biometric-worker.mjs", + "prebuild": "node scripts/generate-git-info.mjs && lingui compile && node scripts/build/demo-biometric-worker.mjs && node scripts/build/personal-workspace.mjs", "build": "next build && node scripts/strip-turbopack-external-hashes.mjs && cp -r .next/static .next/standalone/.next/static && { [ ! -d public ] || cp -r public .next/standalone/public || true; } && node scripts/build/biometrics-worker.mjs", "dev": "next dev", "db:generate": "drizzle-kit generate", @@ -16,7 +16,7 @@ "db:reset": "rm -f sleepypod.dev.db sleepypod.dev.db-shm sleepypod.dev.db-wal && pnpm db:migrate", "db:reset:all": "rm -f sleepypod.dev.db sleepypod.dev.db-shm sleepypod.dev.db-wal biometrics.dev.db biometrics.dev.db-shm biometrics.dev.db-wal && pnpm db:migrate && pnpm db:biometrics:migrate", "db:reset:biometrics": "rm -f biometrics.dev.db biometrics.dev.db-shm biometrics.dev.db-wal && pnpm db:biometrics:migrate", - "db:reset:schedules": "sqlite3 sleepypod.dev.db \"BEGIN; DELETE FROM temperature_schedules; DELETE FROM power_schedules; DELETE FROM alarm_schedules; DELETE FROM run_once_sessions; COMMIT;\"", + "db:reset:schedules": "sqlite3 sleepypod.dev.db \"BEGIN; DELETE FROM bedtime_routines; DELETE FROM temperature_schedules; DELETE FROM power_schedules; DELETE FROM alarm_schedules; DELETE FROM run_once_sessions; COMMIT;\"", "db:reset:settings": "sqlite3 sleepypod.dev.db \"BEGIN; DELETE FROM device_settings; DELETE FROM side_settings; DELETE FROM tap_gestures; COMMIT;\"", "db:reset:state": "sqlite3 sleepypod.dev.db \"BEGIN; DELETE FROM device_state; DELETE FROM system_health; COMMIT;\"", "lingui:extract": "lingui extract --clean", @@ -30,7 +30,9 @@ "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "build:biometrics-worker": "node scripts/build/biometrics-worker.mjs", "biometrics:experiment": "tsx scripts/algorithm-experiments/cli.ts", - "test:browser": "node tests/browser/run.mjs" + "test:browser": "node tests/browser/run.mjs", + "predev": "node scripts/build/personal-workspace.mjs", + "build:personal-workspace": "node scripts/build/personal-workspace.mjs" }, "lint-staged": { "*.{ts,tsx}": "eslint --fix", @@ -67,7 +69,7 @@ "lucide-react": "^1.0.0", "mqtt": "^5.15.1", "negotiator": "^1.0.0", - "next": "^16.1.6", + "next": "^16.4.3", "node-schedule": "^2.1.1", "qrcode": "^1.5.4", "react": "^19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f70c531ac..42589a0d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: dependencies: '@ajayche/trpc-panel': specifier: ^2.0.4 - version: 2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + version: 2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) '@base-ui/react': specifier: ^1.2.0 version: 1.9.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -52,7 +52,7 @@ importers: version: 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/next': specifier: ^11.10.0 - version: 11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) + version: 11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3) '@trpc/react-query': specifier: ^11.10.0 version: 11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3) @@ -61,7 +61,7 @@ importers: version: 11.19.0(typescript@6.0.3) '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + version: 2.0.1(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) better-sqlite3: specifier: ^12.6.2 version: 12.11.1 @@ -105,8 +105,8 @@ importers: specifier: ^1.0.0 version: 1.1.0 next: - specifier: ^16.1.6 - version: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: ^16.4.3 + version: 16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) node-schedule: specifier: ^2.1.1 version: 2.1.1 @@ -149,7 +149,7 @@ importers: devDependencies: '@codecov/nextjs-webpack-plugin': specifier: ^2.0.1 - version: 2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2)) + version: 2.0.1(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2)) '@eslint/css': specifier: ^2.0.0 version: 2.0.0 @@ -1819,60 +1819,60 @@ packages: resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} engines: {node: '>= 18.0.0'} - '@next/env@16.4.0': - resolution: {integrity: sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==} + '@next/env@16.4.3': + resolution: {integrity: sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ==} '@next/eslint-plugin-next@16.4.0': resolution: {integrity: sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==} - '@next/swc-darwin-arm64@16.4.0': - resolution: {integrity: sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==} + '@next/swc-darwin-arm64@16.4.3': + resolution: {integrity: sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.4.0': - resolution: {integrity: sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==} + '@next/swc-darwin-x64@16.4.3': + resolution: {integrity: sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.4.0': - resolution: {integrity: sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==} + '@next/swc-linux-arm64-gnu@16.4.3': + resolution: {integrity: sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.4.0': - resolution: {integrity: sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==} + '@next/swc-linux-arm64-musl@16.4.3': + resolution: {integrity: sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.4.0': - resolution: {integrity: sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==} + '@next/swc-linux-x64-gnu@16.4.3': + resolution: {integrity: sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.4.0': - resolution: {integrity: sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==} + '@next/swc-linux-x64-musl@16.4.3': + resolution: {integrity: sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.4.0': - resolution: {integrity: sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==} + '@next/swc-win32-arm64-msvc@16.4.3': + resolution: {integrity: sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.4.0': - resolution: {integrity: sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==} + '@next/swc-win32-x64-msvc@16.4.3': + resolution: {integrity: sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -5531,8 +5531,8 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} - next@16.4.0: - resolution: {integrity: sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==} + next@16.4.3: + resolution: {integrity: sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -7461,13 +7461,13 @@ snapshots: '@actions/io@3.0.2': {} - '@ajayche/trpc-panel@2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': + '@ajayche/trpc-panel@2.1.0(@trpc/server@11.19.0(typescript@6.0.3))(@types/react@19.3.0)(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': dependencies: '@microlink/react-json-view': 1.31.38(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@trpc/server': 11.19.0(typescript@6.0.3) clsx: 2.1.1 fuzzysort: 2.0.4 - nuqs: 2.10.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + nuqs: 2.10.1(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) path: 0.12.7 pretty-bytes: 6.1.1 pretty-ms: 8.0.0 @@ -7795,11 +7795,11 @@ snapshots: unplugin: 1.16.1 zod: 3.25.76 - '@codecov/nextjs-webpack-plugin@2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2))': + '@codecov/nextjs-webpack-plugin@2.0.1(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(webpack@5.111.1(esbuild@0.28.2))': dependencies: '@codecov/bundler-plugin-core': 2.0.1 '@codecov/webpack-plugin': 2.0.1(webpack@5.111.1(esbuild@0.28.2)) - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) unplugin: 1.16.1 webpack: 5.111.1(esbuild@0.28.2) @@ -8799,7 +8799,7 @@ snapshots: '@nats-io/nkeys': 2.0.3 '@nats-io/nuid': 3.0.0 - '@next/env@16.4.0': {} + '@next/env@16.4.3': {} '@next/eslint-plugin-next@16.4.0(eslint@9.39.5(jiti@2.7.0))': dependencies: @@ -8808,28 +8808,28 @@ snapshots: transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.4.0': + '@next/swc-darwin-arm64@16.4.3': optional: true - '@next/swc-darwin-x64@16.4.0': + '@next/swc-darwin-x64@16.4.3': optional: true - '@next/swc-linux-arm64-gnu@16.4.0': + '@next/swc-linux-arm64-gnu@16.4.3': optional: true - '@next/swc-linux-arm64-musl@16.4.0': + '@next/swc-linux-arm64-musl@16.4.3': optional: true - '@next/swc-linux-x64-gnu@16.4.0': + '@next/swc-linux-x64-gnu@16.4.3': optional: true - '@next/swc-linux-x64-musl@16.4.0': + '@next/swc-linux-x64-musl@16.4.3': optional: true - '@next/swc-win32-arm64-msvc@16.4.0': + '@next/swc-win32-arm64-msvc@16.4.3': optional: true - '@next/swc-win32-x64-msvc@16.4.0': + '@next/swc-win32-x64-msvc@16.4.3': optional: true '@noble/hashes@2.4.0': {} @@ -9349,11 +9349,11 @@ snapshots: '@trpc/server': 11.19.0(typescript@6.0.3) typescript: 6.0.3 - '@trpc/next@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': + '@trpc/next@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.19.0(@tanstack/react-query@5.104.1(react@19.3.0))(@trpc/client@11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(react@19.3.0)(typescript@6.0.3))(@trpc/server@11.19.0(typescript@6.0.3))(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@6.0.3)': dependencies: '@trpc/client': 11.19.0(@trpc/server@11.19.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/server': 11.19.0(typescript@6.0.3) - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 react-dom: 19.3.0(react@19.3.0) typescript: 6.0.3 @@ -9702,9 +9702,9 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vercel/analytics@2.0.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': + '@vercel/analytics@2.0.1(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': optionalDependencies: - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: 19.3.0 '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)))(vite@8.3.4(@types/node@25.9.9)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1))': @@ -12692,9 +12692,9 @@ snapshots: nerf-dart@1.0.0: {} - next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: - '@next/env': 16.4.0 + '@next/env': 16.4.3 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.28 caniuse-lite: 1.0.30001815 @@ -12703,14 +12703,14 @@ snapshots: react-dom: 19.3.0(react@19.3.0) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.4.0 - '@next/swc-darwin-x64': 16.4.0 - '@next/swc-linux-arm64-gnu': 16.4.0 - '@next/swc-linux-arm64-musl': 16.4.0 - '@next/swc-linux-x64-gnu': 16.4.0 - '@next/swc-linux-x64-musl': 16.4.0 - '@next/swc-win32-arm64-msvc': 16.4.0 - '@next/swc-win32-x64-msvc': 16.4.0 + '@next/swc-darwin-arm64': 16.4.3 + '@next/swc-darwin-x64': 16.4.3 + '@next/swc-linux-arm64-gnu': 16.4.3 + '@next/swc-linux-arm64-musl': 16.4.3 + '@next/swc-linux-x64-gnu': 16.4.3 + '@next/swc-linux-x64-musl': 16.4.3 + '@next/swc-win32-arm64-msvc': 16.4.3 + '@next/swc-win32-x64-msvc': 16.4.3 '@playwright/test': 1.58.2 sharp: 0.35.5(@types/node@25.9.9) transitivePeerDependencies: @@ -12787,12 +12787,12 @@ snapshots: npm@11.20.0: {} - nuqs@2.10.1(next@16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): + nuqs@2.10.1(next@16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0): dependencies: '@standard-schema/spec': 1.1.0 react: 19.3.0 optionalDependencies: - next: 16.4.0(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + next: 16.4.3(@babel/core@7.29.7)(@playwright/test@1.58.2)(@types/node@25.9.9)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) object-assign@4.1.1: {} diff --git a/proxy.ts b/proxy.ts index 0ea24cfe2..57fb769db 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,3 +1,4 @@ +import { requireAccess } from '@/src/auth/access' import linguiConfig from 'lingui.config' import Negotiator from 'negotiator' import { type NextRequest, NextResponse } from 'next/server' @@ -9,7 +10,7 @@ const { locales } = linguiConfig // api — tRPC + REST endpoints, no locale needed // panel — tRPC panel dev tool, served as a plain route handler // vendor — static vendored scripts in public/vendor (three.js fallback) -const NO_LOCALE_PATH = /^\/(?:api|panel(?:\/|$)|vendor\/)/ +const NO_LOCALE_PATH = /^\/(?:api|panel(?:\/|$)|vendor\/|guide\/|household\/)/ const getRequestLocale = (requestHeaders: Headers): string => { const langHeader = requestHeaders.get('accept-language') || undefined @@ -48,6 +49,14 @@ export const proxy = (request: NextRequest) => { const { pathname } = request.nextUrl if (NO_LOCALE_PATH.test(pathname)) { + if (pathname === '/api/auth/session') return + // Only this passive endpoint accepts administrative bearer access after + // personal activation. Its handler rejects cookies and browser requests. + if (pathname === '/api/v1/maintenance/status') return + // These handlers resolve a revocable personal identity themselves. Never + // admit personal credentials into the broad legacy owner authorization. + if (pathname.startsWith('/api/v1/household/') || pathname.startsWith('/api/v1/me/')) return + if (pathname.startsWith('/api/') || pathname.startsWith('/panel')) return requireAccess(request) ?? undefined return } diff --git a/public/guide/marissa.html b/public/guide/marissa.html new file mode 100644 index 000000000..7c2364f92 --- /dev/null +++ b/public/guide/marissa.html @@ -0,0 +1,110 @@ + + + + + +Marissa’s guide to a good night · Sleepypod + + + + +
sleepypod
A little guide for Marissa
+
+
+
Your side. Your kind of cozy.

Make yourself
comfortable.

A cooler bed, a gentler morning, and settings that are yours. Let’s try the basics together.

About 8 minutes · Works on your phone, iPad or computer

+
+Two spaces, one shared bedIllustrated overhead view of a bed. Marissa’s future side is Left and Luca’s future side is Right.MLMarissa · LeftLuca · Right +

The plan for when you move in.
We’ll confirm which physical side is “Left” together.

+
+
This is a practice guide. Every temperature, alarm and button below is pretend. Nothing here connects to the bed, changes a real alarm or sends personal data.
+
Getting ready for two: personal invitations and separate accounts are being built. For now, Luca is the only active sleeper. Your Left side will be assigned only after you join and the setup is confirmed together.
+
+ +
+
+
01 / Your own space

Share the bed. Keep your settings.

Each side can have its own temperature and wake-up routine. You might like your side cozy while Luca likes his cool.

+

Yours

Your name, favorite temperature, bedtime, wake-up time and optional personal sleep history.

Shared

The bed frame, room lights and speakers. Moving the frame or playing audio may affect both of you.

+

When the new setup is ready, Luca sends you a personal invitation. Use that invitation for your account; don’t use his connection key.

You can choose settings before moving in. Joining does not immediately switch the bed to two sleepers.

+ + + + + + + +
+ + +
+ + + diff --git a/public/household/index.html b/public/household/index.html new file mode 100644 index 000000000..a7e93943a --- /dev/null +++ b/public/household/index.html @@ -0,0 +1,23 @@ + +Your personal sleep space · sleepypod +
+
sleepypod ✳Practice guide
+

Your personal sleep space

Your sleep.
Your own settings.

+

Prepare a personal profile for your shared bed. Joining keeps the current bed arrangement and saved alarms unchanged.

+
Checking this browser’s personal connection…
+ + + + +
diff --git a/public/household/workspace.css b/public/household/workspace.css new file mode 100644 index 000000000..036c42769 --- /dev/null +++ b/public/household/workspace.css @@ -0,0 +1,6 @@ +:root{font:17px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--paper:#f4f2ed;--card:#fffefa;--ink:#203431;--muted:#526861;--line:#ccd8ce;--blue:#245bda;--soft:#e6efff}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink)}main{max-width:1100px;margin:auto;padding:25px 25px 60px}header{display:flex;justify-content:space-between;gap:16px;margin-bottom:55px}.brand{font-size:1.35rem;font-weight:750;text-decoration:none;color:var(--ink);letter-spacing:-.04em}a{color:var(--blue)}.eyebrow{text-transform:uppercase;font-size:.75rem;letter-spacing:.13em;color:var(--muted);font-weight:650}h1{font-size:clamp(2.5rem,6vw,4.4rem);line-height:1.1;letter-spacing:-.05em;margin:12px 0 22px}h2{font-size:1.35rem;letter-spacing:-.025em;line-height:1.25;margin:0 0 16px}.intro{font-size:1.13rem;max-width:650px;color:var(--muted)}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;margin:24px 0}.grid.compact{gap:12px;margin:0}article{background:var(--card);border:1px solid var(--line);border-radius:23px;padding:25px}button,input,select{font:inherit}input:not([type=checkbox]),select{width:100%;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px;min-height:44px}input[type=checkbox]{width:18px;height:18px;accent-color:var(--blue);flex-shrink:0}label{display:block;margin:15px 0 6px;font-size:.86rem;font-weight:600}.check{display:flex;gap:10px;align-items:flex-start;font-weight:400}button{border:1px solid var(--line);border-radius:12px;padding:10px 16px;color:var(--ink);background:var(--card);cursor:pointer;min-height:44px;margin:12px 0}button.primary{background:var(--blue);color:white;border-color:var(--blue)}button:disabled{opacity:.5;cursor:wait}button:hover{border-color:var(--blue)}:focus-visible{outline:3px solid var(--blue);outline-offset:3px}.small{font-size:.82rem;color:var(--muted)}.muted{color:var(--muted)}p{margin:0 0 15px}.notice,#message{border:1px solid var(--line);border-left:4px solid var(--blue);border-radius:12px;padding:16px 20px;margin:24px 0;background:var(--soft)}#message[data-error=true]{border-left-color:#b83b37}.row,.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.item{border-top:1px solid var(--line);margin-top:14px;padding-top:14px;font-size:.85rem}.item p{margin:0}.item button{font-size:.8rem;margin:5px 0}details{border-top:1px solid var(--line);padding-top:18px;margin-top:24px}summary{cursor:pointer;font-weight:650;margin-bottom:12px}footer{margin-top:40px;font-size:.8rem;color:var(--muted)}[hidden]{display:none!important}@media(max-width:700px){.grid{grid-template-columns:1fr}header{margin-bottom:35px}main{padding:20px 18px 40px}article{padding:21px 18px}.grid.compact{grid-template-columns:1fr 1fr}}@media(prefers-color-scheme:dark){:root{--paper:#14221f;--card:#1c302c;--ink:#eef5f0;--muted:#b4c8c0;--line:#40574f;--blue:#94b4ff;--soft:#263e5d}button.primary{color:#14221f}} +.personal-runtime{display:grid;gap:24px;margin-top:24px}.personal-runtime article{min-width:0}.personal-bed{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding:12px;border:1px solid var(--line);border-radius:30px;background:var(--paper);margin:24px 0}.bed-zone{min-width:0;background:var(--soft);border-radius:22px;padding:18px;text-align:center}.pillow{height:30px;max-width:140px;margin:0 auto 20px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.bed-temperature{display:block;font-size:clamp(1.6rem,4vw,2.8rem);line-height:1.3;letter-spacing:-.04em}.bed-zone>span{font-size:.72rem}.bed-zone h3{margin:8px 0}dl{display:grid;gap:3px;margin:20px 0 0}dt{font-size:.7rem;color:var(--muted)}dd{margin:0 0 7px;font-weight:650}.stop-controls{display:flex;gap:12px;flex-wrap:wrap;padding-top:15px;border-top:1px solid var(--line)}.stop-controls button{border:2px solid var(--blue);font-weight:650}.pill{background:var(--soft);border:1px solid var(--line);padding:4px 12px;border-radius:30px;font-size:.78rem}.routine-editor form>button{margin-right:10px}textarea{font:inherit;display:block;width:100%;background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:10px;min-height:100px;padding:12px}fieldset{border:1px solid var(--line);border-radius:12px;margin-top:18px;min-width:0}legend{padding:0 8px}.week-review{margin-top:24px;border-top:2px solid var(--line)}.night-summary{font-size:.82rem;overflow-wrap:anywhere}.week-review h4{font-size:.85rem;margin:5px 0 12px}.personal-runtime button:disabled{cursor:not-allowed}.personal-runtime p[role=alert]{border-left-color:#b83b37}@media(max-width:700px){.bed-zone{padding:12px 8px}.routine-editor .grid.compact,.week-review .grid.compact{grid-template-columns:1fr}.personal-runtime .grid.compact{gap:12px}.personal-runtime button{max-width:100%;overflow-wrap:anywhere}} + +.editor-fields{margin:0;padding:0;border:0;min-width:0} +.arrangement-review .bed-zone>strong,.arrangement-review .bed-zone>span{display:block;margin-top:6px;overflow-wrap:anywhere} +.history-filters{display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:14px}.history-filters label{min-width:0}.history-list{list-style:none;padding:0;margin:22px 0}.history-record{border:1px solid var(--line);border-radius:18px;padding:22px;margin:16px 0;overflow-wrap:anywhere}.history-record h3{margin:0;font-size:1.1rem}.history-interval{font-size:.8rem;color:var(--muted);margin:12px 0 20px}.history-value{font-size:clamp(1.7rem,4vw,2.4rem);letter-spacing:-.04em;display:block;line-height:1.3;margin-bottom:8px}.history-metrics{grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:15px 0}.history-metrics dt{font-size:.78rem}.history-metrics dd{font-size:1.08rem}.history-overlap{border-left:3px solid var(--blue);padding-left:12px}.history-empty{border:1px dashed var(--line);border-radius:18px;text-align:center;padding:30px 20px;margin:20px 0}.history-empty svg{fill:var(--soft);stroke:var(--muted);stroke-width:2}.history-empty h3{font-size:1.15rem}.history-empty p{max-width:470px;margin:auto}@media(max-width:700px){.history-filters,.history-metrics{grid-template-columns:1fr}.history-record{padding:18px 14px}.history-panel input[type=date]{min-width:0;max-width:100%;font-size:max(16px,.86rem)}.history-record .pill{white-space:normal}} diff --git a/public/household/workspace.js b/public/household/workspace.js new file mode 100644 index 000000000..ced433614 --- /dev/null +++ b/public/household/workspace.js @@ -0,0 +1,393 @@ +// Account shell and isolated runtime both use personal APIs, without owner fallback. +import { mountPersonalWorkspace, contextKey } from './personal-runtime.js' +let personalWorkspace = null +let personalContext = null +const element = id => document.getElementById(id) +let session = null +let preferences = null +let pending = null +let pendingKeySaved = false +let generation = 0 +let busy = false +let invitationAttempt = null +const activeRequests = new Set() +const accountChannel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('sleepypod-account') +function secret() { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') +} +// randomUUID is unavailable on some HTTP LAN origins; getRandomValues is not. +function identifier() { + const bytes = crypto.getRandomValues(new Uint8Array(16)) + bytes[6] = (bytes[6] & 15) | 64 + bytes[8] = (bytes[8] & 63) | 128 + const value = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') + return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}` +} +function message(text, error = false) { + element('message').textContent = text + element('message').dataset.error = String(error) +} +async function request(path, method = 'GET', body, token, expectedSleeper = session?.selfSleeperId) { + const requestGeneration = generation + const controller = new AbortController() + activeRequests.add(controller) + const timeout = setTimeout(() => controller.abort(), 15000) + try { + let browserRevision = null + if (method !== 'GET' && ['household/setup', 'household/join', 'household/session'].includes(path)) { + const stateResponse = await fetch('/api/auth/session', { cache: 'no-store', signal: controller.signal }) + if (!stateResponse.ok) + throw new Error('Browser account state is unavailable. Reconnect explicitly.') + browserRevision = (await stateResponse.json()).browserScopeRevision + if (requestGeneration !== generation) + throw new Error('The browser account changed. Reload before continuing.') + } + const response = await fetch(`/api/v1/${path}`, { + method, credentials: 'same-origin', cache: 'no-store', signal: controller.signal, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(expectedSleeper ? { 'X-Sleepypod-Sleeper-Id': expectedSleeper } : {}), ...(browserRevision ? { 'X-Sleepypod-Browser-Revision': browserRevision } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + const result = response.status === 204 ? null : await response.json() + if (requestGeneration !== generation) + throw new Error('The browser account changed. Reload before continuing.') + if (!response.ok) { + const error = new Error(result?.error?.message || 'The personal workspace is unavailable.') + error.status = response.status + throw error + } + return result + } + catch (error) { + if (error.name === 'AbortError') + throw new Error('The response was not confirmed. Check the connection before trying again.') + throw error + } + finally { + clearTimeout(timeout) + activeRequests.delete(controller) + } +} +async function action(operation) { + if (busy) + return + busy = true + const buttons = [...document.querySelectorAll('button')].filter(button => !button.closest('#personal-runtime')).map(button => [button, button.disabled]) + buttons.forEach(([button]) => { + button.disabled = true + }) + try { + await operation() + } + catch (error) { + message(error.message, true) + } + finally { + busy = false + buttons.forEach(([button, disabled]) => { + if (button.isConnected) + button.disabled = disabled + }) + } +} +function context() { + return { requestId: identifier(), expectedSessionRevision: session.sessionRevision, expectedAssignmentRevision: session.assignmentRevision } +} +function clearPersonalView() { + generation += 1 + personalWorkspace?.dispose() + personalWorkspace = null + personalContext = null + session = null + preferences = null + invitationAttempt = null + element('workspace').hidden = true + element('connect').hidden = false + for (const name of ['device-list', 'invitation-list', 'greeting', 'assignment']) + element(name).replaceChildren() + element('invitation-result').hidden = true + element('invitation-link').value = '' + element('preferences-form').reset() +} +function pendingConnection(value) { + pending = value + pendingKeySaved = false + element('recovery').hidden = false + element('recovery-key').value = value.deviceSecret + element('recovery-key').type = 'password' + element('show-key').textContent = 'Show key' + message('First save your personal key privately. Then choose “I saved it privately” to finish joining.') +} +function addItem(parent, title, subtitle, buttonLabel, operation) { + const row = document.createElement('div') + row.className = 'item' + const heading = document.createElement('strong') + heading.textContent = title + row.append(heading) + const paragraph = document.createElement('p') + paragraph.textContent = subtitle + row.append(paragraph) + if (buttonLabel && operation) { + const button = document.createElement('button') + button.type = 'button' + button.textContent = buttonLabel + button.addEventListener('click', () => action(operation)) + row.append(button) + } + parent.append(row) +} +async function refresh() { + const currentGeneration = ++generation + const currentSession = await request('household/session') + const readPersonal = path => request(path, 'GET', undefined, undefined, currentSession.selfSleeperId) + const results = await Promise.all([ + readPersonal('me/preferences'), readPersonal('me/routines'), readPersonal('household/devices'), readPersonal('household/invitations'), + ]) + if (currentGeneration !== generation) + return + if (results[0].sleeperId !== currentSession.selfSleeperId || results[1].sleeperId !== currentSession.selfSleeperId) + throw new Error('The personal account changed. Reconnect before continuing.') + if (results[1].status !== 'draft_only') { + clearPersonalView() + throw new Error('Update the personal app before editing an active routine. This preparation workspace can edit drafts only.') + } + session = currentSession + preferences = results[0] + element('connect').hidden = true + element('workspace').hidden = false + element('greeting').textContent = `Hello, ${session.self.displayName}.` + element('assignment').textContent = session.self.assignment === 'solo' ? 'Sole current sleeper · either side' : session.self.assignment === 'unassigned' ? 'Your account is ready to prepare · no active bed side assigned yet' : `Your assigned side: ${session.self.assignment}` + element('preference-temperature').value = preferences.preferences.preferredTemperatureF + element('comfort').value = preferences.preferences.comfort + element('display-unit').value = preferences.preferences.displayUnit + element('share-wake').checked = preferences.preferences.shareWakeTime + element('partner-option').hidden = session.role !== 'owner' + if (session.role !== 'owner') + element('invite-kind').value = 'own_device' + element('invitation-help').textContent = session.role === 'owner' ? 'Invite a second person, or pair another device with your own account. Neither action changes the active bed arrangement.' : 'Pair another device with your own account. Your partner has a separate invitation and account.' + element('device-list').replaceChildren() + for (const device of results[2].devices) + addItem(element('device-list'), device.name, device.current ? 'This connection' : 'Personal device', results[2].devices.length > 1 ? 'Remove this device' : null, async () => { + if (!confirm(`Remove ${device.name}? Its personal connection will stop.`)) + return + await request('household/devices', 'DELETE', { ...context(), deviceId: device.id }) + if (device.current) { + clearPersonalView() + message('This personal device was removed. Reconnect with another authorized personal key.') + } + else { + await refresh() + message('Personal device removed.') + } + }) + element('invitation-list').replaceChildren() + for (const invitation of results[3].invitations) + addItem(element('invitation-list'), invitation.kind === 'new_person' ? 'Second-person invitation' : 'Own-device invitation', `${invitation.status} · expires ${new Date(invitation.expiresAt).toLocaleString()}`, invitation.status === 'pending' ? 'Revoke invitation' : null, async () => { + await request('household/invitations', 'DELETE', { ...context(), invitationId: invitation.id }) + element('invitation-result').hidden = true + element('invitation-link').value = '' + await refresh() + message('Invitation revoked.') + }) + const nextContext = contextKey(session) + JSON.stringify(session.capabilities) + if (personalContext !== nextContext) { + personalWorkspace?.dispose() + personalWorkspace = null + personalContext = null + personalWorkspace = mountPersonalWorkspace(element('personal-runtime'), session, preferences.preferences, () => { + clearPersonalView() + message('Your personal account or assignment changed. Reconnect to review it.', true) + }) + personalContext = nextContext + } + else + personalWorkspace.updatePreferences(preferences.preferences) +} +async function finishPendingConnection() { + if (!pending || !pendingKeySaved) + throw new Error('Save your personal key before creating the connection.') + const input = { ...pending } + delete input.kind + await request(`household/${pending.kind}`, 'POST', input, pending.kind === 'setup' ? element('owner-key').value.trim() : undefined) + element('owner-key').value = '' + element('join-form').reset() + element('setup-form').reset() + announceAccountChange() + await refresh() + pending = null + element('recovery-key').value = '' + element('recovery').hidden = true + message('Your private preparation workspace is connected. Existing routines, history and bed arrangement were not changed.') +} +element('join-form').addEventListener('submit', (event) => { + event.preventDefault() + action(async () => { + if (!pending) { + pendingConnection({ kind: 'join', deviceSecret: secret(), invitation: element('invite').value.trim(), displayName: element('name').value.trim(), deviceName: element('device-name').value.trim() }) + return + } + if (pending.kind !== 'join') + throw new Error('Finish or recover the pending connection first.') + await finishPendingConnection() + }) +}) +element('setup-form').addEventListener('submit', (event) => { + event.preventDefault() + action(async () => { + if (!pending) { + pendingConnection({ kind: 'setup', requestId: identifier(), deviceSecret: secret(), deviceName: 'Owner browser', displayName: element('owner-name').value.trim(), currentMode: 'solo-anywhere', sourceSide: element('source-side').value, acknowledgeExistingOwner: true }) + return + } + if (pending.kind !== 'setup') + throw new Error('Finish or recover the pending connection first.') + await finishPendingConnection() + }) +}) +element('login-form').addEventListener('submit', (event) => { + event.preventDefault() + action(async () => { + clearPersonalView() + await request('household/session', 'POST', { token: element('personal-key').value.trim() }) + element('personal-key').value = '' + announceAccountChange() + await refresh() + message('Your personal account is connected.') + }) +}) +element('reconcile').addEventListener('click', () => action(async () => { + if (!pending) + return + await request('household/session', 'POST', { token: pending.deviceSecret }) + announceAccountChange() + await refresh() + message('The retained personal key connected successfully. No second account was created.') +})) +element('show-key').addEventListener('click', () => { + const hidden = element('recovery-key').type === 'password' + element('recovery-key').type = hidden ? 'text' : 'password' + element('show-key').textContent = hidden ? 'Hide key' : 'Show key' +}) +async function copyField(id) { + const input = element(id) + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(input.value) + message('Copied. Save or share it privately.') + return + } + catch { /* HTTP LAN origins can require manual copy. */ + } + } + input.type = 'text' + input.focus() + input.select() + message('The private value is selected. Use Copy, then save or share it privately.') +} +element('copy-key').addEventListener('click', () => copyField('recovery-key')) +element('copy-invitation').addEventListener('click', () => copyField('invitation-link')) +element('saved-key').addEventListener('click', () => { + if (pending && !session) { + pendingKeySaved = true + action(finishPendingConnection) + return + } + pending = null + element('recovery-key').value = '' + element('recovery').hidden = true +}) +element('preferences-form').addEventListener('submit', (event) => { + event.preventDefault() + action(async () => { + const result = await request('me/preferences', 'PUT', { ...context(), expectedRevision: preferences.revision, preferences: { displayUnit: element('display-unit').value, preferredTemperatureF: Number(element('preference-temperature').value), comfort: element('comfort').value, shareWakeTime: element('share-wake').checked, cloudAnalysisConsent: false } }) + if (result.sleeperId !== session.selfSleeperId) + throw new Error('The personal account changed. Reload before continuing.') + preferences = result + personalWorkspace?.updatePreferences(preferences.preferences) + message('Your private preferences were saved. The bed target was not changed.') + }) +}) +element('invitation-form').addEventListener('submit', (event) => { + event.preventDefault() + action(async () => { + invitationAttempt ??= { ...context(), invitation: secret(), kind: element('invite-kind').value, expiresInMinutes: 30 } + const result = await request('household/invitations', 'POST', invitationAttempt) + invitationAttempt = null + element('invitation-link').value = `${location.origin}/household/index.html#invite=${encodeURIComponent(result.invitation)}` + element('invitation-result').hidden = false + await refresh() + message('A private invitation is ready. Share it only with its intended recipient.') + }) +}) +element('logout').addEventListener('click', () => action(async () => { + clearPersonalView() + pending = null + element('recovery-key').value = '' + element('recovery').hidden = true + await request('household/session', 'DELETE') + announceAccountChange() + message('Signed out of the personal workspace in this browser. The bed continues its existing routines.') +})) +function announceAccountChange() { + accountChannel?.postMessage('personal-account-changed') + try { + localStorage.setItem('sleepypod-account-change', `${Date.now()}:personal`) + } + catch { /* No personal information is kept in browser storage. */ + } +} +function otherAccountChanged() { + for (const controller of activeRequests) + controller.abort() + clearPersonalView() + pending = null + element('recovery-key').value = '' + element('recovery').hidden = true + element('personal-key').value = '' + element('owner-key').value = '' + message('The account changed in another tab. Reload to confirm the current personal connection.') +} +if (accountChannel) + accountChannel.onmessage = otherAccountChanged +window.addEventListener('storage', (event) => { + if (event.key === 'sleepypod-account-change') + otherAccountChanged() +}) +window.addEventListener('pagehide', () => { + clearPersonalView() + for (const controller of activeRequests) + controller.abort() +}) +window.addEventListener('pageshow', (event) => { + if (event.persisted) + location.reload() +}) +window.addEventListener('focus', async () => { + if (!session || busy) + return + try { + await request('household/session') + } + catch (error) { + if ([401, 409].includes(error.status)) + otherAccountChanged() + } +}) +const inviteInFragment = new URLSearchParams(location.hash.slice(1)).get('invite') +if (inviteInFragment) { + element('invite').value = inviteInFragment + history.replaceState(null, '', location.pathname) + element('connect').hidden = false + message('A personal invitation is ready. Enter your name and device name to join.') +} +else { + action(async () => { + try { + await refresh() + message('Your private preparation workspace is connected.') + } + catch (error) { + clearPersonalView() + message(error.status === 401 ? 'Join with an invitation or reconnect your personal account.' : error.message, error.status !== 401) + } + }) +} diff --git a/scripts/bedroom/Install-GoveeReader.ps1 b/scripts/bedroom/Install-GoveeReader.ps1 new file mode 100644 index 000000000..5247ca0b4 --- /dev/null +++ b/scripts/bedroom/Install-GoveeReader.ps1 @@ -0,0 +1,130 @@ +param( + [Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{40}$')][string]$Revision, + [Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{64}$')][string]$ReaderSha256, + [Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{64}$')][string]$ProtocolSha256, + [Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{64}$')][string]$ReadLoopSha256, + [ValidatePattern('^[a-f0-9]{64}$')][string]$PowerProtocolSha256, + [ValidateSet('Monitor','AppliancePower')][string]$ReaderProfile = 'Monitor', + [string]$HomebridgeRoot = (Join-Path $env:USERPROFILE '.homebridge'), + [string]$PluginRoot = (Join-Path $env:APPDATA 'npm\node_modules\@homebridge-plugins\homebridge-govee') +) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Use Administrator PowerShell' } +$parent = Join-Path $env:ProgramData 'FreeSleepBedroom' +$cacheConfig = Get-Content (Join-Path $parent 'config.json') -Raw | ConvertFrom-Json +if ($cacheConfig.podUser -ne 'root' -or !(Test-Path $cacheConfig.knownHosts) -or !(Test-Path $cacheConfig.sshExecutable)) { + throw 'A working pinned cache publisher is required first' +} +$plugin = Get-Content (Join-Path $PluginRoot 'package.json') -Raw | ConvertFrom-Json +if ($plugin.version -ne '11.39.1') { throw 'This plugin version needs separate source qualification' } +$accessories = @(foreach ($file in Get-ChildItem (Join-Path $HomebridgeRoot 'accessories\cachedAccessories.*') -File) { + # PowerShell 5.1 can preserve a JSON array as one pipeline item. Enumerate devices explicitly. + foreach ($accessory in (Get-Content $file.FullName -Raw | ConvertFrom-Json)) { $accessory } +}) +$powerProfile = $ReaderProfile -eq 'AppliancePower' +if ($powerProfile -and !$PowerProtocolSha256) { throw 'Appliance power requires its pinned decoder checksum' } +$models = if ($powerProfile) { @('H712C','H7161') } else { @('H5106') } +$devices = @(foreach ($model in $models) { + $deviceMatches = @($accessories | Where-Object {$_.context.gvModel -eq $model}) + if ($deviceMatches.Count -ne 1 -or $deviceMatches[0].context.gvDeviceId -isnot [string] -or + !$deviceMatches[0].context.gvDeviceId -or $deviceMatches[0].context.awsTopic -isnot [string] -or !$deviceMatches[0].context.awsTopic) { + throw "Expected exactly one configured $model; inspect the physical-device mapping" + } + [ordered]@{model=$model;deviceId=$deviceMatches[0].context.gvDeviceId} +}) +if (@($devices.deviceId | Select-Object -Unique).Count -ne $devices.Count) { + throw 'Device identities must be distinct' +} +$node = (Get-Command node.exe -ErrorAction Stop).Source +$keygen = Join-Path $env:WINDIR 'System32\OpenSSH\ssh-keygen.exe' +if (!(Test-Path $keygen)) { throw 'Windows OpenSSH Client is required' } +$readerFolder = if ($powerProfile) { 'govee-appliance-power' } else { 'govee-readings' } +$folder = Join-Path $parent $readerFolder +if (Test-Path $folder) { + if ((Get-Item $folder).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Unexpected reader directory link' } +} else { New-Item -ItemType Directory -Path $folder | Out-Null } +& icacls.exe $folder /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not restrict reader directory' } +$release = Join-Path $folder "releases\$Revision" +New-Item -ItemType Directory -Path $release -Force | Out-Null +$hashes = @{'govee-reader.mjs'=$ReaderSha256;'govee-protocol.mjs'=$ProtocolSha256;'govee-read-loop.mjs'=$ReadLoopSha256} +if ($powerProfile) { $hashes['govee-power-protocol.mjs']=$PowerProtocolSha256 } +foreach ($filename in $hashes.Keys) { + $target = Join-Path $release $filename + if (!(Test-Path $target)) { + $download = "$target.pending" + Invoke-WebRequest -UseBasicParsing -Uri "https://raw.githubusercontent.com/lucaandrea/sleepypod-core/$Revision/scripts/bedroom/$filename" -OutFile $download + if ((Get-FileHash $download -Algorithm SHA256).Hash.ToLower() -ne $hashes[$filename]) { throw 'Reader checksum mismatch' } + Move-Item -LiteralPath $download -Destination $target + } + if ((Get-FileHash $target -Algorithm SHA256).Hash.ToLower() -ne $hashes[$filename]) { throw 'Installed release differs; preserved for inspection' } +} +$key = Join-Path $folder 'sensor_ed25519' +if ((Test-Path $key) -ne (Test-Path "$key.pub")) { throw 'Incomplete identity; preserve and inspect both key paths' } +$generator = @' +import {spawnSync} from "node:child_process"; +import {readFileSync,existsSync} from "node:fs"; +const [keygen,key,comment] = process.argv.slice(2); +if (existsSync(key)) { + const derived=spawnSync(keygen,["-y","-P","","-f",key],{encoding:"utf8",timeout:5000}); + const expected=readFileSync(key+".pub","utf8").trim().split(/\s+/); + if(derived.status!==0 || expected.length!==3 || expected[0]!=="ssh-ed25519" || expected[2]!==comment || + derived.stdout.trim().split(/\s+/).slice(0,2).join(" ")!==expected.slice(0,2).join(" "))process.exit(1); +} else { + const result=spawnSync(keygen,["-q","-t","ed25519","-N","","-C",comment,"-f",key],{stdio:"inherit"}); + process.exit(result.status??1); +} +'@ +$utf8 = New-Object Text.UTF8Encoding($false) +$generatorPath = Join-Path $folder 'verify-identity.mjs' +[IO.File]::WriteAllText($generatorPath,$generator,$utf8) +$keyComment = if ($powerProfile) { 'sleepypod-govee-power-read-only' } else { 'sleepypod-govee-read-only' } +& $node $generatorPath $keygen $key $keyComment +if ($LASTEXITCODE -ne 0) { throw 'Reader identity verification failed; no existing key was replaced' } +& icacls.exe $key /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not restrict sensor key' } +& icacls.exe $key /setowner '*S-1-5-18' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not assign sensor key to SYSTEM' } +if ($identity.User.Value -notin @('S-1-5-18','S-1-5-32-544')) { + & icacls.exe $key /remove:g "*$($identity.User.Value)" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not restrict sensor-key principals' } +} +$configPath = Join-Path $folder 'config.json' +$configuration = [ordered]@{mode='observe';homebridgeRoot=$HomebridgeRoot;pluginRoot=$PluginRoot; + statusFile=(Join-Path $folder 'status.json'); + sshExecutable=$cacheConfig.sshExecutable;podHost=$cacheConfig.podHost;podPort=$cacheConfig.podPort; + podUser='root';keyFile=$key;knownHosts=$cacheConfig.knownHosts} +if ($powerProfile) { $configuration['profile']='appliance-power'; $configuration['devices']=$devices } +else { $configuration['deviceId']=$devices[0].deviceId } +if (Test-Path $configPath) { + $existing = Get-Content $configPath -Raw | ConvertFrom-Json + if (@($existing.PSObject.Properties).Count -ne $configuration.Count) { throw 'Existing reader configuration differs; preserve it' } + foreach ($name in $configuration.Keys) { + if ((ConvertTo-Json -InputObject $existing.$name -Depth 10 -Compress) -cne + (ConvertTo-Json -InputObject $configuration[$name] -Depth 10 -Compress)) { + throw 'Existing reader configuration differs; preserve it' + } + } +} else { [IO.File]::WriteAllText($configPath,($configuration | ConvertTo-Json -Depth 10),$utf8) } +$taskName = if ($powerProfile) { 'FreeSleep-GoveeAppliancePower' } else { 'FreeSleep-GoveeReadings' } +$arguments = "`"$(Join-Path $release 'govee-reader.mjs')`" `"$configPath`"" +$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue +if ($task) { + if ($task.Actions.Count -ne 1 -or $task.Actions[0].Execute -ne $node -or $task.Actions[0].Arguments -ne $arguments -or + $task.Principal.UserId -notin @('SYSTEM','S-1-5-18')) { throw 'Existing task differs; preserve it for an explicit update' } +} else { + $action = New-ScheduledTaskAction -Execute $node -Argument $arguments + $startup = New-ScheduledTaskTrigger -AtStartup + $retry = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 5) + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger @($startup,$retry) -Settings $settings ` + -User 'SYSTEM' -RunLevel Highest -Description "Status-only $ReaderProfile reads; initially observation-only, no Pod publication." | Out-Null +} +Start-ScheduledTask -TaskName $taskName +Write-Output 'Reader installed in observation-only mode. The existing cache publisher was not modified.' +Write-Output 'Authorize this separate PUBLIC key only for the Govee forced receiver before any publishing cutover:' +Get-Content "$key.pub" diff --git a/scripts/bedroom/Install-HomebridgeCache.ps1 b/scripts/bedroom/Install-HomebridgeCache.ps1 new file mode 100644 index 000000000..6ff420b92 --- /dev/null +++ b/scripts/bedroom/Install-HomebridgeCache.ps1 @@ -0,0 +1,119 @@ +param( + [Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{40}$')][string]$Revision, + [Parameter(Mandatory=$true)][ValidatePattern('^[a-f0-9]{64}$')][string]$PublisherSha256, + [Parameter(Mandatory=$true)][ValidatePattern('^[0-9.]+$')][string]$PodAddress, + [Parameter(Mandatory=$true)][string]$PinnedHostKeyBase64, + [ValidateRange(1,65535)][int]$BridgePort = 40263, + [ValidateRange(1,65535)][int]$PodPort = 8822, + [switch]$ResumeIncomplete +) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = New-Object Security.Principal.WindowsPrincipal($identity) +if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Use Administrator PowerShell' } +$node = (Get-Command node.exe -ErrorAction Stop).Source +$ssh = Join-Path $env:WINDIR 'System32\OpenSSH\ssh.exe' +$keygen = Join-Path $env:WINDIR 'System32\OpenSSH\ssh-keygen.exe' +if (!(Test-Path $ssh) -or !(Test-Path $keygen)) { throw 'Windows OpenSSH Client is required' } +$folder = Join-Path $env:ProgramData 'FreeSleepBedroom' +$taskName = 'FreeSleep-BedroomCache' +if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { + throw 'Publisher already exists; preserve its key and inspect before updating' +} +$existingFolder = Test-Path $folder +if ($existingFolder -and !$ResumeIncomplete) { throw 'Publisher folder exists; inspect before using -ResumeIncomplete' } +if ($ResumeIncomplete) { + if (!$existingFolder) { throw 'No incomplete publisher to resume' } + $directory = Get-Item -LiteralPath $folder + $expectedFiles = @('homebridge-cache.mjs','create-key.mjs','publisher_ed25519','publisher_ed25519.pub') + $files = @(Get-ChildItem -LiteralPath $folder -Force) + if (!$directory.PSIsContainer -or ($directory.Attributes -band [IO.FileAttributes]::ReparsePoint) -or + $files.Count -ne $expectedFiles.Count -or @($files | Where-Object { + $_.PSIsContainer -or ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $_.Name -notin $expectedFiles + }).Count -ne 0) { throw 'Incomplete publisher contents differ; preserve the folder for inspection' } + if ((Get-FileHash (Join-Path $folder 'homebridge-cache.mjs') -Algorithm SHA256).Hash.ToLower() -ne $PublisherSha256) { + throw 'Existing publisher checksum differs; preserve the folder for inspection' + } +} +$hostKey = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($PinnedHostKeyBase64)).Trim() +if ($hostKey -notmatch '^ssh-ed25519 [A-Za-z0-9+/=]+$') { throw 'Expected a pinned Ed25519 Pod host key' } +$bridge = Invoke-RestMethod -Uri "http://127.0.0.1:$BridgePort/accessories" -TimeoutSec 5 +$definitions = @( + @{model='H5106';metric='temperature';type='11'}, @{model='H5106';metric='humidity';type='10'}, + @{model='H5106';metric='pm25';type='C6'}, @{model='H5106';metric='airQuality';type='95'}, + @{model='H712C';metric='power';type='B0'}, @{model='H712C';metric='fanSpeed';type='29'}, + @{model='H7161';metric='power';type='B0'} +) +$sources = @() +foreach ($definition in $definitions) { + $matches = @($bridge.accessories | Where-Object { + @($_.services.characteristics | Where-Object {$_.type -eq '21' -and $_.value -eq $definition.model}).Count -eq 1 + }) + if ($matches.Count -ne 1) { throw "Expected exactly one $($definition.model); inspect the bridge mapping" } + $accessory = $matches[0] + $characteristics = @($accessory.services.characteristics | Where-Object {$_.type -eq $definition.type}) + if ($characteristics.Count -ne 1) { throw "Ambiguous $($definition.metric) characteristic" } + $sources += @{sourceId="govee-$($definition.model.ToLower())-$($definition.metric)"; + aid=$accessory.aid;iid=$characteristics[0].iid;metric=$definition.metric} +} +if (!$existingFolder) { New-Item -ItemType Directory -Path $folder | Out-Null } +& icacls.exe $folder /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not restrict publisher directory' } +$publisher = Join-Path $folder 'homebridge-cache.mjs' +Invoke-WebRequest -UseBasicParsing -Uri "https://raw.githubusercontent.com/lucaandrea/sleepypod-core/$Revision/scripts/bedroom/homebridge-cache.mjs" -OutFile $publisher +if ((Get-FileHash $publisher -Algorithm SHA256).Hash.ToLower() -ne $PublisherSha256) { throw 'Publisher checksum mismatch' } +$key = Join-Path $folder 'publisher_ed25519' +# Node preserves the empty passphrase argument across Windows PowerShell 5.1. +$generator = @' +import {spawnSync} from "node:child_process"; +import {readFileSync} from "node:fs"; +const [keygen,key,mode] = process.argv.slice(2); +if (mode === "verify") { + // Derive the public half without prompting or replacing the existing private key. + const derived = spawnSync(keygen,["-y","-P","","-f",key],{encoding:"utf8",timeout:5000}); + const expected = readFileSync(key+".pub","utf8").trim().split(/\s+/); + if (derived.status !== 0 || expected.length !== 3 || expected[0] !== "ssh-ed25519" || + expected[2] !== "sleepypod-bedroom-cache-only" || + derived.stdout.trim().split(/\s+/).slice(0,2).join(" ") !== expected.slice(0,2).join(" ")) process.exit(1); +} else { + const generated = spawnSync(keygen,["-q","-t","ed25519","-N","","-C","sleepypod-bedroom-cache-only","-f",key],{stdio:"inherit"}); + process.exit(generated.status??1); +} +'@ +$generatorPath = Join-Path $folder 'create-key.mjs' +$utf8 = New-Object Text.UTF8Encoding($false) +[IO.File]::WriteAllText($generatorPath, $generator, $utf8) +if ($ResumeIncomplete) { + & $node $generatorPath $keygen $key verify + if ($LASTEXITCODE -ne 0) { throw 'Existing publisher key pair could not be verified; both files were preserved' } +} else { + & $node $generatorPath $keygen $key + if ($LASTEXITCODE -ne 0 -or !(Test-Path "$key.pub")) { throw 'Publisher key generation failed' } +} +& icacls.exe $key /inheritance:r /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not restrict publisher private key' } +& icacls.exe $key /setowner '*S-1-5-18' | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Could not assign the private key to the SYSTEM task' } +if ($identity.User.Value -notin @('S-1-5-18','S-1-5-32-544')) { + & icacls.exe $key /remove:g "*$($identity.User.Value)" | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not remove the creating user from the service key ACL' } +} +$knownHosts = Join-Path $folder 'known_hosts' +[IO.File]::WriteAllText($knownHosts, "[$PodAddress]:$PodPort $hostKey`n", $utf8) +$configPath = Join-Path $folder 'config.json' +$config = @{bridgeUrl="http://127.0.0.1:$BridgePort";sources=$sources;sshExecutable=$ssh; + podHost=$PodAddress;podPort=$PodPort;podUser='root';keyFile=$key;knownHosts=$knownHosts; + statusFile=(Join-Path $folder 'status.json')} +[IO.File]::WriteAllText($configPath, ($config | ConvertTo-Json -Depth 6), $utf8) +$action = New-ScheduledTaskAction -Execute $node -Argument "`"$publisher`" `"$configPath`"" +$startup = New-ScheduledTaskTrigger -AtStartup +$retry = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 5) +$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName $taskName -Action $action -Trigger @($startup,$retry) -Settings $settings ` + -User 'SYSTEM' -RunLevel Highest -Description 'Read-only Homebridge cache reports to the private Pod; no actuator access.' | Out-Null +Start-ScheduledTask -TaskName $taskName +Write-Output 'Publisher installed. Send ONLY the following public key to authorize cached reports on the Pod:' +Get-Content "$key.pub" +Write-Output 'The private key stays on this Lenovo. Homebridge settings and Apple Home pairings were not changed.' diff --git a/scripts/bedroom/README.md b/scripts/bedroom/README.md new file mode 100644 index 000000000..8465b7451 --- /dev/null +++ b/scripts/bedroom/README.md @@ -0,0 +1,143 @@ +# Bedroom publishers + +The cache publisher and the qualified Govee reader have separate identities and +forced commands. Neither identity permits a shell, temperature commands, history +reads, forwarding, or caller-selected source IDs. The Pod's general owner key +stays on the Pod. Existing Apple Home pairings are preserved. + +```mermaid +flowchart LR + Govee[H5106 status reply] --> Reader[Lenovo: correlated status reader] + Appliances[H712C / H7161 status reply] --> Power[Lenovo: power reader] + HAP[Homebridge cached fan setting] --> Cache[Lenovo: cache publisher] + Reader -->|Dedicated SSH identity| Qualified[govee_ingest.py] + Power -->|Separate power-only identity| PowerReceiver[govee_ingest.py --profile appliance-power] + Cache -->|Existing cache-only identity| Cached[cache_ingest.py] + Qualified --> API[Authenticated local bedroom API] + PowerReceiver --> API + Cached --> API + API --> Apps[Web / iOS / ESP32] +``` + +## Qualified H5106 reads + +`govee-reader.mjs` uses the already installed Homebridge Govee **11.39.1** account +certificate and dependencies. It opens a separate cloud connection and sends only +`status` requests, every 30 seconds. It does not create an account, change device +settings, or require Home Assistant/Pod MQTT. A plugin upgrade requires another +qualification rather than silently accepting a changed decoder. + +Only a reply to this reader's pending transaction, from the configured H5106, +received within eight seconds, with `retain=false`, `dup=false`, QoS 0 and the +qualified three-frame layout is accepted. Each reply can be consumed once. +Disconnects and failed/expired requests withdraw the readings. Old receipts are +never replayed or given new timestamps. The receiver checks source ownership and +rejects reports delayed more than 15 seconds. At most one second of sender clock +skew is clamped backwards to the Pod's clock. + +This establishes a **device read**, not a hardware measurement timestamp. The +API remains version 1: `timeBasis=device_read`, `measuredAt=null`. Neither a cloud +connection nor a cached HAP characteristic alone qualifies. Firmware can still +report its last sampled value, so physical freshness and environmental response +must be checked independently before relying on it for comfort control. + +Temperature, relative humidity and PM2.5 come from the qualified response. The +fourth value is a **PM2.5-derived HomeKit air-quality category**, using the plugin's +bands: 1 at ≤12, 2 at ≤35, 3 at ≤75, 4 at ≤115, otherwise 5 µg/m³. It is not a +national AQI or a separate sensor. Zero particles are valid; cached category 0 +still means unknown. Label this derived category in the source configuration. + +Protocol references: [Govee AWS connection](https://github.com/homebridge-plugins/homebridge-govee/blob/v11.39.1/lib/connection/aws.js) +and [H5106 monitor decoding](https://github.com/homebridge-plugins/homebridge-govee/blob/v11.39.1/lib/device/sensor-monitor.js). +Synthetic test frames contain only the required layout and test values. + +## Qualified appliance power + +The `appliance-power` reader profile supports **H712C power** and **H7161 power**. +It shares one cloud connection between two independent status sessions, each +with its own pending transaction, timeout and publication queue. It requires +the matching model and device, a successful `state.result=1`, numeric `onOff` +of 0 or 1, and the qualified status-frame envelope (11 frames for H712C, +17 for H7161; canonical 20-byte frames with valid XOR checksums). Unknown +firmware layouts withdraw the value instead of guessing. The same eight-second +read and fifteen-second forwarding limits apply. + +These are reported power states, not proof that a motor or diffuser physically +operated. The H712C's manual fan setting can be present even when another work +mode is selected; this profile does **not** promote it to current fan speed. +The cache publisher continues to label that setting as cached/display-only. +Protocol references are the installed provider's +[AWS status requests](https://github.com/homebridge-plugins/homebridge-govee/blob/v11.39.1/lib/connection/aws.js), +[H7126/H712C adapter](https://github.com/homebridge-plugins/homebridge-govee/blob/v11.39.1/lib/device/purifier-H7126.js) +and [H7161 adapter](https://github.com/homebridge-plugins/homebridge-govee/blob/v11.39.1/lib/device/diffuser-H7161.js). + +Install with the same installer and pinned runtime hashes plus +`-ReaderProfile AppliancePower -PowerProtocolSha256 `. It creates a separate +`govee-appliance-power` directory, SYSTEM task `FreeSleep-GoveeAppliancePower` +and `sleepypod-govee-power-read-only` key. The running H5106 task is untouched. +Authorize that new key only for `govee_ingest.py --profile appliance-power`, +using a root-only receiver configuration whose `devices` map contains exactly +`H712C` and `H7161`. Each device entry owns its internal `deviceId`, +`sources: {"power": "..."}` and `externalIds: {"power": "..."}`. The shared +`environment` path remains on the Pod. This key cannot submit monitor readings, +fan speed, arbitrary source IDs or commands; the monitor and cache keys retain +their narrower existing scopes. + +Require recent successful observations from **both** appliances before running +`Set-GoveeReaderMode.ps1 -ReaderProfile AppliancePower -Mode Publish`. It transfers +only `govee-h712c-power` and `govee-h7161-power` out of the cache publisher, +preserving the fan setting. Verify each API observation, independent expiry, +recovery, and rejected shell commands. Existing active comfort policies and +hardware controls are outside this operation. + +Rollback this profile with `-ReaderProfile AppliancePower -Mode Observe`. If both +profiles must be rolled back, restore appliance power **first**, then the +monitor profile: each handoff refuses externally changed configuration rather +than overwriting another publisher's source ownership. + +## Installation and handoff + +1. Keep the existing cache publisher running. Verify the Pod's comfort mode and + enabled automation rules: this work does not authorize Active adaptation. +2. Run `Install-GoveeReader.ps1` in Administrator PowerShell with a pinned Git + revision and SHA-256 hashes for its three `.mjs` files. Supply explicit + `HomebridgeRoot` and `PluginRoot` paths if installing as another account. The + SYSTEM task reads these paths, never SYSTEM's own empty profile. +3. Installation defaults to **observation only**. Inspect the private reader + `status.json` and verify recurring correlated replies and plausible values. + The installer preserves its key and refuses to overwrite a different active + configuration or scheduled task. Private keys remain SYSTEM-owned. +4. Install `govee_ingest.py` in a root-owned, non-writable Pod directory. Create a + root-only JSON configuration with `environment`, the internal `deviceId`, and + `sources` / `externalIds` maps for `temperature`, `humidity`, `pm25`, and + `airQuality`. Map these to the already configured H5106; no owner credential + is copied to Windows. Add the new public key with `restrict`, the Lenovo's + source address, and the forced Python `-I` receiver. Keep the old cache key + cache-only. Check sshd configuration before reloading if required. +5. Verify that a command such as `id` is rejected by this new identity. A successful + SSH login does not establish report acceptance. +6. Run `Set-GoveeReaderMode.ps1 -Mode Publish`. It requires a recent observation, + saves both original configurations, stops both publishers, transfers the four + H5106 sources, and restarts them. The old cache publisher retains appliance + power/fan sources. A partial handoff is recoverable from its private manifest; + unexpected external config changes stop the operation for review. +7. Verify four `device_read` sources and cached appliance state through the Pod + API. Stop the reader long enough to verify expiry, then restart it and require + a new reply. Test unavailable transitions and clock/replay rejection offline. + Do not use synthetic sensor values on a real Pod. + +Rollback: `Set-GoveeReaderMode.ps1 -Mode Observe` restores both exact original +configurations and resumes the cache-only monitor path. It does not change +Homebridge, Apple Home, saved routines, temperature, base, vibration, or MQTT. +Leave the unused separate identity disabled/revoke it if retiring the reader. + +The current handoff supports the source IDs created by `Install-HomebridgeCache.ps1`. +It refuses an unknown mapping instead of guessing which device to replace. + +## Validation + +Run `node --test scripts/bedroom/*.test.mjs` and +`python3 -m unittest discover -s scripts/bedroom -p 'test_*.py' -v`. +Windows CI runs actual PowerShell 5.1 installation/task/ACL/recovery tests against +inert synthetic providers. That does not prove cloud or physical sensor behavior. +Live qualification records belong in private deployment evidence, not fixtures. diff --git a/scripts/bedroom/Set-GoveeReaderMode.ps1 b/scripts/bedroom/Set-GoveeReaderMode.ps1 new file mode 100644 index 000000000..5230f29b8 --- /dev/null +++ b/scripts/bedroom/Set-GoveeReaderMode.ps1 @@ -0,0 +1,102 @@ +param([Parameter(Mandatory=$true)][ValidateSet('Publish','Observe')][string]$Mode, + [ValidateSet('Monitor','AppliancePower')][string]$ReaderProfile='Monitor') +$ErrorActionPreference='Stop' +$principal=New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +if (!$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Use Administrator PowerShell' } +$folder=Join-Path $env:ProgramData 'FreeSleepBedroom' +$powerProfile=$ReaderProfile -eq 'AppliancePower' +$readerFolder=if ($powerProfile) { 'govee-appliance-power' } else { 'govee-readings' } +$readerRoot=Join-Path $folder $readerFolder +$cachePath=Join-Path $folder 'config.json' +$readerPath=Join-Path $readerRoot 'config.json' +$statePath=Join-Path $readerRoot 'handoff.json' +$utf8=New-Object Text.UTF8Encoding($false) +$readerTask=if ($powerProfile) { 'FreeSleep-GoveeAppliancePower' } else { 'FreeSleep-GoveeReadings' } +$tasks=@('FreeSleep-BedroomCache',$readerTask) +foreach ($taskName in $tasks) { Get-ScheduledTask -TaskName $taskName -ErrorAction Stop | Out-Null } +function Write-Atomic { + param([string]$Path,[string]$Contents) + [IO.File]::WriteAllText("$Path.pending",$Contents,$utf8) + if (Test-Path $Path) { [IO.File]::Replace("$Path.pending",$Path,"$Path.previous") } + else { [IO.File]::Move("$Path.pending",$Path) } +} +# Preserve both configurations before transferring only the selected profile's source ownership. +if (!(Test-Path $statePath)) { + if ($Mode -ne 'Publish') { throw 'No publishing handoff exists to restore' } + $cacheBefore=[IO.File]::ReadAllText($cachePath) + $readerBefore=[IO.File]::ReadAllText($readerPath) + $cache=$cacheBefore | ConvertFrom-Json + $reader=$readerBefore | ConvertFrom-Json + $sensorIds=if ($powerProfile) { @('govee-h712c-power','govee-h7161-power') } + else { @('govee-h5106-temperature','govee-h5106-humidity','govee-h5106-pm25','govee-h5106-airQuality') } + $monitorSources=@($cache.sources | Where-Object {$_.sourceId -in $sensorIds}) + if ($reader.mode -ne 'observe' -or $monitorSources.Count -ne $sensorIds.Count -or + @($monitorSources.sourceId | Select-Object -Unique).Count -ne $sensorIds.Count -or $cache.sources.Count -le $sensorIds.Count -or + ($powerProfile -and $reader.profile -ne 'appliance-power') -or (!$powerProfile -and $reader.profile -eq 'appliance-power')) { + throw 'Unexpected source ownership; preserve and inspect the existing configuration' + } + $cache.sources=@($cache.sources | Where-Object {$_.sourceId -notin $sensorIds}) + $reader.mode='publish' + $state=[ordered]@{cacheBefore=$cacheBefore;readerBefore=$readerBefore; + cacheAfter=($cache | ConvertTo-Json -Depth 10);readerAfter=($reader | ConvertTo-Json -Depth 10)} + Write-Atomic $statePath ($state | ConvertTo-Json -Depth 4) +} +$state=Get-Content $statePath -Raw | ConvertFrom-Json +if ([IO.File]::ReadAllText($cachePath) -cnotin @($state.cacheBefore,$state.cacheAfter) -or + [IO.File]::ReadAllText($readerPath) -cnotin @($state.readerBefore,$state.readerAfter)) { + throw 'Configuration changed outside this handoff; preserve it for review' +} +if ($Mode -eq 'Publish') { + $status=Get-Content (Join-Path $readerRoot 'status.json') -Raw | ConvertFrom-Json + $observations=if ($powerProfile) { + if (@($status.devices.PSObject.Properties).Count -ne 2) { throw 'Both qualified appliance observations are required' } + foreach ($model in @('H712C','H7161')) { + $observation=$status.devices.$model + if ($observation.receipt.model -ne $model -or $observation.receipt.reading.power -isnot [int] -or + $observation.receipt.reading.power -notin @(0,1)) { throw 'Both qualified appliance observations are required' } + $observation + } + } else { @($status) } + foreach ($observation in $observations) { + $age=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()-$observation.receipt.observedAt + if (!$observation.receipt.reading -or $age -lt 0 -or $age -gt 90000 -or $observation.error) { + throw 'A recent qualified observation is required before publication' + } + } +} +$completed=$false +$stopped=$false +try { + # Disable retry triggers while source ownership changes, including during recovery. + foreach ($taskName in $tasks) { + Disable-ScheduledTask -TaskName $taskName | Out-Null + Stop-ScheduledTask -TaskName $taskName + } + $deadline=(Get-Date).AddSeconds(20) + do { + $running=@($tasks | ForEach-Object {Get-ScheduledTask -TaskName $_} | Where-Object {$_.State -eq 'Running'}) + if (!$running.Count) { break } + if ((Get-Date) -gt $deadline) { throw 'Publisher did not stop; source ownership was not changed' } + Start-Sleep -Milliseconds 200 + } while ($true) + $stopped=$true + if ($Mode -eq 'Publish') { + Write-Atomic $cachePath $state.cacheAfter + Write-Atomic $readerPath $state.readerAfter + } else { + Write-Atomic $readerPath $state.readerBefore + Write-Atomic $cachePath $state.cacheBefore + } + $completed=$true +} finally { + if (!$completed -and $stopped) { + # On a partial write, restore the original cache-only pipeline before restarting either task. + Write-Atomic $readerPath $state.readerBefore + Write-Atomic $cachePath $state.cacheBefore + } + foreach ($taskName in $tasks) { + Enable-ScheduledTask -TaskName $taskName | Out-Null + Start-ScheduledTask -TaskName $taskName + } +} +Write-Output "Reader profile: $ReaderProfile; mode: $Mode. Verify Pod receipts and expiry before considering the handoff accepted." diff --git a/scripts/bedroom/Test-GoveeInstaller.ps1 b/scripts/bedroom/Test-GoveeInstaller.ps1 new file mode 100644 index 000000000..88ac33751 --- /dev/null +++ b/scripts/bedroom/Test-GoveeInstaller.ps1 @@ -0,0 +1,132 @@ +param([ValidateSet('Monitor','AppliancePower')][string]$ReaderProfile='Monitor') +$ErrorActionPreference='Stop' +if ($env:GITHUB_ACTIONS -ne 'true') { throw 'This installation test is only for an ephemeral GitHub Windows runner' } +$folder=Join-Path $env:ProgramData 'FreeSleepBedroom' +$powerProfile=$ReaderProfile -eq 'AppliancePower' +$readerFolder=if ($powerProfile) { 'govee-appliance-power' } else { 'govee-readings' } +$readerTask=if ($powerProfile) { 'FreeSleep-GoveeAppliancePower' } else { 'FreeSleep-GoveeReadings' } +$remainingSource=if ($powerProfile) { 'govee-h712c-fanSpeed' } else { 'appliance-state' } +$fixtureRoot=Join-Path $env:RUNNER_TEMP 'govee-installer-fixture' +$homebridge=Join-Path $fixtureRoot 'homebridge' +$plugin=Join-Path $fixtureRoot 'plugin' +New-Item -ItemType Directory -Path $folder,(Join-Path $homebridge 'accessories'),$plugin -Force | Out-Null +$utf8=New-Object Text.UTF8Encoding($false) +$fixture=Join-Path $fixtureRoot 'govee-reader.mjs' +# An inert child exercises the real SYSTEM task without accessing a provider or owner credentials. +[IO.File]::WriteAllText($fixture,'setInterval(()=>{},1000)',$utf8) +[IO.File]::WriteAllText((Join-Path $plugin 'package.json'),' {"version":"11.39.1"}',$utf8) +[IO.File]::WriteAllText((Join-Path $homebridge 'accessories\cachedAccessories.fixture'), + '[{"context":{"gvModel":"H712C","gvDeviceId":"test-purifier","awsTopic":"test/purifier"}},{"context":{"gvModel":"H5106","gvDeviceId":"test-monitor","awsTopic":"test/device"}},{"context":{"gvModel":"H7161","gvDeviceId":"test-diffuser","awsTopic":"test/diffuser"}}]',$utf8) +$known=Join-Path $folder 'known_hosts' +[IO.File]::WriteAllText($known,'test-only-host-pin',$utf8) +$cache=[ordered]@{podHost='127.0.0.1';podPort=9;podUser='root';knownHosts=$known; + sshExecutable=(Join-Path $env:WINDIR 'System32\OpenSSH\ssh.exe');sources=@( + @{sourceId='govee-h5106-temperature'},@{sourceId='govee-h5106-humidity'}, + @{sourceId='govee-h5106-pm25'},@{sourceId='govee-h5106-airQuality'},@{sourceId='appliance-state'})} +if ($powerProfile) { $cache.sources=@(@{sourceId='govee-h712c-power'},@{sourceId='govee-h7161-power'},@{sourceId='govee-h712c-fanSpeed'}) } +$cachePath=Join-Path $folder 'config.json' +[IO.File]::WriteAllText($cachePath,($cache | ConvertTo-Json),$utf8) +$cacheHash=(Get-FileHash $cachePath -Algorithm SHA256).Hash +function Invoke-WebRequest { + param([switch]$UseBasicParsing,$Uri,$OutFile) + $name=($Uri -split '/')[-1] + if ($name -eq 'govee-reader.mjs') { Copy-Item $fixture $OutFile } + elseif ($name -in @('govee-protocol.mjs','govee-read-loop.mjs','govee-power-protocol.mjs')) { Copy-Item (Join-Path $PSScriptRoot $name) $OutFile } + else { throw 'Unexpected artifact request' } +} +$installer=Join-Path $PSScriptRoot 'Install-GoveeReader.ps1' +$arguments=@{Revision=('b'*40);ReaderSha256=(Get-FileHash $fixture -Algorithm SHA256).Hash.ToLower(); + ProtocolSha256=(Get-FileHash (Join-Path $PSScriptRoot 'govee-protocol.mjs') -Algorithm SHA256).Hash.ToLower(); + ReadLoopSha256=(Get-FileHash (Join-Path $PSScriptRoot 'govee-read-loop.mjs') -Algorithm SHA256).Hash.ToLower(); + PowerProtocolSha256=(Get-FileHash (Join-Path $PSScriptRoot 'govee-power-protocol.mjs') -Algorithm SHA256).Hash.ToLower(); + ReaderProfile=$ReaderProfile;HomebridgeRoot=$homebridge;PluginRoot=$plugin} +function Assert-Rejected { + param([string]$Expected) + try { & $installer @arguments; throw 'Expected installation rejection' } + catch { if ($_.Exception.Message -notlike "*$Expected*") { throw } } +} +try { + & $installer @arguments + Stop-ScheduledTask -TaskName $readerTask + $readerRoot=Join-Path $folder $readerFolder + $key=Join-Path $readerRoot 'sensor_ed25519' + $keyHash=(Get-FileHash $key -Algorithm SHA256).Hash + $configPath=Join-Path $readerRoot 'config.json' + $configuration=Get-Content $configPath -Raw | ConvertFrom-Json + if ($configuration.mode -ne 'observe' -or + $configuration.homebridgeRoot -ne $homebridge) { throw 'Unexpected observation configuration' } + if ($powerProfile) { + if ($configuration.profile -ne 'appliance-power' -or $configuration.devices.Count -ne 2 -or + $configuration.devices[0].model -ne 'H712C' -or $configuration.devices[0].deviceId -ne 'test-purifier' -or + $configuration.devices[1].model -ne 'H7161' -or $configuration.devices[1].deviceId -ne 'test-diffuser') { + throw 'Unexpected appliance identity mapping' + } + } elseif ($configuration.deviceId -isnot [string] -or $configuration.deviceId -ne 'test-monitor') { + throw 'Unexpected monitor identity mapping' + } + if ((Get-FileHash $cachePath -Algorithm SHA256).Hash -ne $cacheHash) { throw 'Existing cache publisher changed' } + $acl=Get-Acl $key + if ($acl.Owner -notmatch 'SYSTEM') { throw 'The SYSTEM reader must own its key' } + foreach ($entry in $acl.Access) { + if ($entry.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18','S-1-5-32-544')) { + throw 'Unexpected sensor-key principal' + } + } + & $installer @arguments + Stop-ScheduledTask -TaskName $readerTask + if ((Get-FileHash $key -Algorithm SHA256).Hash -ne $keyHash) { throw 'Idempotent installation replaced its key' } + $task=Get-ScheduledTask -TaskName $readerTask + if ($task.Principal.UserId -notin @('SYSTEM','S-1-5-18') -or $task.Triggers.Count -ne 2) { throw 'Incorrect persistent task' } + Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false + & $installer @arguments + Stop-ScheduledTask -TaskName $readerTask + if ((Get-FileHash $key -Algorithm SHA256).Hash -ne $keyHash) { throw 'Task recovery replaced its key' } + $action=New-ScheduledTaskAction -Execute (Get-Command node.exe).Source -Argument "`"$fixture`"" + Register-ScheduledTask -TaskName 'FreeSleep-BedroomCache' -Action $action -User 'SYSTEM' -RunLevel Highest | Out-Null + $status=@{receipt=@{observedAt=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds();reading=@{temperatureC=21}}} + if ($powerProfile) { + $status=@{devices=@{}} + foreach ($model in @('H712C','H7161')) { + $status.devices[$model]=@{receipt=@{model=$model;observedAt=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds();reading=@{power=0}}} + } + } + [IO.File]::WriteAllText((Join-Path $readerRoot 'status.json'),($status | ConvertTo-Json -Depth 5),$utf8) + $handoff=Join-Path $PSScriptRoot 'Set-GoveeReaderMode.ps1' + if ($powerProfile) { + $incomplete=@{devices=@{H712C=$status.devices.H712C}} + [IO.File]::WriteAllText((Join-Path $readerRoot 'status.json'),($incomplete | ConvertTo-Json -Depth 6),$utf8) + try { & $handoff -Mode Publish -ReaderProfile $ReaderProfile; throw 'Expected incomplete observation rejection' } + catch { if ($_.Exception.Message -notlike '*Both qualified*') { throw } } + if ((Get-FileHash $cachePath -Algorithm SHA256).Hash -ne $cacheHash) { throw 'Failed qualification changed source ownership' } + [IO.File]::WriteAllText((Join-Path $readerRoot 'status.json'),($status | ConvertTo-Json -Depth 6),$utf8) + } + & $handoff -Mode Publish -ReaderProfile $ReaderProfile + $publishedCache=Get-Content $cachePath -Raw | ConvertFrom-Json + if ($publishedCache.sources.Count -ne 1 -or $publishedCache.sources[0].sourceId -ne $remainingSource -or + (Get-Content $configPath -Raw | ConvertFrom-Json).mode -ne 'publish') { throw 'Source handoff failed' } + & $handoff -Mode Publish -ReaderProfile $ReaderProfile + & $handoff -Mode Observe -ReaderProfile $ReaderProfile + if ((Get-FileHash $cachePath -Algorithm SHA256).Hash -ne $cacheHash -or + (Get-Content $configPath -Raw | ConvertFrom-Json).mode -ne 'observe') { throw 'Exact rollback failed' } + # Interrupted handoff with only one config replaced is resumed from its durable manifest. + $manifest=Get-Content (Join-Path $readerRoot 'handoff.json') -Raw | ConvertFrom-Json + [IO.File]::WriteAllText($cachePath,$manifest.cacheAfter,$utf8) + & $handoff -Mode Publish -ReaderProfile $ReaderProfile + & $handoff -Mode Observe -ReaderProfile $ReaderProfile + [IO.File]::WriteAllText($cachePath,'{"unexpected":"preserve"}',$utf8) + try { & $handoff -Mode Publish -ReaderProfile $ReaderProfile; throw 'Expected changed-config rejection' } + catch { if ($_.Exception.Message -notlike '*changed outside*') { throw } } + [IO.File]::WriteAllText($cachePath,$manifest.cacheBefore,$utf8) + $configuration.mode='publish' + [IO.File]::WriteAllText($configPath,($configuration | ConvertTo-Json),$utf8) + Assert-Rejected 'configuration differs' + if ((Get-Content $configPath -Raw | ConvertFrom-Json).mode -ne 'publish') { throw 'Existing reader mode was overwritten' } + Write-Output 'Observation-only installation, preserved cache, key ACLs, idempotence, recovery, handoff, rollback and active-mode protection passed.' +} finally { + $task=Get-ScheduledTask -TaskName $readerTask -ErrorAction SilentlyContinue + if ($task) { Stop-ScheduledTask -TaskName $task.TaskName; Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false } + $cacheTask=Get-ScheduledTask -TaskName 'FreeSleep-BedroomCache' -ErrorAction SilentlyContinue + if ($cacheTask) { Stop-ScheduledTask -TaskName $cacheTask.TaskName; Unregister-ScheduledTask -TaskName $cacheTask.TaskName -Confirm:$false } + if (Test-Path $folder) { Remove-Item $folder -Recurse -Force } + if (Test-Path $fixtureRoot) { Remove-Item $fixtureRoot -Recurse -Force } +} diff --git a/scripts/bedroom/Test-WindowsInstaller.ps1 b/scripts/bedroom/Test-WindowsInstaller.ps1 new file mode 100644 index 000000000..22c0118c5 --- /dev/null +++ b/scripts/bedroom/Test-WindowsInstaller.ps1 @@ -0,0 +1,75 @@ +$ErrorActionPreference='Stop' +if ($env:GITHUB_ACTIONS -ne 'true') { throw 'This installation test is only for an ephemeral GitHub Windows runner' } +$folder = Join-Path $env:ProgramData 'FreeSleepBedroom' +$fixture = Join-Path $PSScriptRoot 'homebridge-cache.mjs' +$fixtureHash = (Get-FileHash $fixture -Algorithm SHA256).Hash.ToLower() +function Invoke-RestMethod { + param($Uri,$TimeoutSec) + if ($Uri -ne 'http://127.0.0.1:40263/accessories') { throw 'Unexpected bridge request' } + return @{accessories=@( + @{aid=9;services=@(@{characteristics=@(@{type='21';value='H5106'},@{type='11';iid=10},@{type='10';iid=12},@{type='95';iid=14},@{type='C6';iid=15})})}, + @{aid=10;services=@(@{characteristics=@(@{type='21';value='H712C'},@{type='B0';iid=10},@{type='29';iid=13})})}, + @{aid=11;services=@(@{characteristics=@(@{type='21';value='H7161'},@{type='B0';iid=10})})} + )} +} +function Invoke-WebRequest { + param([switch]$UseBasicParsing,$Uri,$OutFile) + Copy-Item $fixture $OutFile +} +try { + $pin = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKz03wJatN9duVL1CYOYE9Lu8OY9TiFVCbTx6TdFKWdf')) + & (Join-Path $PSScriptRoot 'Install-HomebridgeCache.ps1') -Revision ('a'*40) -PublisherSha256 $fixtureHash -PodAddress 127.0.0.1 -PodPort 9 -PinnedHostKeyBase64 $pin + Stop-ScheduledTask -TaskName 'FreeSleep-BedroomCache' + $config = Get-Content (Join-Path $folder 'config.json') -Raw | ConvertFrom-Json + if ($config.sources.Count -ne 7 -or $config.podPort -ne 9) { throw 'Incorrect source or transport configuration' } + $key = Join-Path $folder 'publisher_ed25519' + $acl = Get-Acl $key + if ($acl.Owner -notmatch 'SYSTEM') { throw 'The publishing task must own its key' } + foreach ($entry in $acl.Access) { + $sid = $entry.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + if ($sid -notin @('S-1-5-18','S-1-5-32-544')) { throw "Unexpected private-key principal: $sid" } + } + $task = Get-ScheduledTask -TaskName 'FreeSleep-BedroomCache' + if ($task.Principal.UserId -notin @('SYSTEM','S-1-5-18') -or $task.Triggers.Count -ne 2) { throw 'Incorrect persistent task' } + $keyHash = (Get-FileHash $key -Algorithm SHA256).Hash + $publicKey = [IO.File]::ReadAllText("$key.pub") + $installer = Join-Path $PSScriptRoot 'Install-HomebridgeCache.ps1' + $arguments = @{Revision=('a'*40);PublisherSha256=$fixtureHash;PodAddress='127.0.0.1';PodPort=9;PinnedHostKeyBase64=$pin} + function Assert-Rejected { + param([string]$Expected) + try { & $installer @arguments -ResumeIncomplete; throw 'Expected installation rejection' } + catch { if ($_.Exception.Message -notlike "*$Expected*") { throw } } + } + Assert-Rejected 'Publisher already exists' + # Reproduce the old ACL failure: a generated pair, but no config or task yet. + Unregister-ScheduledTask -TaskName 'FreeSleep-BedroomCache' -Confirm:$false -ErrorAction SilentlyContinue + foreach ($name in @('config.json','known_hosts','status.json')) { + Remove-Item (Join-Path $folder $name) -Force -ErrorAction SilentlyContinue + } + [IO.File]::WriteAllText((Join-Path $folder 'unexpected.txt'),'preserve me') + Assert-Rejected 'contents differ' + Remove-Item (Join-Path $folder 'unexpected.txt') -Force + [IO.File]::WriteAllText("$key.pub",$publicKey.Replace('ssh-ed25519 ','ssh-ed25519 B')) + Assert-Rejected 'key pair could not be verified' + [IO.File]::WriteAllText("$key.pub",$publicKey) + & $installer @arguments -ResumeIncomplete + Stop-ScheduledTask -TaskName 'FreeSleep-BedroomCache' + if ((Get-FileHash $key -Algorithm SHA256).Hash -ne $keyHash -or [IO.File]::ReadAllText("$key.pub") -ne $publicKey) { + throw 'Recovery must preserve both original key files' + } + $config = Get-Content (Join-Path $folder 'config.json') -Raw | ConvertFrom-Json + if ($config.sources.Count -ne 7) { throw 'Recovery did not restore the source mapping' } + $task = Get-ScheduledTask -TaskName 'FreeSleep-BedroomCache' + if ($task.Triggers.Count -ne 2) { throw 'Recovery did not restore the persistent task' } + $acl = Get-Acl $key + if ($acl.Owner -notmatch 'SYSTEM') { throw 'Recovery did not retain SYSTEM ownership' } + foreach ($entry in $acl.Access) { + $sid = $entry.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + if ($sid -notin @('S-1-5-18','S-1-5-32-544')) { throw 'Recovery broadened private-key access' } + } + Write-Output 'Fresh installation, guarded recovery, key preservation, mapping, ownership, ACL and task checks passed.' +} finally { + $task = Get-ScheduledTask -TaskName 'FreeSleep-BedroomCache' -ErrorAction SilentlyContinue + if ($task) { Stop-ScheduledTask -TaskName $task.TaskName; Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false } + if (Test-Path $folder) { Remove-Item $folder -Recurse -Force } +} diff --git a/scripts/bedroom/cache_ingest.py b/scripts/bedroom/cache_ingest.py new file mode 100644 index 000000000..15095c53c --- /dev/null +++ b/scripts/bedroom/cache_ingest.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Forced SSH command: accept only allowlisted cached bedroom readings, never controls.""" +import argparse +import json +import math +import os +from pathlib import Path +import signal +import stat +import sys +import time +import urllib.request + +LIMIT = 32768 + + +def strict_json(contents): + def pairs(entries): + result = {} + for key, value in entries: + if key in result: raise ValueError('Duplicate field') + result[key] = value + return result + return json.loads(contents, object_pairs_hook=pairs, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Non-finite value'))) + + +def private_file(path): + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise ValueError('Expected a root-owned private file') + return path.read_text() + + +def readings(payload, source_ids, now): + """The key cannot choose a timestamp basis, revision, URL, credential or actuator.""" + if (not isinstance(payload, dict) or set(payload) != {'version', 'issuedAt', 'readings'} + or type(payload['version']) is not int or payload['version'] != 1 + or type(payload['issuedAt']) is not int or abs(now-payload['issuedAt']) > 15000 + or not isinstance(payload['readings'], list) or not 1 <= len(payload['readings']) <= 40): + raise ValueError('Invalid or expired cache envelope') + result = [] + seen = set() + for item in payload['readings']: + if (not isinstance(item, dict) or set(item) != {'sourceId', 'value'} + or not isinstance(item['sourceId'], str) or item['sourceId'] not in source_ids + or item['sourceId'] in seen or (item['value'] is not None and + (type(item['value']) not in (int, float) or not math.isfinite(item['value'])))): + raise ValueError('Invalid or unauthorized source') + seen.add(item['sourceId']) + result.append(item) + return result + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + raise ValueError('Unexpected controller redirect') + + +def deliver(payload, config): + now = int(time.time()*1000) + items = readings(payload, config['sourceIds'], now) + lines = private_file(Path(config['environment'])).splitlines() + keys = [line.split('=', 1)[1] for line in lines if line.startswith('SLEEPYPOD_AUTH_TOKEN=')] + if len(keys) != 1 or len(keys[0]) < 32: raise ValueError('Owner authentication unavailable') + headers = {'Authorization': 'Bearer '+keys[0], 'Content-Type': 'application/json'} + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + # The endpoint is fixed, with no caller-controlled path or HTTP method. + with opener.open(urllib.request.Request('http://127.0.0.1:3000/api/v1/home/bedroom', headers=headers), timeout=5) as response: + status = strict_json(response.read(131072)) + reports = [{'schemaVersion': 1, 'revision': status['revision'], 'sourceId': item['sourceId'], + 'available': item['value'] is not None, 'value': item['value'], + 'observedAt': int(time.time()*1000), 'measuredAt': None, 'timeBasis': 'cached'} for item in items] + request = urllib.request.Request('http://127.0.0.1:3000/api/v1/home/bedroom/reports', + data=json.dumps({'reports': reports}).encode(), headers=headers, method='POST') + with opener.open(request, timeout=5) as response: + result = strict_json(response.read(16384)) + # Never return controller settings, health history or the owner token to the publisher. + return {'accepted': sum(item['accepted'] is True for item in result['results']), + 'submitted': len(reports), 'cachedOnly': True} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', required=True, type=Path) + args = parser.parse_args() + signal.alarm(15) + try: + if os.environ.get('SSH_ORIGINAL_COMMAND', ''): + raise ValueError('Only the forced cache publisher is permitted') + config = strict_json(private_file(args.config)) + contents = sys.stdin.buffer.read(LIMIT+1) + if len(contents) > LIMIT: raise ValueError('Payload too large') + result = deliver(strict_json(contents), config) + print(json.dumps(result)) + except Exception: + print(json.dumps({'accepted': 0, 'error': 'cache_report_rejected'})) + sys.exit(1) + + +if __name__ == '__main__': main() diff --git a/scripts/bedroom/govee-power-protocol.mjs b/scripts/bedroom/govee-power-protocol.mjs new file mode 100644 index 000000000..55b3ea122 --- /dev/null +++ b/scripts/bedroom/govee-power-protocol.mjs @@ -0,0 +1,27 @@ +import { READ_TIMEOUT_MS } from './govee-protocol.mjs' + +export const POWER_MODELS = ['H712C', 'H7161'] + +/** Verify the complete status-frame envelope; fan settings are deliberately not decoded as speed. */ +function validStatusFrames(commands, model) { + if (!Array.isArray(commands) || commands.length !== (model === 'H712C' ? 11 : 17)) return false + return commands.every((value) => { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{27}=$/.test(value)) return false + const frame = Buffer.from(value, 'base64') + return frame.length === 20 && frame.toString('base64') === value + && [0xaa, 0xac].includes(frame[0]) && frame.reduce((checksum, byte) => checksum ^ byte, 0) === 0 + }) +} + +/** Accept only a successful power-state response to this appliance's own pending request. */ +export function decodeAppliancePowerReply(payload, packet, request, deviceId, receivedAt, model) { + if (!POWER_MODELS.includes(model) || !request || !Number.isSafeInteger(request.issuedAt) || request.issuedAt <= 0 + || request.transaction !== `v_${request.issuedAt}000` + || !Number.isSafeInteger(receivedAt) || receivedAt < request.issuedAt + || receivedAt - request.issuedAt > READ_TIMEOUT_MS || packet?.retain !== false + || packet?.dup !== false || packet?.qos !== 0 || payload?.cmd !== 'status' + || payload?.sku !== model || payload?.device !== deviceId || payload?.transaction !== request.transaction + || payload?.state?.result !== 1 || ![0, 1].includes(payload?.state?.onOff) + || !validStatusFrames(payload?.op?.command, model)) return null + return { power: payload.state.onOff } +} diff --git a/scripts/bedroom/govee-power-protocol.test.mjs b/scripts/bedroom/govee-power-protocol.test.mjs new file mode 100644 index 000000000..5231adadf --- /dev/null +++ b/scripts/bedroom/govee-power-protocol.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { decodeAppliancePowerReply } from './govee-power-protocol.mjs' +import { GoveeReadSession } from './govee-protocol.mjs' + +const request = { issuedAt: 1793000000000, transaction: 'v_1793000000000000' } +const packet = { retain: false, dup: false, qos: 0 } + +/** Synthetic frames exercise the qualified envelope without storing a user's device reports. */ +export function powerReply(model, power = 0, transaction = request.transaction) { + const frame = Buffer.alloc(20) + frame[0] = 0xaa + frame[1] = 0x17 + frame[19] = 0xbd + return { cmd: 'status', sku: model, device: `test-${model}`, transaction, + state: { result: 1, onOff: power }, + op: { command: Array.from({ length: model === 'H712C' ? 11 : 17 }, () => frame.toString('base64')) } } +} + +function decode(payload, changes = {}) { + return decodeAppliancePowerReply(payload, changes.packet ?? packet, changes.request ?? request, + changes.deviceId ?? 'test-H712C', changes.receivedAt ?? request.issuedAt + 400, changes.model ?? 'H712C') +} + +test('both qualified models preserve off/on and expose no fan setting as a speed measurement', () => { + for (const model of ['H712C', 'H7161']) for (const power of [0, 1]) { + assert.deepEqual(decode(powerReply(model, power), { model, deviceId: `test-${model}` }), { power }) + } +}) + +test('rejects stale, retained, mismatched, duplicate, unsuccessful and malformed power responses', () => { + for (const changes of [{ packet: { ...packet, retain: true } }, { packet: { ...packet, dup: true } }, + { packet: { ...packet, qos: 1 } }, { receivedAt: request.issuedAt - 1 }, + { receivedAt: request.issuedAt + 8001 }, { model: 'H7126' }, { deviceId: 'another-device' }, + { request: { ...request, transaction: 'unrelated' } }]) assert.equal(decode(powerReply('H712C'), changes), null) + for (const state of [{ onOff: false, result: 1 }, { onOff: 2, result: 1 }, { onOff: '1', result: 1 }, + { onOff: 1, result: 0 }, { onOff: 1 }]) assert.equal(decode({ ...powerReply('H712C'), state }), null) + for (const changes of [{ cmd: 'turn' }, { sku: 'H7161' }, { transaction: 'old' }, { state: null }, { op: {} }]) { + assert.equal(decode({ ...powerReply('H712C'), ...changes }), null) + } + const malformed = powerReply('H712C') + const badChecksum = Buffer.from(malformed.op.command[0], 'base64') + badChecksum[2] = 1 + malformed.op.command[0] = badChecksum.toString('base64') + assert.equal(decode(malformed), null) + malformed.op.command[0] = Buffer.alloc(20).toString('base64') + assert.equal(decode(malformed), null) + malformed.op.command[0] = 'qhcAAAAAAAAAAAAAAAAAAAAAAA==' + assert.equal(decode(malformed), null) + malformed.op.command.pop() + assert.equal(decode(malformed), null) +}) + +test('an appliance session consumes only its own correlated response and never resurrects after disconnect', () => { + const session = new GoveeReadSession('test-H7161', (...arguments_) => decodeAppliancePowerReply(...arguments_, 'H7161')) + session.connect() + session.begin(request.issuedAt) + assert.equal(session.receive(powerReply('H712C'), packet, request.issuedAt + 100), null) + assert.equal(session.receive(powerReply('H7161'), packet, request.issuedAt + 200).reading.power, 0) + assert.equal(session.receive(powerReply('H7161'), packet, request.issuedAt + 300), null) + session.begin(request.issuedAt + 30000) + session.disconnect() + assert.equal(session.receive(powerReply('H7161', 1, 'v_1793000030000000'), packet, request.issuedAt + 30100), null) +}) diff --git a/scripts/bedroom/govee-protocol.mjs b/scripts/bedroom/govee-protocol.mjs new file mode 100644 index 000000000..9abd6e30b --- /dev/null +++ b/scripts/bedroom/govee-protocol.mjs @@ -0,0 +1,84 @@ +/** Qualified H5106 status reads. Cached characteristics are never passed to this decoder. */ +export const READ_TIMEOUT_MS = 8000 + +function statusFrame(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{27}=$/.test(value)) return null + const frame = Buffer.from(value, 'base64') + return frame.length === 20 && frame.toString('base64') === value ? frame : null +} + +/** Only the correlated, non-retained three-frame status layout qualified on H5106 is accepted. */ +export function decodeH5106Reply(payload, packet, request, deviceId, receivedAt) { + if (!request || !Number.isSafeInteger(request.issuedAt) || request.issuedAt <= 0 + || request.transaction !== `v_${request.issuedAt}000` + || !Number.isSafeInteger(receivedAt) || receivedAt < request.issuedAt + || receivedAt - request.issuedAt > READ_TIMEOUT_MS || packet?.retain !== false + || packet?.dup !== false || packet?.qos !== 0 || payload?.cmd !== 'status' + || payload?.sku !== 'H5106' || payload?.device !== deviceId + || payload?.transaction !== request.transaction || !Array.isArray(payload?.op?.command) + || payload.op.command.length !== 3) return null + const frames = payload.op.command.map(statusFrame) + if (frames.some(frame => frame === null) + || frames[1].readUInt16BE(0) !== 0x0103 || frames[2].readUInt16BE(0) !== 0x0100) return null + const climate = frames[0] + const temperatureC = climate.readUInt16BE(0) / 100 + const humidity = climate.readUInt16BE(9) / 100 + const pm25 = climate.readUInt16BE(18) + if (temperatureC > 60 || humidity > 100 || pm25 > 2000) return null + return { temperatureC, humidity, pm25 } +} + +/** One pending read per connection; disconnect, timeout and consumption invalidate it. */ +export class GoveeReadSession { + constructor(deviceId, decodeReply = decodeH5106Reply) { + this.deviceId = deviceId + this.decodeReply = decodeReply + this.pending = null + this.connected = false + this.lastIssuedAt = 0 + } + + connect() { + this.pending = null + this.connected = true + } + + disconnect() { + this.pending = null + this.connected = false + } + + begin(issuedAt) { + if (!this.connected || this.pending || !Number.isSafeInteger(issuedAt) + || issuedAt <= this.lastIssuedAt) return null + this.lastIssuedAt = issuedAt + this.pending = { issuedAt, transaction: `v_${issuedAt}000` } + return { ...this.pending } + } + + expire(now) { + if (!this.pending || (now >= this.pending.issuedAt && now - this.pending.issuedAt <= READ_TIMEOUT_MS)) return false + this.pending = null + return true + } + + cancel(transaction) { + if (this.pending?.transaction !== transaction) return false + this.pending = null + return true + } + + receive(payload, packet, receivedAt) { + if (!this.connected) return null + const reading = this.decodeReply(payload, packet, this.pending, this.deviceId, receivedAt) + if (!reading) return null + const requestIssuedAt = this.pending.issuedAt + this.pending = null + return { version: 1, requestIssuedAt, observedAt: receivedAt, reading } + } +} + +/** A failed request withdraws the complete sensor, without inventing a measurement clock. */ +export function unavailableGoveeReport(observedAt) { + return { version: 1, requestIssuedAt: null, observedAt, reading: null } +} diff --git a/scripts/bedroom/govee-protocol.test.mjs b/scripts/bedroom/govee-protocol.test.mjs new file mode 100644 index 000000000..0457056cc --- /dev/null +++ b/scripts/bedroom/govee-protocol.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { decodeH5106Reply, GoveeReadSession, READ_TIMEOUT_MS, unavailableGoveeReport } from './govee-protocol.mjs' + +const issuedAt = 1793000000000 +const request = { issuedAt, transaction: `v_${issuedAt}000` } +const packet = { retain: false, dup: false, qos: 0 } +// Synthetic frame values in the qualified H5106 layout; unused bytes are zero-filled. +const payload = { cmd: 'status', sku: 'H5106', device: 'test-monitor', transaction: request.transaction, + op: { command: ['CGYAAAAAAAAAFdYAAAAAAAAAAAA=', 'AQMAAAAAAAAAAAAAAAAAAAAAAAA=', 'AQAAAAAAAAAAAAAAAAAAAAAAAAA='] } } + +test('decodes a correlated synthetic status layout without rounding humidity or dropping zero particles', () => { + assert.deepEqual(decodeH5106Reply(payload, packet, request, 'test-monitor', issuedAt + 936), + { temperatureC: 21.5, humidity: 55.9, pm25: 0 }) +}) + +test('rejects wrong devices, requests, cached/duplicate delivery and unsupported message layouts', () => { + for (const changed of [{ device: 'another-monitor' }, { sku: 'H5101' }, { cmd: 'pt' }, + { transaction: 'an-old-read' }, { op: { command: [] } }, + { op: { command: [...payload.op.command, payload.op.command[0]] } }, + { op: { command: [payload.op.command[0], payload.op.command[2], payload.op.command[1]] } }, + { op: { command: ['not-base64', ...payload.op.command.slice(1)] } }]) { + assert.equal(decodeH5106Reply({ ...payload, ...changed }, packet, request, 'test-monitor', issuedAt + 100), null) + } + for (const metadata of [{ ...packet, retain: true }, { ...packet, dup: true }, { ...packet, qos: 1 }, {}, undefined]) { + assert.equal(decodeH5106Reply(payload, metadata, request, 'test-monitor', issuedAt + 100), null) + } + for (const timestamp of [issuedAt - 1, issuedAt + READ_TIMEOUT_MS + 1, NaN, 1.5]) { + assert.equal(decodeH5106Reply(payload, packet, request, 'test-monitor', timestamp), null) + } +}) + +test('rejects malformed climate values while preserving a real zero-degree status response', () => { + for (const [offset, value] of [[0, 6001], [9, 10001], [18, 2001]]) { + const frame = Buffer.from(payload.op.command[0], 'base64') + frame.writeUInt16BE(value, offset) + assert.equal(decodeH5106Reply({ ...payload, op: { command: [frame.toString('base64'), ...payload.op.command.slice(1)] } }, + packet, request, 'test-monitor', issuedAt + 100), null) + } + const frame = Buffer.from(payload.op.command[0], 'base64') + frame.writeUInt16BE(0, 0) + assert.equal(decodeH5106Reply({ ...payload, op: { command: [frame.toString('base64'), ...payload.op.command.slice(1)] } }, + packet, request, 'test-monitor', issuedAt + 100).temperatureC, 0) +}) + +test('a connection cannot replay, overlap, resurrect or accept an expired read', () => { + const session = new GoveeReadSession('test-monitor') + assert.equal(session.begin(issuedAt), null) + session.connect() + assert.deepEqual(session.begin(issuedAt), request) + assert.equal(session.begin(issuedAt + 1), null) + const report = session.receive(payload, packet, issuedAt + 100) + assert.equal(report.requestIssuedAt, issuedAt) + assert.equal(report.observedAt, issuedAt + 100) + assert.equal(session.receive(payload, packet, issuedAt + 101), null) + assert.equal(session.begin(issuedAt), null) + session.begin(issuedAt + 1000) + session.disconnect() + session.connect() + assert.equal(session.receive({ ...payload, transaction: `v_${issuedAt + 1000}000` }, packet, issuedAt + 1100), null) + session.begin(issuedAt + 2000) + assert.equal(session.expire(issuedAt + 2000 + READ_TIMEOUT_MS + 1), true) + assert.equal(session.receive({ ...payload, transaction: `v_${issuedAt + 2000}000` }, packet, issuedAt + 2100), null) + session.begin(issuedAt + 3000) + assert.equal(session.expire(issuedAt + 2999), true) + assert.deepEqual(unavailableGoveeReport(issuedAt + 4000), + { version: 1, requestIssuedAt: null, observedAt: issuedAt + 4000, reading: null }) +}) diff --git a/scripts/bedroom/govee-read-loop.mjs b/scripts/bedroom/govee-read-loop.mjs new file mode 100644 index 000000000..4f06bc5a0 --- /dev/null +++ b/scripts/bedroom/govee-read-loop.mjs @@ -0,0 +1,93 @@ +import { GoveeReadSession, unavailableGoveeReport } from './govee-protocol.mjs' + +export const READ_INTERVAL_MS = 30000 + +/** The only outbound device command is a status read; source loss withdraws this device's observations. */ +export function startGoveeReadLoop({ client, deviceId, deviceTopic, accountTopic, statusVersion, decodeReply }, report, + { now = Date.now, repeat = setInterval, cancel = clearInterval, closeClientOnStop = true } = {}) { + const session = new GoveeReadSession(deviceId, decodeReply) + let generation = 0 + let stopped = false + let withdrawn = false + let lastRequestAt = 0 + let lastDelivery = Promise.resolve() + + function deliver(receipt) { + // The caller serializes transport. Do not make a failed receipt fresh by changing its clock. + lastDelivery = Promise.resolve(report(receipt)).catch(() => {}) + return lastDelivery + } + + function withdraw() { + if (withdrawn) return lastDelivery + withdrawn = true + return deliver(unavailableGoveeReport(now())) + } + + function read() { + const request = session.begin(now()) + if (!request) return + lastRequestAt = request.issuedAt + const requestGeneration = generation + client.publish(deviceTopic, JSON.stringify({ msg: { + cmd: 'status', cmdVersion: statusVersion, transaction: request.transaction, + type: 0, accountTopic, + } }), { qos: 0, retain: false }, (error) => { + if (error && generation === requestGeneration && !stopped && session.cancel(request.transaction)) { + withdraw() + } + }) + } + + function disconnected() { + if (stopped) return + generation += 1 + session.disconnect() + withdraw() + } + + client.on('connect', () => { + if (stopped) return + generation += 1 + const connectedGeneration = generation + session.disconnect() + client.subscribe(accountTopic, { qos: 0 }, (error) => { + if (stopped || generation !== connectedGeneration) return + if (error) return disconnected() + session.connect() + read() + }) + }) + for (const event of ['close', 'offline', 'error']) client.on(event, disconnected) + client.on('message', (topic, bytes, packet) => { + if (stopped || topic !== accountTopic || bytes.length > 32768) return + try { + let payload = JSON.parse(bytes.toString('utf8')) + if (payload.msg) payload = typeof payload.msg === 'string' ? JSON.parse(payload.msg) : payload.msg + const receipt = session.receive(payload, packet, now()) + if (receipt) { + withdrawn = false + deliver(receipt) + } + } + catch { /* A malformed or unrecognized reply cannot extend the pending read's deadline. */ } + }) + + const timer = repeat(() => { + if (stopped) return + if (session.expire(now())) withdraw() + if (session.connected && !session.pending && now() - lastRequestAt >= READ_INTERVAL_MS) read() + }, 500) + + return { + async stop() { + if (stopped) return + stopped = true + generation += 1 + session.disconnect() + cancel(timer) + await withdraw() + if (closeClientOnStop) client.end(true) + }, + } +} diff --git a/scripts/bedroom/govee-read-loop.test.mjs b/scripts/bedroom/govee-read-loop.test.mjs new file mode 100644 index 000000000..fc0c9b4f0 --- /dev/null +++ b/scripts/bedroom/govee-read-loop.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import test from 'node:test' +import { startGoveeReadLoop } from './govee-read-loop.mjs' +import { decodeAppliancePowerReply } from './govee-power-protocol.mjs' + +function fixture() { + let clock = 1793000000000 + let tick + const client = new EventEmitter() + const requests = [] + const reports = [] + client.publish = (topic, contents, options, callback) => { + requests.push({ topic, contents: JSON.parse(contents), options }) + callback() + } + client.subscribe = (_topic, _options, callback) => callback() + client.end = () => { + client.ended = true + } + const loop = startGoveeReadLoop({ client, deviceId: 'test-monitor', deviceTopic: 'device-topic', + accountTopic: 'account-topic', statusVersion: 0 }, receipt => reports.push(receipt), + { now: () => clock, repeat: (callback) => { + tick = callback + return 1 + }, cancel: () => {} }) + return { client, requests, reports, loop, advance: (milliseconds) => { + clock += milliseconds + tick() + }, + reply(changes = {}, packet = { retain: false, dup: false, qos: 0 }) { + const payload = { cmd: 'status', sku: 'H5106', device: 'test-monitor', + transaction: requests.at(-1).contents.msg.transaction, + op: { command: ['CGYAAAAAAAAAFdYAAAAAAAAAAAA=', 'AQMAAAAAAAAAAAAAAAAAAAAAAAA=', 'AQAAAAAAAAAAAAAAAAAAAAAAAAA='] }, ...changes } + client.emit('message', 'account-topic', Buffer.from(JSON.stringify(payload)), packet) + } } +} + +test('issues only status reads and delivers a newly correlated reading once', async () => { + const state = fixture() + state.client.emit('connect') + assert.deepEqual(state.requests[0], { topic: 'device-topic', contents: { msg: { + cmd: 'status', cmdVersion: 0, transaction: 'v_1793000000000000', type: 0, accountTopic: 'account-topic', + } }, options: { qos: 0, retain: false } }) + state.advance(900) + state.reply() + state.reply() + assert.equal(state.reports.length, 1) + assert.equal(state.reports[0].reading.temperatureC, 21.5) + state.client.emit('offline') + state.client.emit('close') + assert.equal(state.reports.length, 2) + assert.equal(state.reports[1].reading, null) + await state.loop.stop() + assert.equal(state.client.ended, true) +}) + +test('retained/malformed/delayed replies expire and reconnect requires another request', async () => { + const state = fixture() + state.client.emit('connect') + const originalTransaction = state.requests[0].contents.msg.transaction + state.reply({}, { retain: true, dup: false, qos: 0 }) + state.client.emit('message', 'account-topic', Buffer.from('bad-json'), {}) + state.advance(8001) + state.reply() + assert.deepEqual(state.reports.map(receipt => receipt.reading), [null]) + state.client.emit('offline') + state.client.emit('connect') + state.reply({ transaction: originalTransaction }) + assert.equal(state.reports.length, 1) + state.advance(500) + state.reply() + assert.equal(state.reports.at(-1).reading.humidity, 55.9) + state.advance(30000) + assert.equal(state.requests.length, 3) + await state.loop.stop() +}) + +test('a late subscription callback cannot revive a disconnected or stopped session', async () => { + const state = fixture() + let subscription + state.client.subscribe = (_topic, _options, callback) => { + subscription = callback + } + state.client.emit('connect') + state.client.emit('close') + subscription() + assert.equal(state.requests.length, 0) + state.client.emit('connect') + await state.loop.stop() + subscription() + assert.equal(state.requests.length, 0) +}) + +test('publish failure withdraws the source without retrying an old request', async () => { + const state = fixture() + state.client.publish = (_topic, _contents, _options, callback) => callback(new Error('offline')) + state.client.emit('connect') + assert.equal(state.reports.length, 1) + assert.equal(state.reports[0].reading, null) + state.advance(1000) + assert.equal(state.reports.length, 1) + await state.loop.stop() +}) + +test('a late failed publish callback cannot cancel a newer pending read', async () => { + const state = fixture() + const callbacks = [] + state.client.publish = (topic, contents, options, callback) => { + state.requests.push({ topic, contents: JSON.parse(contents), options }) + callbacks.push(callback) + } + state.client.emit('connect') + state.advance(900) + state.reply() + state.advance(30000) + callbacks[0](new Error('late error')) + state.reply() + assert.equal(state.reports.length, 2) + assert.ok(state.reports.every(receipt => receipt.reading !== null)) + await state.loop.stop() +}) + +test('two appliance sessions share a connection without mixing receipts or closing each other', async () => { + const client = new EventEmitter() + const requests = [] + const reports = { H712C: [], H7161: [] } + let ended = 0 + client.subscribe = (_topic, _options, callback) => callback() + client.publish = (topic, contents, _options, callback) => { + requests.push({ topic, message: JSON.parse(contents).msg }) + callback() + } + client.end = () => { + ended += 1 + } + const loops = ['H712C', 'H7161'].map(model => startGoveeReadLoop({ client, + deviceId: `test-${model}`, deviceTopic: model, accountTopic: 'account-topic', statusVersion: 2, + decodeReply: (...arguments_) => decodeAppliancePowerReply(...arguments_, model) }, + receipt => reports[model].push(receipt), { + now: () => 1793000000000, repeat: () => 1, cancel: () => {}, closeClientOnStop: false, + })) + client.emit('connect') + assert.deepEqual(requests.map(request => request.topic), ['H712C', 'H7161']) + assert.ok(requests.every(request => request.message.cmd === 'status')) + const frame = Buffer.alloc(20) + frame[0] = 0xaa + frame[1] = 0x17 + frame[19] = 0xbd + const emit = model => client.emit('message', 'account-topic', Buffer.from(JSON.stringify({ + cmd: 'status', sku: model, device: `test-${model}`, transaction: requests[0].message.transaction, + state: { result: 1, onOff: 0 }, op: { command: Array(model === 'H712C' ? 11 : 17).fill(frame.toString('base64')) }, + })), { retain: false, dup: false, qos: 0 }) + emit('H712C') + assert.equal(reports.H712C[0].reading.power, 0) + assert.equal(reports.H7161.length, 0) + await loops[0].stop() + assert.equal(ended, 0) + emit('H7161') + assert.equal(reports.H7161[0].reading.power, 0) + await loops[1].stop() + assert.equal(ended, 0) + assert.ok(Object.values(reports).every(receipts => receipts.at(-1).reading === null)) +}) diff --git a/scripts/bedroom/govee-reader.mjs b/scripts/bedroom/govee-reader.mjs new file mode 100644 index 000000000..470e393ae --- /dev/null +++ b/scripts/bedroom/govee-reader.mjs @@ -0,0 +1,241 @@ +/** Headless, status-only Govee adapter. Account credentials remain in the existing Homebridge store. */ +import { readFile, readdir, writeFile, rename } from 'node:fs/promises' +import path from 'node:path' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { randomBytes } from 'node:crypto' +import { spawn } from 'node:child_process' +import { startGoveeReadLoop } from './govee-read-loop.mjs' + +const SUPPORTED_PLUGIN_VERSION = '11.39.1' + +function concreteTopic(value) { + return typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[+#\0]/.test(value) +} + +/** Resolve exact model/device pairs; ambiguous devices or wildcard topics cannot enter the reader. */ +export function resolveDeviceRoutes(accessories, devices, capabilities) { + if (!Array.isArray(devices) || devices.length < 1 || devices.length > 3 + || new Set(devices.map(device => device.model)).size !== devices.length + || new Set(devices.map(device => device.deviceId)).size !== devices.length) throw new Error('Invalid device selection') + return devices.map(({ model, deviceId }) => { + if (!['H5106', 'H712C', 'H7161'].includes(model) || typeof deviceId !== 'string' || !deviceId) { + throw new Error('Unsupported device selection') + } + const matches = accessories.filter(accessory => accessory.context?.gvModel === model + && accessory.context.gvDeviceId === deviceId) + if (matches.length !== 1 || !concreteTopic(matches[0].context.awsTopic)) throw new Error('Device routing unavailable') + return { model, deviceId, deviceTopic: matches[0].context.awsTopic, + statusVersion: capabilities(model).awsStatusCmdVersion } + }) +} + +/** Use one account connection for the explicitly selected devices; credentials remain private. */ +export async function loadGoveeProviders(config, devices) { + const packageFile = path.join(config.pluginRoot, 'package.json') + const plugin = JSON.parse(await readFile(packageFile, 'utf8')) + if (plugin.version !== SUPPORTED_PLUGIN_VERSION) throw new Error('Govee plugin version requires qualification') + const require = createRequire(packageFile) + const { device } = require('aws-iot-device-sdk') + const { pfxToCertAndKey } = await import(pathToFileURL(path.join(config.pluginRoot, 'lib/utils/functions.js'))) + const { getDeviceCapabilities } = await import(pathToFileURL(path.join(config.pluginRoot, 'lib/utils/device-capabilities.js'))) + const cacheRoot = path.join(config.homebridgeRoot, 'bwp91_cache') + const cache = await Promise.all((await readdir(cacheRoot)).map(async filename => + JSON.parse(await readFile(path.join(cacheRoot, filename), 'utf8')))) + const accounts = cache.filter(item => item.key === 'Govee_All_Devices_temp') + if (accounts.length !== 1 || typeof accounts[0].value !== 'string') throw new Error('Account cache unavailable') + const fields = accounts[0].value.split(':::') + if (fields.length !== 7 || !concreteTopic(fields[0]) + || !/^[a-z0-9-]+\.iot\.[a-z0-9-]+\.amazonaws\.com$/.test(fields[4])) throw new Error('Unexpected account routing') + const accessoryRoot = path.join(config.homebridgeRoot, 'accessories') + const accessories = (await Promise.all((await readdir(accessoryRoot)) + .filter(filename => filename.startsWith('cachedAccessories.')) + .map(async filename => JSON.parse(await readFile(path.join(accessoryRoot, filename), 'utf8'))))).flat() + const routes = resolveDeviceRoutes(accessories, devices, getDeviceCapabilities) + const identity = await pfxToCertAndKey(path.join(config.homebridgeRoot, 'persist/govee.pfx'), fields[5]) + const client = device({ privateKey: Buffer.from(identity.key), clientCert: Buffer.from(identity.cert), + caCert: await readFile(path.join(config.pluginRoot, 'lib/connection/cert/AmazonRootCA1.pem')), + clientId: `AP/${fields[3]}/hbsleepypod${randomBytes(8).toString('hex')}`, + host: fields[4], enableMetrics: false, clean: true, keepalive: 30, + }) + return { client, accountTopic: fields[0], devices: routes } +} + +export async function loadGoveeProvider(config) { + const provider = await loadGoveeProviders(config, [{ model: 'H5106', deviceId: config.deviceId }]) + return { client: provider.client, accountTopic: provider.accountTopic, ...provider.devices[0] } +} + +/** A dedicated forced-command key can submit only these receipts, never a remote shell command. */ +export function publishRead(config, receipt, expectedCount = 4) { + return new Promise((resolve, reject) => { + const child = spawn(config.sshExecutable, ['-T', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', '-o', 'LogLevel=ERROR', + '-o', `UserKnownHostsFile=${config.knownHosts}`, '-i', config.keyFile, '-p', String(config.podPort), + `${config.podUser}@${config.podHost}`], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + let output = '' + const deadline = setTimeout(() => child.kill(), 14000) + child.stdout.on('data', (chunk) => { + output += chunk.toString('utf8') + if (output.length > 16384) child.kill() + }) + child.stderr.resume() + child.stdin.on('error', () => {}) + child.on('error', () => { + clearTimeout(deadline) + reject(new Error('Transport unavailable')) + }) + child.on('close', (code) => { + clearTimeout(deadline) + try { + const result = JSON.parse(output) + if (code !== 0 || result.deviceReadOnly !== true || result.submitted !== expectedCount || result.accepted !== expectedCount) { + throw new Error('Receipt not accepted') + } + resolve(result) + } + catch { reject(new Error('Publication unavailable')) } + }) + child.stdin.end(JSON.stringify(receipt)) + }) +} + +/** Serialize publication and preserve original clocks; stale queued receipts are discarded. */ +export function receiptQueue(send, onStatus, now = Date.now) { + let running = null + let latest = null + return function enqueue(receipt) { + latest = receipt + if (running) return running + running = (async () => { + while (latest) { + const current = latest + latest = null + if (now() - current.observedAt > 15000 || current.observedAt - now() > 1000) { + await onStatus({ error: 'receipt_expired', observedAt: current.observedAt }) + continue + } + try { + await onStatus({ ...(await send(current)), receipt: current }) + } + catch { await onStatus({ error: 'publication_unavailable', observedAt: current.observedAt }) } + } + })().finally(() => { running = null }) + return running + } +} + +async function runMonitor(config) { + const once = process.argv.includes('--once') + let loop + let completed = false + let lastQualifiedRead = null + const status = async (value) => { + if (value.receipt?.reading) lastQualifiedRead = value.receipt + const contents = JSON.stringify({ mode: config.mode, checkedAt: new Date().toISOString(), lastQualifiedRead, ...value }) + await writeFile(config.statusFile + '.tmp', contents, { mode: 0o600 }) + await rename(config.statusFile + '.tmp', config.statusFile) + if (once && value.receipt?.reading && !completed) { + completed = true + console.log(JSON.stringify({ observed: true, published: config.mode === 'publish', measurements: 4 })) + setTimeout(async () => { + await loop.stop() + process.exit(0) + }, 0) + } + } + const queue = receiptQueue(receipt => config.mode === 'publish' + ? publishRead(config, receipt) + : Promise.resolve({ observedOnly: true }), status) + loop = startGoveeReadLoop(await loadGoveeProvider(config), queue) + for (const event of ['SIGINT', 'SIGTERM']) process.once(event, async () => { + const deadline = setTimeout(() => process.exit(1), 18000) + await loop.stop() + clearTimeout(deadline) + process.exit(0) + }) + if (once) setTimeout(async () => { + if (completed) return + completed = true + await loop.stop() + console.error('No qualified device reply arrived.') + process.exit(1) + }, 20000) +} + +/** Independent per-device queues preserve both power reports while sharing one cloud connection. */ +async function runAppliancePower(config) { + const { POWER_MODELS, decodeAppliancePowerReply } = await import('./govee-power-protocol.mjs') + if (!Array.isArray(config.devices) || config.devices.length !== POWER_MODELS.length + || POWER_MODELS.some(model => !config.devices.some(device => device.model === model))) { + throw new Error('Expected both qualified appliance models') + } + const provider = await loadGoveeProviders(config, config.devices) + const states = {} + const qualified = new Set() + const loops = [] + let writes = Promise.resolve() + let completed = false + const once = process.argv.includes('--once') + const stop = async () => { + await Promise.all(loops.map(loop => loop.stop())) + provider.client.end(true) + await writes + } + for (const device of provider.devices) { + const status = (value) => { + if (value.receipt?.reading) qualified.add(device.model) + states[device.model] = { checkedAt: new Date().toISOString(), + lastQualifiedRead: value.receipt?.reading ? value.receipt : states[device.model]?.lastQualifiedRead ?? null, ...value } + writes = writes.then(async () => { + const contents = JSON.stringify({ mode: config.mode, profile: config.profile, + checkedAt: new Date().toISOString(), devices: states }) + await writeFile(config.statusFile + '.tmp', contents, { mode: 0o600 }) + await rename(config.statusFile + '.tmp', config.statusFile) + }) + if (once && qualified.size === POWER_MODELS.length && !completed) { + completed = true + console.log(JSON.stringify({ observed: true, published: config.mode === 'publish', measurements: 2 })) + setTimeout(async () => { + await stop() + process.exit(0) + }, 0) + } + return writes + } + const queue = receiptQueue(receipt => config.mode === 'publish' + ? publishRead(config, receipt, 1) + : Promise.resolve({ observedOnly: true }), status) + loops.push(startGoveeReadLoop({ ...device, client: provider.client, accountTopic: provider.accountTopic, + decodeReply: (...arguments_) => decodeAppliancePowerReply(...arguments_, device.model) }, + receipt => queue({ ...receipt, model: device.model }), { closeClientOnStop: false })) + } + for (const event of ['SIGINT', 'SIGTERM']) process.once(event, async () => { + const deadline = setTimeout(() => process.exit(1), 18000) + await stop() + clearTimeout(deadline) + process.exit(0) + }) + if (once) setTimeout(async () => { + if (completed) return + completed = true + await stop() + console.error('Not all appliances returned a qualified status.') + process.exit(1) + }, 20000) +} + +async function main() { + const config = JSON.parse(await readFile(process.argv[2], 'utf8')) + if (!['observe', 'publish'].includes(config.mode)) throw new Error('Expected an explicit reader mode') + if (config.profile === 'appliance-power') await runAppliancePower(config) + else if (config.profile === undefined || config.profile === 'monitor') await runMonitor(config) + else throw new Error('Unsupported reader profile') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(() => { + console.error('Govee reader stopped; inspect the private installation.') + process.exitCode = 1 + }) +} diff --git a/scripts/bedroom/govee-reader.test.mjs b/scripts/bedroom/govee-reader.test.mjs new file mode 100644 index 000000000..18e7fdb3f --- /dev/null +++ b/scripts/bedroom/govee-reader.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { receiptQueue, resolveDeviceRoutes } from './govee-reader.mjs' + +test('device routing matches exact model/identity pairs and refuses ambiguity or topic wildcards', () => { + const accessories = ['H5106', 'H712C', 'H7161'].map(model => ({ context: { + gvModel: model, gvDeviceId: `test-${model}`, awsTopic: `test/${model}`, + } })) + const devices = ['H712C', 'H7161'].map(model => ({ model, deviceId: `test-${model}` })) + const capabilities = () => ({ awsStatusCmdVersion: 2 }) + assert.deepEqual(resolveDeviceRoutes(accessories, devices, capabilities).map(device => device.deviceTopic), ['test/H712C', 'test/H7161']) + assert.throws(() => resolveDeviceRoutes([...accessories, accessories[1]], devices, capabilities)) + assert.throws(() => resolveDeviceRoutes(accessories, [devices[0], devices[0]], capabilities)) + assert.throws(() => resolveDeviceRoutes(accessories, [{ model: 'H7126', deviceId: 'test-H712C' }], capabilities)) + assert.throws(() => resolveDeviceRoutes(accessories, [{ model: 'H7161', deviceId: 'test-H712C' }], capabilities)) + accessories[1].context.awsTopic = 'test/#' + assert.throws(() => resolveDeviceRoutes(accessories, devices, capabilities)) +}) + +test('serial publication coalesces pending updates and prioritizes the most recent unavailable receipt', async () => { + const sent = [] + const states = [] + let finish + const queue = receiptQueue(async (receipt) => { + sent.push(receipt) + if (sent.length === 1) await new Promise((resolve) => { + finish = resolve + }) + return { accepted: 4 } + }, state => states.push(state), () => 100000) + const first = queue({ observedAt: 99900, reading: { temperatureC: 21 } }) + queue({ observedAt: 99950, reading: { temperatureC: 22 } }) + queue({ observedAt: 100000, reading: null }) + finish() + await first + assert.equal(sent.length, 2) + assert.equal(sent[1].reading, null) + assert.equal(sent[0].observedAt, 99900) + assert.equal(states.length, 2) +}) + +test('a failed or expired receipt is never silently re-timestamped or retried', async () => { + const sent = [] + const states = [] + const queue = receiptQueue(async (receipt) => { + sent.push(receipt) + throw new Error('unreachable') + }, + state => states.push(state), () => 100000) + await queue({ observedAt: 100000, reading: {} }) + await queue({ observedAt: 1, reading: {} }) + await queue({ observedAt: 101001, reading: {} }) + assert.equal(sent.length, 1) + assert.deepEqual(states.map(state => state.error), ['publication_unavailable', 'receipt_expired', 'receipt_expired']) +}) diff --git a/scripts/bedroom/govee_ingest.py b/scripts/bedroom/govee_ingest.py new file mode 100644 index 000000000..37ca949b1 --- /dev/null +++ b/scripts/bedroom/govee_ingest.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Profile-scoped forced Govee receiver; no caller-selected source or clock basis.""" +import argparse +import json +import math +import os +from pathlib import Path +import signal +import stat +import sys +import time +import urllib.request + +MAX_BYTES = 4096 +READ_WINDOW_MS = 8000 +FORWARD_WINDOW_MS = 15000 + + +def strict_json(contents): + def unique(entries): + result = {} + for name, value in entries: + if name in result: + raise ValueError('Duplicate field') + result[name] = value + return result + return json.loads(contents, object_pairs_hook=unique, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Non-finite value'))) + + +def private_file(path): + information = path.lstat() + if not stat.S_ISREG(information.st_mode) or information.st_uid != 0 or information.st_mode & 0o077: + raise ValueError('Expected a root-owned private file') + return path.read_text() + + +def air_quality(pm25): + """HomeKit category derived from a new PM2.5 read using Govee's published bands; not AQI.""" + return 1 if pm25 <= 12 else 2 if pm25 <= 35 else 3 if pm25 <= 75 else 4 if pm25 <= 115 else 5 + + +def receipt_reading(payload, now, extra_fields=frozenset()): + """Validate the receipt clock before model-specific fields are interpreted.""" + if (not isinstance(payload, dict) + or set(payload) != {'version', 'requestIssuedAt', 'observedAt', 'reading'} | extra_fields + or type(payload['version']) is not int or payload['version'] != 1 + or type(payload['observedAt']) is not int or payload['observedAt'] <= 0 + or payload['observedAt'] - now > 1000 + or now - payload['observedAt'] > FORWARD_WINDOW_MS): + raise ValueError('Invalid or expired read receipt') + # Small sender clock skew is clamped backwards, never made newer by forwarding. + observed_at = min(payload['observedAt'], now) + reading = payload['reading'] + if reading is None: + if payload['requestIssuedAt'] is not None: + raise ValueError('Unavailable receipt must not claim a successful request') + return observed_at, None + requested_at = payload['requestIssuedAt'] + if (type(requested_at) is not int or requested_at <= 0 + or not 0 <= payload['observedAt'] - requested_at <= READ_WINDOW_MS + or not isinstance(reading, dict)): + raise ValueError('Unqualified device read') + return observed_at, reading + + +def qualified_reading(payload, now): + observed_at, reading = receipt_reading(payload, now) + if reading is None: + return observed_at, {metric: None for metric in ('temperature', 'humidity', 'pm25', 'airQuality')} + if set(reading) != {'temperatureC', 'humidity', 'pm25'}: + raise ValueError('Invalid monitor reading') + for metric, maximum in (('temperatureC', 60), ('humidity', 100), ('pm25', 2000)): + value = reading[metric] + if type(value) not in (int, float) or not math.isfinite(value) or not 0 <= value <= maximum: + raise ValueError('Invalid device value') + return observed_at, {'temperature': reading['temperatureC'], 'humidity': reading['humidity'], + 'pm25': reading['pm25'], 'airQuality': air_quality(reading['pm25'])} + + +def qualified_power(payload, now): + """Appliance power is the only metric permitted by this separate identity.""" + observed_at, reading = receipt_reading(payload, now, {'model'}) + if payload['model'] not in ('H712C', 'H7161'): + raise ValueError('Unqualified appliance model') + if reading is None: + return observed_at, {'power': None} + if set(reading) != {'power'} or type(reading['power']) is not int or reading['power'] not in (0, 1): + raise ValueError('Invalid appliance power') + return observed_at, {'power': reading['power']} + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_arguments, **_keywords): + raise ValueError('Controller redirect rejected') + + +def deliver(payload, config, profile='monitor'): + now = int(time.time() * 1000) + if profile == 'monitor': + observed_at, values = qualified_reading(payload, now) + model = 'H5106' + elif profile == 'appliance-power': + observed_at, values = qualified_power(payload, now) + if set(config['devices']) != {'H712C', 'H7161'}: + raise ValueError('Invalid appliance allowlist') + model = payload['model'] + config = {**config['devices'][model], 'environment': config['environment']} + else: + raise ValueError('Unsupported receiver profile') + if (set(config['sources']) != set(values) or set(config['externalIds']) != set(values) + or not isinstance(config['deviceId'], str) or not config['deviceId'] + or len(set(config['sources'].values())) != len(values) + or not all(isinstance(value, str) and value for value in config['sources'].values())): + raise ValueError('Invalid server-owned sensor mapping') + environment = private_file(Path(config['environment'])).splitlines() + tokens = [line.split('=', 1)[1] for line in environment if line.startswith('SLEEPYPOD_AUTH_TOKEN=')] + if len(tokens) != 1 or len(tokens[0]) < 32: + raise ValueError('Owner authentication unavailable') + headers = {'Authorization': 'Bearer ' + tokens[0], 'Content-Type': 'application/json'} + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + endpoint = 'http://127.0.0.1:3000/api/v1/home/bedroom' + with opener.open(urllib.request.Request(endpoint, headers=headers), timeout=5) as response: + status = strict_json(response.read(131072)) + sources = {source['id']: source for source in status['config']['sources']} + reports = [] + for metric, value in values.items(): + source_id = config['sources'][metric] + source = sources.get(source_id) + if (not source or source['model'] != model or source['metric'] != metric + or source['provider'] != 'homebridge' + or source['deviceId'] != config['deviceId'] or source['externalId'] != config['externalIds'][metric] + or source['unit'] != {'temperature': '°C', 'humidity': '%', 'pm25': 'µg/m³', + 'airQuality': 'category', 'power': 'on/off'}[metric]): + raise ValueError('Configured sensor identity changed') + reports.append({'schemaVersion': 1, 'revision': status['revision'], 'sourceId': source_id, + 'available': value is not None, 'value': value, 'observedAt': observed_at, + 'measuredAt': None, 'timeBasis': 'device_read'}) + request = urllib.request.Request(endpoint + '/reports', data=json.dumps({'reports': reports}).encode(), + headers=headers, method='POST') + with opener.open(request, timeout=5) as response: + result = strict_json(response.read(16384)) + return {'accepted': sum(item['accepted'] is True for item in result['results']), + 'submitted': len(reports), 'deviceReadOnly': True} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', required=True, type=Path) + parser.add_argument('--profile', choices=('monitor', 'appliance-power'), default='monitor') + arguments = parser.parse_args() + signal.alarm(15) + try: + if os.environ.get('SSH_ORIGINAL_COMMAND', ''): + raise ValueError('Only the forced sensor publisher is permitted') + contents = sys.stdin.buffer.read(MAX_BYTES + 1) + if len(contents) > MAX_BYTES: + raise ValueError('Read receipt too large') + result = deliver(strict_json(contents), strict_json(private_file(arguments.config)), arguments.profile) + print(json.dumps(result)) + except Exception: + print(json.dumps({'accepted': 0, 'error': 'device_read_rejected'})) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/bedroom/homebridge-cache.mjs b/scripts/bedroom/homebridge-cache.mjs new file mode 100644 index 000000000..4a551446b --- /dev/null +++ b/scripts/bedroom/homebridge-cache.mjs @@ -0,0 +1,103 @@ +/** Read-only Homebridge cache publisher. Its SSH key cannot read history or control the Pod. */ +import { readFile, writeFile, rename } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +export function cacheReadings(accessories, sources) { + return sources.map((source) => { + const accessory = accessories.find(item => item.aid === source.aid) + const characteristic = accessory?.services?.flatMap(service => service.characteristics ?? []) + .find(item => item.iid === source.iid) + let value = characteristic?.value + if (typeof value === 'boolean' && source.metric === 'power') value = Number(value) + const bounds = { temperature: [-40, 60], humidity: [0, 100], pm25: [0, 2000], + airQuality: [1, 5], power: [0, 1], fanSpeed: [0, 100], filterLife: [0, 100] }[source.metric] + if (!bounds || typeof value !== 'number' || !Number.isFinite(value) || value < bounds[0] || value > bounds[1] + || (['power', 'airQuality'].includes(source.metric) && !Number.isInteger(value)) + || (characteristic?.status !== undefined && characteristic.status !== 0)) value = null + return { sourceId: source.sourceId, value } + }) +} + +async function boundedJson(response) { + if (!response.ok) throw new Error('Bridge unavailable') + const chunks = [] + let length = 0 + for await (const chunk of response.body) { + length += chunk.length + if (length > 1048576) throw new Error('Bridge response too large') + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function publish(config, readings) { + return new Promise((resolve, reject) => { + const child = spawn(config.sshExecutable, ['-T', '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', '-o', 'LogLevel=ERROR', + '-o', `UserKnownHostsFile=${config.knownHosts}`, '-i', config.keyFile, '-p', String(config.podPort), + `${config.podUser}@${config.podHost}`], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + let output = '' + const deadline = setTimeout(() => child.kill(), 14000) + child.stdout.on('data', (chunk) => { + output += chunk.toString('utf8') + if (output.length > 16384) child.kill() + }) + child.stderr.resume() + child.stdin.on('error', () => {}) + child.on('error', (error) => { + clearTimeout(deadline) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(deadline) + try { + const result = JSON.parse(output) + if (code !== 0 || result.cachedOnly !== true) throw new Error('Publication rejected') + resolve(result) + } + catch { reject(new Error('Publication unavailable')) } + }) + child.stdin.end(JSON.stringify({ version: 1, issuedAt: Date.now(), readings })) + }) +} + +export async function cycle(config) { + let readings + try { + const response = await fetch(config.bridgeUrl + '/accessories', { + method: 'GET', redirect: 'error', signal: AbortSignal.timeout(5000), + }) + const value = await boundedJson(response) + if (!Array.isArray(value.accessories)) throw new Error('Invalid bridge response') + readings = cacheReadings(value.accessories, config.sources) + } + catch { readings = config.sources.map(source => ({ sourceId: source.sourceId, value: null })) } + return publish(config, readings) +} + +async function main() { + const filename = process.argv[2] + const config = JSON.parse(await readFile(filename, 'utf8')) + const once = process.argv.includes('--once') + do { + let status + try { + status = { ...(await cycle(config)), checkedAt: new Date().toISOString() } + } + catch { status = { error: 'publisher_unavailable', checkedAt: new Date().toISOString() } } + if (config.statusFile) { + await writeFile(config.statusFile + '.tmp', JSON.stringify(status), { mode: 0o600 }) + await rename(config.statusFile + '.tmp', config.statusFile) + } + if (once) console.log(JSON.stringify(status)) + else await new Promise(resolve => setTimeout(resolve, 15000)) + } while (!once) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(() => { + console.error('Publisher stopped; inspect the private installation.') + process.exitCode = 1 + }) +} diff --git a/scripts/bedroom/homebridge-cache.test.mjs b/scripts/bedroom/homebridge-cache.test.mjs new file mode 100644 index 000000000..ef6963ed2 --- /dev/null +++ b/scripts/bedroom/homebridge-cache.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { cacheReadings } from './homebridge-cache.mjs' + +const sources = [{ sourceId: 'temperature', aid: 1, iid: 10, metric: 'temperature' }, + { sourceId: 'quality', aid: 1, iid: 11, metric: 'airQuality' }, + { sourceId: 'power', aid: 2, iid: 12, metric: 'power' }] +test('preserves zero temperature, treats unknown air quality as unavailable, routes power', () => { + assert.deepEqual(cacheReadings([{ aid: 1, services: [{ characteristics: [ + { iid: 10, value: 0 }, { iid: 11, value: 0 }] }] }, + { aid: 2, services: [{ characteristics: [{ iid: 12, value: true }] }] }], sources), + [{ sourceId: 'temperature', value: 0 }, { sourceId: 'quality', value: null }, { sourceId: 'power', value: 1 }]) +}) +test('missing, invalid and failed characteristics withdraw their readings', () => { + for (const characteristic of [{ iid: 10, value: 21, status: -70402 }, + { iid: 10, value: '21' }, { iid: 10, value: NaN }, { iid: 10, value: 150 }, {}]) { + assert.equal(cacheReadings([{ aid: 1, services: [{ characteristics: [characteristic] }] }], sources)[0].value, null) + } + assert.deepEqual(cacheReadings([], sources).map(item => item.value), [null, null, null]) +}) diff --git a/scripts/bedroom/test_cache_ingest.py b/scripts/bedroom/test_cache_ingest.py new file mode 100644 index 000000000..f49603ba4 --- /dev/null +++ b/scripts/bedroom/test_cache_ingest.py @@ -0,0 +1,32 @@ +import unittest +from cache_ingest import readings, strict_json + + +class CacheBoundaryTests(unittest.TestCase): + def payload(self, **values): + return {'version': 1, 'issuedAt': 100000, 'readings': [{'sourceId': 'room-temp', 'value': 21}], **values} + + def test_accepts_only_allowlisted_finite_readings(self): + self.assertEqual(readings(self.payload(), ['room-temp'], 100001)[0]['value'], 21) + for item in ({'sourceId': 'other', 'value': 21}, {'sourceId': 'room-temp', 'value': True}, + {'sourceId': 'room-temp', 'value': float('nan')}, + {'sourceId': 'room-temp', 'value': 21, 'timeBasis': 'device_read'}): + with self.assertRaises(ValueError): readings(self.payload(readings=[item]), ['room-temp'], 100001) + + def test_rejects_stale_future_duplicate_and_control_payloads(self): + cases = [self.payload(issuedAt=1), self.payload(issuedAt=999999), + self.payload(readings=self.payload()['readings']*2), self.payload(action='temperature'), + self.payload(readings=[]), self.payload(version=True)] + for payload in cases: + with self.assertRaises(ValueError): readings(payload, ['room-temp'], 100001) + + def test_explicit_unavailable_is_allowed(self): + value = readings(self.payload(readings=[{'sourceId': 'room-temp', 'value': None}]), ['room-temp'], 100001) + self.assertIsNone(value[0]['value']) + + def test_json_rejects_duplicate_keys_and_nonfinite_numbers(self): + for value in ('{"version":1,"version":2}', '{"value":NaN}', '{"value":Infinity}'): + with self.assertRaises(ValueError): strict_json(value) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/bedroom/test_govee_ingest.py b/scripts/bedroom/test_govee_ingest.py new file mode 100644 index 000000000..627f1892d --- /dev/null +++ b/scripts/bedroom/test_govee_ingest.py @@ -0,0 +1,81 @@ +import json +import unittest +from unittest.mock import patch + +from govee_ingest import air_quality, deliver, qualified_reading, strict_json + + +class QualifiedReadTests(unittest.TestCase): + def payload(self): + return {'version': 1, 'requestIssuedAt': 100000, 'observedAt': 100936, + 'reading': {'temperatureC': 21.5, 'humidity': 55.9, 'pm25': 0}} + + def test_successful_device_read_preserves_clock_and_zero(self): + observed_at, values = qualified_reading(self.payload(), 101000) + self.assertEqual(observed_at, 100936) + self.assertEqual(values, {'temperature': 21.5, 'humidity': 55.9, 'pm25': 0, 'airQuality': 1}) + self.assertEqual(qualified_reading(self.payload(), 100000)[0], 100000) + + def test_expired_future_malformed_and_spoofed_clock_basis_are_rejected(self): + for extra in ({'timeBasis': 'measurement'}, {'sourceId': 'another-device'}, {'action': 'temperature'}, + {'version': True}, {'requestIssuedAt': None}, {'requestIssuedAt': 100937}, + {'requestIssuedAt': 90000}, {'observedAt': True}, {'observedAt': 1.5}): + with self.assertRaises(ValueError): qualified_reading({**self.payload(), **extra}, 101000) + for now in (90000, 115937): + with self.assertRaises(ValueError): qualified_reading(self.payload(), now) + for contents in ('{"version":1,"version":2}', '{"value":NaN}'): + with self.assertRaises(ValueError): strict_json(contents) + + def test_invalid_values_withdrawals_and_category_boundaries(self): + for metric, value in (('temperatureC', True), ('humidity', float('nan')), ('temperatureC', -1), + ('temperatureC', 61), ('humidity', 101), ('pm25', 2001), ('pm25', '0')): + payload = self.payload() + payload['reading'][metric] = value + with self.assertRaises(ValueError): qualified_reading(payload, 101000) + unavailable = {'version': 1, 'observedAt': 101000, 'requestIssuedAt': None, 'reading': None} + self.assertTrue(all(value is None for value in qualified_reading(unavailable, 101000)[1].values())) + with self.assertRaises(ValueError): qualified_reading({**unavailable, 'requestIssuedAt': 100000}, 101000) + self.assertEqual([air_quality(value) for value in (0, 12, 13, 35, 36, 75, 76, 115, 116)], + [1, 1, 2, 2, 3, 3, 4, 4, 5]) + + def test_receiver_owns_mapping_basis_endpoint_and_does_not_return_owner_credentials(self): + mapping = {metric: 'test-' + metric for metric in ('temperature', 'humidity', 'pm25', 'airQuality')} + units = {'temperature': '°C', 'humidity': '%', 'pm25': 'µg/m³', 'airQuality': 'category'} + external_ids = {metric: 'bridge/monitor/' + metric for metric in mapping} + configuration = {'sources': mapping, 'externalIds': external_ids, 'deviceId': 'test-monitor', + 'environment': '/private/test.env'} + status = {'revision': 3, 'config': {'sources': [ + {'id': mapping[metric], 'metric': metric, 'model': 'H5106', 'provider': 'homebridge', 'unit': unit, + 'deviceId': 'test-monitor', 'externalId': external_ids[metric]} + for metric, unit in units.items()]}} + requests = [] + + class Response: + def __init__(self, payload): self.payload = payload + def __enter__(self): return self + def __exit__(self, *_arguments): return False + def read(self, _limit): return json.dumps(self.payload).encode() + + class Opener: + def open(self, request, timeout): + requests.append(request) + return Response(status if len(requests) == 1 else {'results': [{'accepted': True}] * 4}) + + with patch('govee_ingest.time.time', return_value=101), \ + patch('govee_ingest.private_file', return_value='SLEEPYPOD_AUTH_TOKEN=' + 'x' * 40), \ + patch('govee_ingest.urllib.request.build_opener', return_value=Opener()): + result = deliver(self.payload(), configuration) + self.assertEqual(result, {'accepted': 4, 'submitted': 4, 'deviceReadOnly': True}) + self.assertEqual(requests[-1].full_url, 'http://127.0.0.1:3000/api/v1/home/bedroom/reports') + reports = json.loads(requests[-1].data)['reports'] + self.assertEqual({report['sourceId'] for report in reports}, set(mapping.values())) + self.assertTrue(all(report['timeBasis'] == 'device_read' and report['measuredAt'] is None + and report['observedAt'] == 100936 and report['revision'] == 3 for report in reports)) + requests.clear() + status['config']['sources'][0]['model'] = 'another-model' + with self.assertRaises(ValueError): deliver(self.payload(), configuration) + self.assertEqual(len(requests), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/bedroom/test_govee_power_ingest.py b/scripts/bedroom/test_govee_power_ingest.py new file mode 100644 index 000000000..bec6ecf79 --- /dev/null +++ b/scripts/bedroom/test_govee_power_ingest.py @@ -0,0 +1,78 @@ +import json +import unittest +from unittest.mock import patch + +from govee_ingest import deliver, qualified_power, qualified_reading + + +class PowerReadTests(unittest.TestCase): + def payload(self, model='H712C', power=0): + return {'version': 1, 'model': model, 'requestIssuedAt': 100000, 'observedAt': 100400, + 'reading': {'power': power}} + + def test_off_and_on_are_device_reads_but_not_measurement_timestamps(self): + for model in ('H712C', 'H7161'): + for power in (0, 1): + self.assertEqual(qualified_power(self.payload(model, power), 101000), (100400, {'power': power})) + self.assertEqual(qualified_power(self.payload(), 100000)[0], 100000) + unavailable = {**self.payload(), 'reading': None, 'requestIssuedAt': None} + self.assertEqual(qualified_power(unavailable, 101000), (100400, {'power': None})) + + def test_receipts_cannot_choose_other_sources_metrics_or_clock_basis(self): + for extra in ({'model': 'H5106'}, {'model': 'H7126'}, {'sourceId': 'another-source'}, + {'timeBasis': 'measurement'}, {'action': 'power'}, {'observedAt': True}, + {'requestIssuedAt': None}, {'requestIssuedAt': 90000}): + with self.assertRaises(ValueError): qualified_power({**self.payload(), **extra}, 101000) + for reading in ({'power': True}, {'power': -1}, {'power': 2}, {'power': 0.0}, {'power': '0'}, + {'power': 0, 'fanSpeed': 66}, {'temperatureC': 22}, {}): + with self.assertRaises(ValueError): qualified_power({**self.payload(), 'reading': reading}, 101000) + for now in (99000, 115401): + with self.assertRaises(ValueError): qualified_power(self.payload(), now) + with self.assertRaises(ValueError): qualified_reading(self.payload(), 101000) + + def test_receiver_is_limited_to_two_fixed_power_sources_and_verifies_the_live_mapping(self): + devices = {model: {'deviceId': 'test-' + model, 'sources': {'power': model + '-power'}, + 'externalIds': {'power': model + '/power'}} for model in ('H712C', 'H7161')} + config = {'environment': '/private/test.env', 'devices': devices} + status = {'revision': 7, 'config': {'sources': [ + {'id': model + '-power', 'model': model, 'metric': 'power', 'provider': 'homebridge', + 'deviceId': 'test-' + model, 'externalId': model + '/power', 'unit': 'on/off'} + for model in devices]}} + requests = [] + + class Response: + def __init__(self, payload): self.payload = payload + def __enter__(self): return self + def __exit__(self, *_arguments): return False + def read(self, _limit): return json.dumps(self.payload).encode() + + class Opener: + def open(self, request, timeout): + requests.append(request) + return Response(status if request.data is None else {'results': [{'accepted': True}]}) + + with patch('govee_ingest.time.time', return_value=101), \ + patch('govee_ingest.private_file', return_value='SLEEPYPOD_AUTH_TOKEN=' + 'x' * 40), \ + patch('govee_ingest.urllib.request.build_opener', return_value=Opener()): + for model in devices: + requests.clear() + result = deliver(self.payload(model), config, 'appliance-power') + self.assertEqual(result, {'accepted': 1, 'submitted': 1, 'deviceReadOnly': True}) + reports = json.loads(requests[-1].data)['reports'] + self.assertEqual(reports, [{'schemaVersion': 1, 'revision': 7, 'sourceId': model + '-power', + 'available': True, 'value': 0, 'observedAt': 100400, + 'measuredAt': None, 'timeBasis': 'device_read'}]) + for field, value in (('model', 'H5106'), ('metric', 'fanSpeed'), ('provider', 'apple_home'), + ('deviceId', 'other'), ('externalId', 'other/power'), ('unit', '%')): + original = status['config']['sources'][0][field] + status['config']['sources'][0][field] = value + requests.clear() + with self.assertRaises(ValueError): deliver(self.payload(), config, 'appliance-power') + self.assertEqual(len(requests), 1) + status['config']['sources'][0][field] = original + with self.assertRaises(ValueError): deliver(self.payload(), config) + with self.assertRaises(ValueError): deliver(self.payload(), {**config, 'devices': {'H712C': devices['H712C']}}, 'appliance-power') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/bin/sp-update b/scripts/bin/sp-update index 995eb1a06..9ff99efef 100644 --- a/scripts/bin/sp-update +++ b/scripts/bin/sp-update @@ -23,7 +23,10 @@ if [ -r /etc/sleepypod/data-dir ] && [ -s /etc/sleepypod/data-dir ]; then else DATA_DIR="/persistent/sleepypod-data" fi -GITHUB_REPO="${SLEEPYPOD_GITHUB_REPO:-sleepypod/core}" +REPOSITORY_FILE="/etc/sleepypod/github-repo" +GITHUB_REPO="${SLEEPYPOD_GITHUB_REPO:-}" +SAVED_REPOSITORY="" +REPOSITORY_CHANGED=false BRANCH=main ARCHIVE="" usage() { @@ -33,7 +36,9 @@ Usage: sp-update [branch] Downloads a pre-built GitHub release; never builds Next.js on the Pod. For a cloned repo on your computer, run: ./scripts/deploy POD_IP [branch] -SLEEPYPOD_GITHUB_REPO selects the release repository (default: sleepypod/core). +The release repository is pinned in /etc/sleepypod/github-repo by installation. +SLEEPYPOD_GITHUB_REPO explicitly selects and persists a different repository. +An unpinned installation must select OWNER/REPO before downloading an update. TMPDIR selects temporary storage (default: /persistent). EOF } @@ -56,6 +61,45 @@ if [ ! -d "$INSTALL_DIR" ] || ! command -v systemctl >/dev/null; then echo "Error: sp-update runs on an installed Pod. On your computer use ./scripts/deploy POD_IP [branch]." >&2 exit 1 fi +# sudo intentionally need not preserve the app's environment. The installation +# owns the source pin; missing or malformed metadata must never select upstream. +if [ -e "$REPOSITORY_FILE" ] || [ -L "$REPOSITORY_FILE" ]; then + if [ ! -f "$REPOSITORY_FILE" ] || [ -L "$REPOSITORY_FILE" ] || [ ! -r "$REPOSITORY_FILE" ]; then + echo "Error: Update repository metadata must be a readable regular file: $REPOSITORY_FILE" >&2 + exit 1 + fi + if [ "$(wc -c < "$REPOSITORY_FILE")" -gt 256 ]; then + echo "Error: Update repository metadata is too large." >&2 + exit 1 + fi + SAVED_REPOSITORY="$(cat "$REPOSITORY_FILE")" + if [[ ! "$SAVED_REPOSITORY" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$ ]] || [[ "${SAVED_REPOSITORY##*/}" = . || "${SAVED_REPOSITORY##*/}" = .. ]]; then + echo "Error: Invalid update repository metadata in $REPOSITORY_FILE; expected OWNER/REPO." >&2 + exit 1 + fi +fi +GITHUB_REPO="${GITHUB_REPO:-$SAVED_REPOSITORY}" +if [ -n "$GITHUB_REPO" ] && { [[ ! "$GITHUB_REPO" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$ ]] || [[ "${GITHUB_REPO##*/}" = . || "${GITHUB_REPO##*/}" = .. ]] || [ "${#GITHUB_REPO}" -gt 255 ]; }; then + echo "Error: SLEEPYPOD_GITHUB_REPO must be OWNER/REPO." >&2 + exit 1 +fi +if [ -z "$ARCHIVE" ] && [ -z "$GITHUB_REPO" ]; then + echo "Error: No update repository is pinned. Select the installed fork explicitly with sudo env SLEEPYPOD_GITHUB_REPO=OWNER/REPO sp-update." >&2 + exit 1 +fi + +# Replace a complete metadata file, keeping the old source intact on failure. +write_repository() { + local repository="$1" directory temporary + directory="$(dirname "$REPOSITORY_FILE")" + mkdir -p "$directory" || return 1 + temporary="$(mktemp "$directory/.github-repo.XXXXXX")" || return 1 + if printf '%s\n' "$repository" > "$temporary" && chmod 0644 "$temporary" && mv -f "$temporary" "$REPOSITORY_FILE"; then + return 0 + fi + rm -f "$temporary" + return 1 +} # Existing installations may point at a release via a symlink. find does # not descend through a command-line symlink by default; resolve it so stale # .next/standalone files are removed rather than silently booting old code. @@ -173,6 +217,13 @@ WORK_DIR="" BACKUP_DIR="" cleanup() { local exit_code=$? + if [ "$exit_code" -ne 0 ] && [ "$REPOSITORY_CHANGED" = true ]; then + if [ -n "$SAVED_REPOSITORY" ]; then + write_repository "$SAVED_REPOSITORY" || echo "Critical: could not restore update repository metadata." >&2 + else + rm -f "$REPOSITORY_FILE" + fi + fi if [ "$WAN_WAS_BLOCKED" = true ]; then reload_iptables_helpers if ! restore_wan; then @@ -639,6 +690,13 @@ if [ "$WAN_WAS_BLOCKED" = true ]; then WAN_WAS_BLOCKED=false fi +# Commit the selected source with the new code; cleanup restores it on failure. +# Archives without an explicit repository retain the installation's source. +if [ -n "$GITHUB_REPO" ] && [ "$GITHUB_REPO" != "$SAVED_REPOSITORY" ]; then + REPOSITORY_CHANGED=true + write_repository "$GITHUB_REPO" +fi + echo "Starting service..." systemctl start sleepypod.service diff --git a/scripts/build/personal-workspace.mjs b/scripts/build/personal-workspace.mjs new file mode 100644 index 000000000..35eff738a --- /dev/null +++ b/scripts/build/personal-workspace.mjs @@ -0,0 +1,14 @@ +import { build } from 'esbuild' +// A separate browser graph prevents personal sessions from mounting owner clients. +const result = await build({ entryPoints: ['src/household/web/entry.tsx'], outfile: 'public/household/personal-runtime.js', + bundle: true, platform: 'browser', format: 'esm', target: ['safari16', 'chrome110'], jsx: 'automatic', minify: true, + define: { 'process.env.NODE_ENV': '"production"' }, metafile: true, write: false, +}) +const forbidden = Object.keys(result.metafile.inputs).filter(path => /src\/(hardware|db|streaming|homekit)\//.test(path) + || (/src\/server\//.test(path) && !path.endsWith('src/server/validation-schemas.ts'))) +if (forbidden.length) + throw new Error(`Personal browser bundle contains forbidden server modules: ${forbidden.join(', ')}`) +const { writeFile } = await import('node:fs/promises') +for (const output of result.outputFiles) + await writeFile(output.path, output.contents) +console.log(`Personal browser workspace: ${result.outputFiles[0].contents.length} bytes, isolated graph checked.`) diff --git a/scripts/deploy b/scripts/deploy index a840dcbaf..2b797735f 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -39,7 +39,7 @@ POD_IP="${POSITIONAL[0]}" BRANCH="${POSITIONAL[1]:-}" FETCH_REPO=origin if [ -n "$GITHUB_REPO" ]; then - if [[ ! "$GITHUB_REPO" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]] || [ -z "$BRANCH" ]; then + if [[ ! "$GITHUB_REPO" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$ ]] || [[ "${GITHUB_REPO##*/}" = . || "${GITHUB_REPO##*/}" = .. ]] || [ "${#GITHUB_REPO}" -gt 255 ] || [ -z "$BRANCH" ]; then echo "Error: An explicit repository requires OWNER/REPO and a branch." >&2 exit 1 fi @@ -52,6 +52,19 @@ if [[ ! "$POD_IP" =~ ^[a-zA-Z0-9][a-zA-Z0-9.:-]*$ ]] || [[ ! "$SSH_PORT" =~ ^[0- fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +# Carry the known checkout source into the successful install, including when +# sudo later starts an update without this workstation's environment. +UPDATE_REPOSITORY="$GITHUB_REPO" +if [ -z "$UPDATE_REPOSITORY" ]; then + origin_url="$(git -C "$PROJECT_DIR" remote get-url origin 2>/dev/null || true)" + case "$origin_url" in + https://github.com/*) UPDATE_REPOSITORY="${origin_url#https://github.com/}" ;; + git@github.com:*) UPDATE_REPOSITORY="${origin_url#git@github.com:}" ;; + ssh://git@github.com/*) UPDATE_REPOSITORY="${origin_url#ssh://git@github.com/}" ;; + esac + UPDATE_REPOSITORY="${UPDATE_REPOSITORY%.git}" + if [[ ! "$UPDATE_REPOSITORY" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$ ]] || [[ "${UPDATE_REPOSITORY##*/}" = . || "${UPDATE_REPOSITORY##*/}" = .. ]] || [ "${#UPDATE_REPOSITORY}" -gt 255 ]; then UPDATE_REPOSITORY=""; fi +fi SSH=(ssh -p "$SSH_PORT" -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=30 "root@$POD_IP") LOCAL_STAGE=$(mktemp -d "${TMPDIR:-/tmp}/sleepypod-deploy.XXXXXX") REMOTE_STAGE="" @@ -139,5 +152,9 @@ fi "${SSH[@]}" "cat > '$REMOTE_STAGE/sleepypod-core.tar.gz'" < "$LOCAL_STAGE/sleepypod-core.tar.gz" "${SSH[@]}" "cat > '$REMOTE_STAGE/sp-update'" < "$SCRIPT_DIR/bin/sp-update" echo "Build uploaded. Applying update on the Pod..." -"${SSH[@]}" "bash '$REMOTE_STAGE/sp-update' --archive '$REMOTE_STAGE/sleepypod-core.tar.gz'" +if [ -n "$UPDATE_REPOSITORY" ]; then + "${SSH[@]}" "env SLEEPYPOD_GITHUB_REPO='$UPDATE_REPOSITORY' bash '$REMOTE_STAGE/sp-update' --archive '$REMOTE_STAGE/sleepypod-core.tar.gz'" +else + "${SSH[@]}" "bash '$REMOTE_STAGE/sp-update' --archive '$REMOTE_STAGE/sleepypod-core.tar.gz'" +fi echo "Deploy complete. Web UI: http://$POD_IP:3000/" diff --git a/scripts/deployment/activate.py b/scripts/deployment/activate.py new file mode 100644 index 000000000..a900eb844 --- /dev/null +++ b/scripts/deployment/activate.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Activate a trialed imported controller, or restore its retained Free Sleep services.""" +import argparse +import fcntl +import json +import os +from pathlib import Path +import signal +import stat +import time + +import activation_checks +import handoff +from service_plan import CORE_UNIT, CORE_UNITS, LEGACY_UNITS, read_plan + +FORMAT = 'sleepypod-activation/1' + + +def read_handoff(directory, plan): + journal = json.loads((directory / 'handoff.json').read_text()) + if (journal.get('format') != 'sleepypod-handoff/1' or journal.get('unitHashes') != plan['units'] + or set(journal.get('legacy', {})) != set(LEGACY_UNITS)): + raise ValueError('Handoff journal does not belong to this plan') + if any(saved['state'].get('UnitFileState') != 'enabled' for saved in journal['legacy'].values()): + raise ValueError('Expected the original enabled Free Sleep installation') + return journal + + +def validate_legacy_definitions(journal): + # Enablement intentionally changes here; executable definitions must never change. + for name, saved in journal['legacy'].items(): + state = handoff.unit_state(name) + if state.get('LoadState') != 'loaded' or handoff.unit_hashes(state) != saved['hashes']: + raise ValueError('Retained controller configuration changed: ' + name) + + +def read_activation(directory, plan): + record = json.loads((directory / 'activation.json').read_text()) + if record.get('format') != FORMAT or record.get('unitHashes') != plan['units']: + raise ValueError('Activation journal does not belong to this plan') + return record + + +def phase(directory, record, value): + record.update(phase=value, updatedAt=time.time()) + handoff.private_json(directory / 'activation.json', record) + + +def assert_ownership(core): + active, inactive = (CORE_UNITS, LEGACY_UNITS) if core else (LEGACY_UNITS, CORE_UNITS) + for name in active: + state = handoff.unit_state(name) + if state.get('ActiveState') != 'active' or state.get('MainPID') in (None, '0'): + raise RuntimeError('Required controller or sensor service is not active: ' + name) + for name in inactive: + if not handoff.stopped(handoff.unit_state(name)): + raise RuntimeError('Competing controller process is present: ' + name) + + +def assert_boot_selection(core): + for name in (*CORE_UNITS, *LEGACY_UNITS): + enabled = name == CORE_UNIT if core else name in LEGACY_UNITS + expected = 'enabled' if enabled else 'disabled' + if handoff.unit_state(name).get('UnitFileState') != expected: + raise RuntimeError('Boot selection did not persist: ' + name) + + +def settle(token, revision, seconds, observe, sleep, monotonic): + """Require all processors and a continuous 30-second fresh, idle hardware window.""" + deadline = monotonic() + seconds + fresh_since = None + while monotonic() < deadline: + assert_ownership(core=True) + try: + observation = observe(token, revision) + ready = (observation['device']['freshness'] == 'fresh' + and observation['temperatureRuntime'] == 'ready') + except (OSError, ValueError, KeyError): + ready = False + if ready: + # Unexpected thermal activity fails immediately; it is not a transient readiness error. + handoff.require_core_thermal_idle(token, observation) + observed_at = monotonic() + if fresh_since is None: + fresh_since = observed_at + if observed_at - fresh_since >= 30: + return observation + else: + fresh_since = None + sleep(min(2, max(0, deadline - monotonic()))) + raise RuntimeError('Core did not maintain fresh, idle telemetry for 30 seconds') + + +def restore(directory, plan, saved, record, sleep=time.sleep, monotonic=time.monotonic): + """Restore service ownership and boot selection, preserving both sets of databases.""" + handoff.require_legacy_recovery_data(Path(plan['config']['data'])) + record.update(bootOwner='unknown', activeOwner='transitioning') + phase(directory, record, 'restoring_legacy') + handoff.validate_installed(plan) + handoff.stop_units(CORE_UNITS) + handoff.require_legacy_recovery_data(Path(plan['config']['data'])) + validate_legacy_definitions(saved) + handoff.systemctl('disable', *CORE_UNITS) + handoff.systemctl('enable', *LEGACY_UNITS) + assert_boot_selection(core=False) + handoff.systemctl('start', *LEGACY_UNITS) + assert_ownership(core=False) + record.update(bootOwner='free-sleep', activeOwner='free-sleep', physicalOperationVerified=False, + restoredIdleVerified=False) + # An ordinary rollback can occur during a user's live heating session; do not cancel it. + if record.get('requireIdleOnRestore') is True: + record['restoredIdleVerified'] = handoff.restored_idle(sleep, monotonic) + phase(directory, record, 'rolled_back') + return record + + +def activate(directory, legacy, seconds=120, observe=handoff.core_observation, + sleep=time.sleep, monotonic=time.monotonic): + if not 30 <= seconds <= 300: + raise ValueError('Activation deadline must be 30–300 seconds') + plan = read_plan(directory) + saved = read_handoff(directory, plan) + path = directory / 'activation.json' + if path.exists() or path.is_symlink(): + raise ValueError('An activation journal exists; inspect it and use rollback for recovery') + handoff.validate_installed(plan) + handoff.validate_legacy(saved) + revision = handoff.prerequisites(plan) + activation_checks.require_trial(saved, revision, time.time()) + data = Path(plan['config']['data']) + activation_checks.candidate_ready(data) + hashes = activation_checks.unchanged_source(data, legacy) + token = handoff.read_owner_key(Path(plan['config']['environment'])) + handoff.require_thermal_idle(handoff.local_json('/api/deviceStatus')) + record = {'format': FORMAT, 'unitHashes': plan['units'], 'sourceRevision': revision, + 'legacyData': str(legacy), 'retainedConfigurationHashes': hashes, + 'createdAt': time.time(), 'phase': 'prepared', 'activeOwner': 'free-sleep', + 'bootOwner': 'free-sleep', 'physicalOperationVerified': False, + 'requireIdleOnRestore': True} + record['activeOwner'] = 'transitioning' + phase(directory, record, 'stopping_legacy') + try: + handoff.stop_units(LEGACY_UNITS) + if activation_checks.unchanged_source(data, legacy) != hashes: + raise ValueError('Retained configuration changed during handoff') + activation_checks.candidate_ready(data) + phase(directory, record, 'starting_core') + handoff.systemctl('start', CORE_UNIT) + observation = settle(token, revision, seconds, observe, sleep, monotonic) + record['observation'] = observation + record['activeOwner'] = 'sleepypod-core' + # Service ownership is verified before and after the non-atomic boot-selection writes. + record['bootOwner'] = 'unknown' + phase(directory, record, 'selecting_boot_owner') + handoff.validate_installed(plan) + validate_legacy_definitions(saved) + handoff.systemctl('disable', *LEGACY_UNITS) + handoff.systemctl('enable', CORE_UNIT) + assert_boot_selection(core=True) + assert_ownership(core=True) + observation = observe(token, revision) + if observation['device']['freshness'] != 'fresh' or observation['temperatureRuntime'] != 'ready': + raise RuntimeError('Core readiness changed while selecting the boot owner') + handoff.require_core_thermal_idle(token, observation) + record['observation'] = observation + record['bootOwner'] = 'sleepypod-core' + record['requireIdleOnRestore'] = False + phase(directory, record, 'active') + return record + except BaseException: + record['phaseBeforeRestore'] = record['phase'] + try: + restore(directory, plan, saved, record, sleep, monotonic) + except BaseException: + phase(directory, record, 'recovery_required') + raise + raise + + +def rollback(directory): + plan = read_plan(directory) + saved = read_handoff(directory, plan) + record = read_activation(directory, plan) + record['phaseBeforeRestore'] = record['phase'] + try: + return restore(directory, plan, saved, record) + except BaseException: + phase(directory, record, 'recovery_required') + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('activate', 'rollback', 'status')) + parser.add_argument('--plan', required=True, type=Path) + parser.add_argument('--legacy-data', type=Path) + parser.add_argument('--seconds', type=int, default=120) + args = parser.parse_args() + if args.action == 'activate' and args.legacy_data is None: + parser.error('Activation requires --legacy-data pointing to the retained live data') + if os.geteuid() != 0: + raise ValueError('Run the reviewed activation on the Pod as root') + info = args.plan.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise ValueError('The plan directory must be root-owned and private') + os.umask(0o077) + for event in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(event, handoff.interrupt_trial) + descriptor = os.open(args.plan / 'handoff.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + if args.action == 'activate': + result = activate(args.plan, args.legacy_data, args.seconds) + elif args.action == 'rollback': + result = rollback(args.plan) + else: + result = read_activation(args.plan, read_plan(args.plan)) + result = {**result, 'services': {name: handoff.unit_state(name) for name in (*CORE_UNITS, *LEGACY_UNITS)}} + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/activation_checks.py b/scripts/deployment/activation_checks.py new file mode 100644 index 000000000..2dc82b15c --- /dev/null +++ b/scripts/deployment/activation_checks.py @@ -0,0 +1,149 @@ +"""Read-only gates for the first permanent activation of an imported controller.""" +from contextlib import closing +import hashlib +import json +import math +import re +import sqlite3 +import stat + + +SOURCE_FILES = { + 'lowdb/settingsDB.json': True, + 'lowdb/schedulesDB.json': True, + 'left_cap_baseline.json': False, + 'right_cap_baseline.json': False, + 'analytics/comfort.json': False, + 'analytics/journal.json': False, +} + +HISTORY_DATABASE = 'free-sleep-history.db' + + +def database_checksum(path): + """Hash snapshots in bounded memory on the Pod rather than loading a full history file.""" + checksum = hashlib.sha256() + with path.open('rb') as source: + for chunk in iter(lambda: source.read(1024 * 1024), b''): + checksum.update(chunk) + return checksum.hexdigest() + + +def require_imported_history(data, report): + """Reject partial or mismatched promotions before any retained service is stopped.""" + metadata = report.get('importedHistory') + hashes = report.get('databaseHashes') + if (not isinstance(metadata, dict) or metadata.get('format') != 'sleepypod-imported-history/1' + or metadata.get('source') != 'free-sleep' or not isinstance(hashes, dict)): + raise ValueError('Imported history evidence is missing; reimport before activation') + expected = hashes.get(HISTORY_DATABASE) + source_hash = metadata.get('sourceSha256') + if any(not isinstance(value, str) or not re.fullmatch('[a-f0-9]{64}', value) + for value in (expected, source_hash)): + raise ValueError('Imported history checksums are missing or invalid') + history = data / HISTORY_DATABASE + original = data / 'legacy/free-sleep.db' + if (data.is_symlink() or (data / 'legacy').is_symlink() + or any(path.is_symlink() or not path.is_file() for path in (history, original))): + raise ValueError('Expected regular imported history and preserved original database files') + for suffix in ('-wal', '-shm', '-journal'): + sidecar = history.with_name(history.name + suffix) + if sidecar.exists() or sidecar.is_symlink(): + raise ValueError('Imported history must be a complete standalone snapshot without SQLite sidecars') + if database_checksum(history) != expected or database_checksum(original) != source_hash: + raise ValueError('Imported history differs from its report or preserved original database') + try: + with closing(sqlite3.connect(history.resolve().as_uri() + '?mode=ro&immutable=1', uri=True)) as database: + if (database.execute('PRAGMA integrity_check').fetchall() != [('ok',)] + or database.execute('PRAGMA foreign_key_check').fetchall() + or database.execute('PRAGMA journal_mode').fetchone() != ('delete',)): + raise ValueError('Imported history integrity or standalone snapshot check failed') + rows = database.execute('SELECT id, document FROM imported_history_metadata').fetchall() + if len(rows) != 1 or rows[0][0] != 1 or json.loads(rows[0][1]) != metadata: + raise ValueError('Imported history metadata differs from its report') + except (sqlite3.Error, json.JSONDecodeError, TypeError) as error: + raise ValueError('Imported history database or metadata cannot be read') from error + + +def source_digest(root, filename, required): + path = root / filename + for candidate in (root, *(root / parent for parent in path.relative_to(root).parents)): + if candidate.is_symlink(): + raise ValueError('Retained source contains a symlink') + if not path.exists() and not path.is_symlink() and not required: + return None + if not stat.S_ISREG(path.lstat().st_mode): + raise ValueError('Retained source must be a regular file: ' + filename) + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def unchanged_source(data, legacy): + """Compare configuration twice around stopping writers; never overwrite new user edits.""" + if not legacy.is_absolute() or not legacy.is_dir(): + raise ValueError('Use the absolute existing retained data directory') + retained = data / 'legacy' + if (data.resolve() == legacy.resolve() or legacy.resolve() in data.resolve().parents + or data.resolve() in legacy.resolve().parents): + raise ValueError('Core and retained data must be separate') + hashes = {} + for filename, required in SOURCE_FILES.items(): + expected = source_digest(retained, filename, required) + actual = source_digest(legacy, filename, required) + if expected != actual: + raise ValueError('Retained configuration changed since import: ' + filename) + hashes[filename] = expected + return hashes + + +def require_trial(journal, revision, now): + """An old API-only trial or a future/stale receipt cannot authorize activation.""" + ended = journal.get('trialEndedAt') + started = journal.get('trialStartedAt') + if any(type(value) not in (int, float) or not math.isfinite(value) for value in (started, ended)): + raise ValueError('A dated successful idle trial is required') + if not 0 <= now - ended <= 24 * 60 * 60 or not 0 <= ended - started <= 600: + raise ValueError('Repeat the idle trial; its receipt is stale or has invalid dates') + observation = journal.get('observation') or {} + observed_revision = observation.get('controller', {}).get('revision') + if not isinstance(observed_revision, str) or len(observed_revision) < 7 or not revision.startswith(observed_revision): + raise ValueError('Trial did not observe this release') + settle = journal.get('settleSeconds') + if (journal.get('phase') != 'trial_restored' or journal.get('trialSucceeded') is not True + or journal.get('requireIdle') is not True or journal.get('restoredIdleVerified') is not True + or type(settle) not in (int, float) or not 30 <= settle <= 60 + or observation.get('device', {}).get('freshness') != 'fresh' + or observation.get('temperatureRuntime') != 'ready'): + raise ValueError('A restored idle trial with at least 30 settled seconds is required') + + +def candidate_ready(data): + """Keep first activation passive apart from the explicitly imported daily routines.""" + report = json.loads((data / 'migration-report.json').read_text()) + if not isinstance(report, dict) or report.get('format') != 'sleepypod-free-sleep-import/1': + raise ValueError('Expected a separately imported candidate') + require_imported_history(data, report) + for filename in ('sleepypod.db', 'biometrics.db'): + path = data / filename + if path.is_symlink() or not path.is_file(): + raise ValueError('Expected a regular candidate database') + with closing(sqlite3.connect(path.resolve().as_uri() + '?mode=ro', uri=True)) as database: + if database.execute('PRAGMA integrity_check').fetchall() != [('ok',)] or database.execute('PRAGMA foreign_key_check').fetchall(): + raise ValueError('Candidate database integrity check failed') + if filename != 'sleepypod.db': + continue + flags = database.execute('''SELECT autopilot_enabled, prime_pod_daily, reboot_daily, + homekit_enabled, mqtt_enabled, pump_stall_protection_enabled, + pump_stall_auto_recovery_enabled FROM device_settings''').fetchall() + if flags != [(0, 0, 0, 0, 0, 0, 0)]: + raise ValueError('Initial activation requires automation, integrations and automatic maintenance off') + sides = database.execute('SELECT side, always_on, auto_on_enabled, auto_off_enabled FROM side_settings ORDER BY side').fetchall() + if sides != [('left', 0, 0, 0), ('right', 0, 0, 0)]: + raise ValueError('Initial activation requires presence-based and always-on behavior off') + comfort = database.execute('SELECT side,state FROM comfort_states ORDER BY side').fetchall() + if [row[0] for row in comfort] != ['left', 'right'] or any(json.loads(row[1]).get('config', {}).get('mode') not in ('off', 'preview') for row in comfort): + raise ValueError('Personal comfort must start Off or Preview on both sides') + for query in ("SELECT COUNT(*) FROM base_schedules WHERE enabled != 0", + "SELECT COUNT(*) FROM run_once_sessions WHERE status = 'active'", + "SELECT COUNT(*) FROM automations WHERE enabled != 0"): + if database.execute(query).fetchone()[0] != 0: + raise ValueError('Active base schedules, temporary sessions or house rules require separate review') diff --git a/scripts/deployment/handoff.py b/scripts/deployment/handoff.py new file mode 100644 index 000000000..4f9a54e21 --- /dev/null +++ b/scripts/deployment/handoff.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +"""Install reviewed service files and rehearse a bounded Core-to-Free-Sleep round trip.""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import signal +import stat +import subprocess +import time +import urllib.error +import urllib.request +import maintenance_status +from recovery_privacy import require_legacy_recovery_data + +from service_plan import CORE_UNIT, CORE_UNITS, LEGACY_UNITS, read_plan + +UNIT_DIRECTORY = Path('/etc/systemd/system') + + +def private_json(path, value): + temporary = path.with_suffix('.tmp') + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, 'w') as output: + json.dump(value, output, indent=2) + output.write('\n') + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, path) + descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + temporary.unlink(missing_ok=True) + + +def systemctl(*arguments): + return subprocess.check_output(['systemctl', *arguments], text=True, stderr=subprocess.PIPE, timeout=45).strip() + + +def unit_state(name): + output = systemctl('show', name, '-p', 'LoadState', '-p', 'ActiveState', '-p', 'MainPID', + '-p', 'UnitFileState', '-p', 'FragmentPath', '-p', 'DropInPaths', '-p', 'KillMode') + return dict(line.split('=', 1) for line in output.splitlines() if '=' in line) + + +def stopped(state): + return state['ActiveState'] in ('inactive', 'failed') and state['MainPID'] == '0' + + +def stop_units(names): + """Never allow the other controller to start until every outgoing process is gone.""" + systemctl('stop', *names) + if not all(stopped(unit_state(name)) for name in names): + raise RuntimeError('Outgoing controller processes are still present') + + +def unit_hashes(state): + paths = [state.get('FragmentPath', '')] + state.get('DropInPaths', '').split() + return {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in paths if path} + + +def validate_installed(plan): + for name, digest in plan['units'].items(): + path = UNIT_DIRECTORY / name + state = unit_state(name) + if path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != digest: + raise ValueError('Installed service changed: ' + name) + if state.get('DropInPaths') or state.get('FragmentPath') != str(path): + raise ValueError('Unexpected service override: ' + name) + + +def validate_legacy(journal): + for name, saved in journal['legacy'].items(): + state = unit_state(name) + if state.get('UnitFileState') != saved['state']['UnitFileState'] or unit_hashes(state) != saved['hashes']: + raise ValueError('Retained controller configuration changed: ' + name) + + +def read_owner_key(path): + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise ValueError('Credential environment must be a root-owned private regular file') + lines = [line[len('SLEEPYPOD_AUTH_TOKEN='):] for line in path.read_text().splitlines() + if line.startswith('SLEEPYPOD_AUTH_TOKEN=')] + if len(lines) != 1 or not all(character.isascii() and (character.isalnum() or character in '_-') for character in lines[0]) or len(lines[0]) < 32: + raise ValueError('Expected one unquoted randomly generated owner key') + return lines[0] + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + raise ValueError('Unexpected redirect from local controller') + + +def local_json(path, token=None): + headers = {'Authorization': 'Bearer ' + token} if token else {} + request = urllib.request.Request('http://127.0.0.1:3000' + path, headers=headers) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + with opener.open(request, timeout=5) as response: + return json.loads(response.read(1024 * 1024)) + + +def require_thermal_idle(status, core=False): + """A retained target is allowed; a live countdown or priming is not idle.""" + if status.get('isPriming') is not False: + raise RuntimeError('Idle trial requires priming to remain off') + for side in ('left', 'right'): + state = status.get(side + 'Side' if core else side, {}) + duration = state.get('heatingDuration' if core else 'secondsRemaining') + if type(duration) not in (int, float) or duration != 0: + raise RuntimeError('Idle trial detected a nonzero or unavailable heating countdown') + if not core and (state.get('isOn') is not False or state.get('isAlarmVibrating') is not False): + raise RuntimeError('Retained controller is not idle') + + +def restored_idle(sleep=time.sleep, monotonic=time.monotonic): + deadline = monotonic() + 45 + while True: + try: + value = local_json('/api/deviceStatus') + except (OSError, ValueError): + if monotonic() >= deadline: + raise RuntimeError('Retained controller API did not recover') + sleep(2) + continue + require_thermal_idle(value) + return True + + +def core_observation(token, expected_revision): + try: + value = local_json(maintenance_status.PATH, token) + except urllib.error.HTTPError as error: + # Only an older release without this route may use the legacy owner API. + # Authentication failures, broken storage, redirects and malformed data + # never authorize fallback to a broader surface. + if error.code != 404: + raise + else: + return maintenance_status.parse(value, expected_revision) + value = local_json('/api/v1/capabilities', token) + if value.get('controller', {}).get('id') != 'sleepypod-core': + raise ValueError('Unexpected controller response') + revision = value['controller'].get('revision') + if not isinstance(revision, str) or len(revision) < 7 or not expected_revision.startswith(revision): + raise ValueError('Controller revision differs from the staged release') + return {'controller': value['controller'], 'device': value['observations']['device'], + 'temperatureRuntime': value['features']['temperatureControl']['runtime']} + + +def require_core_thermal_idle(token, observation): + if 'maintenanceProtocol' in observation: + maintenance_status.require_idle(observation) + else: + require_thermal_idle(local_json('/api/device/status', token), core=True) + + +def prerequisites(plan): + config = plan['config'] + require_legacy_recovery_data(Path(config['data'])) + if not stat.S_ISSOCK(Path(config['dacSocket']).stat().st_mode): + raise ValueError('The inspected firmware socket is absent') + manifest = json.loads((Path(config['release']) / 'bundle-manifest.json').read_text()) + if manifest.get('format') != 'sleepypod-offline-runtime/1': + raise ValueError('Missing reviewed offline runtime manifest') + revision = manifest.get('sourceCommit', '') + if len(revision) != 40 or any(character not in '0123456789abcdef' for character in revision): + raise ValueError('Missing full source revision') + read_owner_key(Path(config['environment'])) + for filename in ('sleepypod.db', 'biometrics.db'): + path = Path(config['data']) / filename + if path.is_symlink() or not path.is_file(): + raise ValueError('Missing separately imported database') + for name in CORE_UNITS: + state = unit_state(name) + if not stopped(state): + raise ValueError('Core must be stopped before a trial') + if state['UnitFileState'] != 'disabled': + raise ValueError('Trial services must remain disabled at boot') + for name in LEGACY_UNITS: + state = unit_state(name) + if state['ActiveState'] != 'active' or state['MainPID'] == '0' or state['UnitFileState'] != 'enabled': + raise ValueError('The retained controller must be active and enabled before a trial') + if state.get('KillMode') != 'control-group': + raise ValueError('Retained services must stop their entire control group') + return revision + + +def install(directory): + """Only create absent units; retain original enablement, runtimes, data and SSH.""" + plan = read_plan(directory) + journal_path = directory / 'handoff.json' + if journal_path.exists(): + raise ValueError('An installation journal already exists') + for name in CORE_UNITS: + if (UNIT_DIRECTORY / name).exists() or (UNIT_DIRECTORY / name).is_symlink() or unit_state(name)['LoadState'] != 'not-found': + raise ValueError('A Core service already exists: ' + name) + legacy = {name: {'state': unit_state(name)} for name in LEGACY_UNITS} + for saved in legacy.values(): + if saved['state']['LoadState'] != 'loaded': + raise ValueError('Retained controller service is missing') + saved['hashes'] = unit_hashes(saved['state']) + journal = {'format': 'sleepypod-handoff/1', 'phase': 'installing', 'legacy': legacy, + 'unitHashes': plan['units'], 'createdUnits': [], 'bootSelectionChanged': False} + private_json(journal_path, journal) + for name in CORE_UNITS: + # O_EXCL refuses existing paths and symlinks, even if another process raced preflight. + descriptor = os.open(UNIT_DIRECTORY / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + with os.fdopen(descriptor, 'wb') as output: + output.write((directory / name).read_bytes()) + output.flush() + os.fsync(output.fileno()) + journal['createdUnits'].append(name) + private_json(journal_path, journal) + systemctl('daemon-reload') + validate_installed(plan) + journal['phase'] = 'installed_not_started' + private_json(journal_path, journal) + return journal + + +def restore(plan, journal): + # Changed units need human review; never kill an unrelated replacement service. + validate_installed(plan) + require_legacy_recovery_data(Path(plan['config']['data'])) + stop_units(CORE_UNITS) + require_legacy_recovery_data(Path(plan['config']['data'])) + validate_legacy(journal) + systemctl('start', *LEGACY_UNITS) + states = {name: unit_state(name) for name in LEGACY_UNITS} + if any(state['ActiveState'] != 'active' or state['MainPID'] == '0' for state in states.values()): + raise RuntimeError('Retained controller did not recover') + return {'services': {name: {'active': True, 'pid': state['MainPID']} for name, state in states.items()}, + 'physicalOperationVerified': False} + + +def trial(directory, seconds, observe=core_observation, sleep=time.sleep, monotonic=time.monotonic, + settle_seconds=0, require_idle=False): + require_no_activation(directory) + if not 10 <= seconds <= 300: + raise ValueError('Trial duration must be 10–300 seconds') + if not 0 <= settle_seconds <= min(60, seconds): + raise ValueError('Settling period must be 0–60 seconds within the trial deadline') + plan = read_plan(directory) + journal_path = directory / 'handoff.json' + journal = json.loads(journal_path.read_text()) + if journal.get('format') != 'sleepypod-handoff/1' or journal.get('unitHashes') != plan['units']: + raise ValueError('Journal does not belong to this plan') + if journal['phase'] not in ('installed_not_started', 'trial_restored'): + raise ValueError('Recover the previous operation before starting another trial') + validate_installed(plan) + validate_legacy(journal) + revision = prerequisites(plan) + token = read_owner_key(Path(plan['config']['environment'])) + if require_idle: + require_thermal_idle(local_json('/api/deviceStatus')) + journal.update(phase='trial_starting', trialStartedAt=time.time(), observation=None, trialSucceeded=False, + settleSeconds=settle_seconds, requireIdle=require_idle, restoredIdleVerified=False) + private_json(journal_path, journal) + try: + stop_units(LEGACY_UNITS) + journal['phase'] = 'legacy_stopped' + private_json(journal_path, journal) + systemctl('start', CORE_UNIT) + journal['phase'] = 'core_trial' + private_json(journal_path, journal) + deadline = monotonic() + seconds + fresh_since = None + while monotonic() < deadline: + try: + observation = observe(token, revision) + journal['observation'] = observation + if observation['device']['freshness'] == 'fresh': + if require_idle: + require_core_thermal_idle(token, observation) + observed_at = monotonic() + if fresh_since is None: + fresh_since = observed_at + if observed_at - fresh_since >= settle_seconds: + journal['trialSucceeded'] = True + break + else: + fresh_since = None + except (OSError, ValueError, KeyError): + fresh_since = None # Interrupted observations cannot satisfy a stable window. + sleep(min(2, max(0, deadline - monotonic()))) + finally: + journal['phase'] = 'restoring_legacy' + private_json(journal_path, journal) + try: + journal['restoration'] = restore(plan, journal) + except BaseException: + journal['phase'] = 'recovery_required' + journal['trialSucceeded'] = False + private_json(journal_path, journal) + raise + journal['phase'] = 'trial_restored' + journal['trialEndedAt'] = time.time() + try: + if require_idle: + journal['restoredIdleVerified'] = restored_idle(sleep, monotonic) + except BaseException: + journal['trialSucceeded'] = False + raise + finally: + private_json(journal_path, journal) + if not journal['trialSucceeded']: + raise RuntimeError('Core did not produce a fresh device report; Free Sleep was restored') + return journal + + +def interrupt_trial(_number, _frame): + """Let the first interruption enter finally; do not interrupt its restoration again.""" + for event in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(event, signal.SIG_IGN) + raise KeyboardInterrupt('Restoring the retained controller before exiting') + + +def require_no_activation(directory): + if (directory / 'activation.json').exists() or (directory / 'activation.json').is_symlink(): + raise ValueError('Use activate.py rollback for an activation journal; then prepare a new trial plan') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('install', 'trial', 'rollback', 'status')) + parser.add_argument('--plan', type=Path, required=True) + parser.add_argument('--seconds', type=int, default=120) + parser.add_argument('--settle-seconds', type=int, default=0) + parser.add_argument('--require-idle', action='store_true', help='Require zero heating countdowns before, during and after the trial') + args = parser.parse_args() + if os.geteuid() != 0: + raise ValueError('Run the reviewed handoff on the Pod as root') + info = args.plan.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise ValueError('The plan directory must be root-owned and private') + os.umask(0o077) + for event in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(event, interrupt_trial) + descriptor = os.open(args.plan / 'handoff.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + plan = read_plan(args.plan) + if args.action == 'install': + result = install(args.plan) + elif args.action == 'trial': + result = trial(args.plan, args.seconds, settle_seconds=args.settle_seconds, require_idle=args.require_idle) + else: + path = args.plan / 'handoff.json' + result = json.loads(path.read_text()) + if result.get('format') != 'sleepypod-handoff/1' or result.get('unitHashes') != plan['units']: + raise ValueError('Journal does not belong to this plan') + if args.action == 'rollback': + require_no_activation(args.plan) + result['restoration'] = restore(plan, result) + result['phase'] = 'trial_restored' + private_json(path, result) + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/maintenance_broker.py b/scripts/deployment/maintenance_broker.py new file mode 100644 index 000000000..2598c0e37 --- /dev/null +++ b/scripts/deployment/maintenance_broker.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Root-owned Unix-socket maintenance broker for the isolated Core deployment.""" +import argparse +from contextlib import contextmanager +import fcntl +import hashlib +import json +import math +import os +from pathlib import Path +import pwd +import re +import socket +import stat +import struct +import subprocess +import tempfile +import time + +SOCKET_PATH = '/run/sleepypod-maintenance/control.sock' +STATE_DIRECTORY = Path('/run/sleepypod-maintenance-state') +CORE = 'sleepypod.service' +TARGETS = (CORE, 'sleepypod-piezo-processor.service', 'sleepypod-sleep-detector.service', + 'sleepypod-environment-monitor.service') +PLAN_UNITS = set(TARGETS) | {'sleepypod-calibrator.service'} +MAX_REQUEST_BYTES = 1024 +REQUEST_LIFETIME_MS = 5000 +COOLDOWN_SECONDS = 60 + + +class Rejected(Exception): + """A request refused before any service command was submitted.""" + + +def unique_json(value): + def object_pairs(pairs): + result = {} + for key, item in pairs: + if key in result: + raise ValueError('Duplicate JSON field') + result[key] = item + return result + return json.loads(value, object_pairs_hook=object_pairs, + parse_constant=lambda _value: (_ for _ in ()).throw(ValueError('Non-finite JSON'))) + + +def private_directory(path, owner=0): + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != owner or info.st_mode & 0o077: + raise ValueError('Expected a private, owned directory') + + +def regular_bytes(path, owner=0, private=True): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(descriptor, 'rb') as source: + info = os.fstat(source.fileno()) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != owner + or info.st_mode & (0o077 if private else 0o022) or info.st_size > 64 * 1024): + raise ValueError('Unexpected file ownership, mode or size') + return source.read() + + +@contextmanager +def operation_lock(directory, filename, owner=0, create=True): + """Share the selected plan lock with handoff, activation and plan replacement.""" + private_directory(directory, owner) + descriptor = os.open(directory / filename, (os.O_RDWR | os.O_CREAT if create else os.O_RDONLY) | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'r+' if create else 'r') as lock: + info = os.fstat(lock.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != owner or info.st_mode & 0o077: + raise ValueError('Unexpected lock file') + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + raise Rejected('Controller maintenance or migration is already in progress.') from error + yield + + +def validate_request(request, now_ms): + if not isinstance(request, dict) or set(request) != {'version', 'requestId', 'invocationId', 'unit', 'issuedAt', 'expiresAt'}: + raise Rejected('Invalid maintenance request.') + if (type(request['version']) is not int or request['version'] != 1 + or not isinstance(request['requestId'], str) + or not re.fullmatch(r'[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}', request['requestId']) + or not isinstance(request['invocationId'], str) + or not re.fullmatch(r'[a-f0-9]{32}', request['invocationId']) + or request['unit'] not in TARGETS): + raise Rejected('Unsupported maintenance request.') + issued, expiry = request['issuedAt'], request['expiresAt'] + if (type(issued) is not int or type(expiry) is not int + or not 0 < expiry - issued <= REQUEST_LIFETIME_MS or not issued <= now_ms <= expiry): + raise Rejected('Maintenance request expired or has an invalid timestamp.') + return request + + +def response(request, outcome, message): + return {'requestId': request.get('requestId'), 'outcome': outcome, 'message': message} + + +class StateStore: + """One durable attempt is enough: its cooldown exceeds every request's lifetime.""" + def __init__(self, directory, owner=0): + self.directory, self.owner = directory, owner + private_directory(directory, owner) + self.path = directory / 'attempt.json' + + def read(self): + try: + value = unique_json(regular_bytes(self.path, self.owner)) + except FileNotFoundError: + return None + if (not isinstance(value, dict) or set(value) != {'request', 'outcome', 'message', 'monotonic'} + or value['outcome'] not in ('submitting', 'accepted', 'rejected', 'unknown') + or type(value['monotonic']) not in (int, float) or not math.isfinite(value['monotonic']) + or value['monotonic'] < 0 or not isinstance(value['message'], str)): + raise ValueError('Maintenance receipt needs operator review') + # Validate its structure at its original issuance time, not as a new action. + validate_request(value['request'], value['request']['issuedAt']) + return value + + def write(self, value): + descriptor, filename = tempfile.mkstemp(prefix='.attempt-', dir=self.directory) + temporary = Path(filename) + try: + with os.fdopen(descriptor, 'w') as output: + json.dump(value, output, allow_nan=False) + output.flush() + os.fsync(output.fileno()) + temporary.chmod(0o600) + os.replace(temporary, self.path) + descriptor = os.open(self.directory, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + temporary.unlink(missing_ok=True) + + +class Systemd: + def __init__(self, account_uid, proc=Path('/proc'), units=Path('/etc/systemd/system')): + self.account_uid, self.proc, self.units = account_uid, proc, units + + def properties(self, unit): + result = subprocess.run(['/bin/systemctl', 'show', unit, '--no-pager', + '--property=MainPID,ActiveState,SubState,ControlGroup,InvocationID,FragmentPath,DropInPaths,Job,NoNewPrivileges'], + capture_output=True, text=True, check=True, timeout=2) + return dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line) + + def authorize(self, peer, request, hashes): + """Fence by kernel peer UID/PID, systemd invocation, cgroup and reviewed unit bytes.""" + peer_pid, peer_uid, _peer_gid = peer + if peer_uid != self.account_uid or peer_pid <= 1: + raise Rejected('Only the running Core controller may request maintenance.') + core = self.properties(CORE) + if (core.get('ActiveState') != 'active' or core.get('SubState') != 'running' + or core.get('MainPID') != str(peer_pid) or core.get('InvocationID') != request['invocationId'] + or core.get('NoNewPrivileges') != 'yes' or not core.get('ControlGroup')): + raise Rejected('The requesting Core invocation is no longer active.') + groups = [line.split(':', 2) for line in (self.proc / str(peer_pid) / 'cgroup').read_text().splitlines()] + if not any(len(item) == 3 and item[1] in ('', 'name=systemd') and item[2] == core['ControlGroup'] for item in groups): + raise Rejected('Controller process membership could not be verified.') + units = sorted(PLAN_UNITS) if request['unit'] == CORE else (CORE, request['unit']) + for unit in units: + state = core if unit == CORE else self.properties(unit) + filename = self.units / unit + if (state.get('FragmentPath') != str(filename) or state.get('DropInPaths') + or state.get('Job') not in ('', '0') + or hashlib.sha256(regular_bytes(filename, private=False)).hexdigest() != hashes[unit]): + raise Rejected('Selected services changed or already have a pending job.') + + def submit(self, unit): + # try-restart can never start an inactive Core during rollback. A sensor + # recovery may start a failed module, but only while its parent is current. + if unit not in TARGETS: + raise Rejected('Unsupported maintenance service.') + action = 'try-restart' if unit == CORE else 'restart' + result = subprocess.run(['/bin/systemctl', '--no-block', '--job-mode=fail', action, unit], + capture_output=True, text=True, timeout=3) + if result.returncode != 0: + raise RuntimeError('Systemd did not confirm submission') + return True + + +def read_hashes(plan): + value = unique_json(regular_bytes(plan / 'plan.json')) + hashes = value.get('units') if isinstance(value, dict) else None + if (not isinstance(value, dict) or value.get('format') != 'sleepypod-service-plan/1' or not isinstance(hashes, dict) + or set(hashes) != PLAN_UNITS or any(not isinstance(item, str) or not re.fullmatch(r'[a-f0-9]{64}', item) for item in hashes.values())): + raise ValueError('Unsupported reviewed controller plan') + return hashes + + +class Broker: + def __init__(self, plan, store, system, now=time.time, monotonic=time.monotonic): + self.plan, self.store, self.system = plan, store, system + self.now, self.monotonic = now, monotonic + + def request(self, request, peer): + """Persist before submission; never replay a timed-out or interrupted attempt.""" + validate_request(request, int(self.now() * 1000)) + with operation_lock(self.plan, 'handoff.lock', create=False): + hashes = read_hashes(self.plan) + self.system.authorize(peer, request, hashes) + previous = self.store.read() + if previous: + if previous['request']['requestId'] == request['requestId']: + if previous['request'] != request: + raise Rejected('Request identifier was reused with different contents.') + outcome = 'unknown' if previous['outcome'] == 'submitting' else previous['outcome'] + return response(request, outcome, previous['message']) + if (previous['outcome'] in ('submitting', 'unknown') + and previous['request']['invocationId'] == request['invocationId']): + raise Rejected('An earlier attempt has an unknown outcome. Inspect the service before clearing its maintenance receipt.') + if self.monotonic() - previous['monotonic'] < COOLDOWN_SECONDS: + raise Rejected('Wait 60 seconds after the previous maintenance attempt.') + attempt = {'request': request, 'outcome': 'submitting', + 'message': 'Restart submission could not be confirmed. Reconnect and inspect service health.', + 'monotonic': self.monotonic()} + self.store.write(attempt) + # Recheck after durable IO, while holding the same migration lock. + try: + validate_request(request, int(self.now() * 1000)) + self.system.authorize(peer, request, hashes) + validate_request(request, int(self.now() * 1000)) + except Exception: + attempt.update(outcome='rejected', message='Controller ownership or request freshness changed before submission.') + self.store.write(attempt) + return response(request, attempt['outcome'], attempt['message']) + try: + accepted = self.system.submit(request['unit']) + if accepted is True: + attempt.update(outcome='accepted', message='Restart requested. Reconnect to check its status.') + else: + attempt['outcome'] = 'unknown' + except Exception: + # A timeout or interrupted client can occur after systemd accepted + # the job. Retain the uncertainty; do not issue another command. + attempt['outcome'] = 'unknown' + self.store.write(attempt) + return response(request, attempt['outcome'], attempt['message']) + + +def serve_connection(connection, broker): + request = {} + try: + connection.settimeout(2) + peer = struct.unpack('3i', connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i'))) + data = bytearray() + while b'\n' not in data and len(data) <= MAX_REQUEST_BYTES: + chunk = connection.recv(min(256, MAX_REQUEST_BYTES + 1 - len(data))) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_REQUEST_BYTES or not data.endswith(b'\n') or data.count(b'\n') != 1: + raise Rejected('Invalid maintenance frame.') + request = unique_json(data) + if not isinstance(request, dict): + raise Rejected('Invalid maintenance request.') + reply = broker.request(request, peer) + except Rejected as error: + reply = response(request if isinstance(request, dict) else {}, 'rejected', str(error)) + except Exception: + reply = response(request if isinstance(request, dict) else {}, 'unknown', 'Maintenance could not be confirmed. Reconnect and inspect service health.') + try: + connection.sendall((json.dumps(reply) + '\n').encode()) + except OSError: + pass # The durable receipt, not delivery of this reply, controls retries. + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--plan', required=True, type=Path) + arguments = parser.parse_args() + if os.geteuid() != 0 or os.environ.get('LISTEN_PID') != str(os.getpid()) or os.environ.get('LISTEN_FDS') != '1': + raise ValueError('Run only as a root systemd socket-activated service') + private_directory(arguments.plan) + store = StateStore(STATE_DIRECTORY) + broker = Broker(arguments.plan, store, Systemd(pwd.getpwnam('dac').pw_uid)) + with operation_lock(STATE_DIRECTORY, 'broker.lock'), socket.socket(fileno=3) as listener: + if listener.family != socket.AF_UNIX or listener.type != socket.SOCK_STREAM or listener.getsockname() != SOCKET_PATH: + raise ValueError('Unexpected activated listener') + while True: + connection, _address = listener.accept() + with connection: + serve_connection(connection, broker) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/maintenance_install.py b/scripts/deployment/maintenance_install.py new file mode 100644 index 000000000..0e2446c80 --- /dev/null +++ b/scripts/deployment/maintenance_install.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Provision or remove the restricted broker while retained Free Sleep owns the Pod.""" +import argparse +from contextlib import ExitStack +import hashlib +import json +import os +from pathlib import Path +import pwd +import re +import stat +import tempfile +import time + +import handoff +import maintenance_broker +import maintenance_plan +import service_plan + +OWNER_UID = 0 +UNIT_DIRECTORY = Path('/etc/systemd/system') +ROOT = maintenance_plan.ROOT +UNITS = (maintenance_plan.SOCKET, maintenance_plan.SERVICE) +JOURNAL_FORMAT = 'sleepypod-maintenance-install/1' +PHASES = {'installing', 'installed_stopped', 'enabling', 'enabled', 'rolling_back', 'rolled_back'} + + +def digest(contents): + return hashlib.sha256(contents).hexdigest() + + +def owned_bytes(path, private=False, limit=64*1024): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(descriptor, 'rb') as source: + info = os.fstat(source.fileno()) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != OWNER_UID + or info.st_mode & (0o077 if private else 0o022) or info.st_size > limit): + raise ValueError('Unexpected file ownership, permissions or size: '+str(path)) + return source.read() + + +def secure_path(path, private=False): + """Reject writable or symlinked ancestors before trusting managed root files.""" + if not path.is_absolute(): raise ValueError('Use an absolute managed path') + for item in reversed((path, *path.parents)): + info = item.lstat() + if stat.S_ISLNK(info.st_mode) or info.st_uid != OWNER_UID or info.st_mode & 0o022: + raise ValueError('Untrusted managed path: '+str(item)) + if private and path.stat().st_mode & 0o077: + raise ValueError('Expected a private managed path') + + +def verify_release(release): + """Verify the packaged broker and build identity without executing application code.""" + secure_path(release) + manifest = json.loads(owned_bytes(release/'bundle-manifest.json', limit=32*1024*1024)) + if (manifest.get('format') != 'sleepypod-offline-runtime/1' + or type(manifest.get('maintenanceProtocol')) is not int or manifest['maintenanceProtocol'] != 1 + or not re.fullmatch(r'[a-f0-9]{40}', manifest.get('sourceCommit', ''))): + raise ValueError('Release lacks the reviewed maintenance protocol') + for relative in ('scripts/deployment/maintenance_broker.py', '.git-info'): + path = release/'app'/relative + secure_path(path) + records = [entry for entry in manifest['application'] if entry.get('path') == relative] + contents = owned_bytes(path) + if len(records) != 1 or records[0].get('sha256') != digest(contents): + raise ValueError('Packaged maintenance file differs from its manifest') + if relative == '.git-info': + revision = json.loads(contents).get('commitHash', '') + if not re.fullmatch(r'[a-f0-9]{7,40}', revision) or not manifest['sourceCommit'].startswith(revision): + raise ValueError('Build identity differs from its release') + return manifest['sourceCommit'] + + +def load_context(directory, recovery=False): + """Bind this installation to the selected, stopped Core plan and untouched credentials.""" + if directory.parent != ROOT or not directory.name.startswith('maintenance-plan-'): + raise ValueError('Use a separate managed maintenance-plan directory') + secure_path(directory, private=True) + for name in (*UNITS, 'maintenance-plan.json'): owned_bytes(directory/name, private=True) + manifest = maintenance_plan.read_plan(directory) + controller = Path(manifest['controllerPlan']) + secure_path(controller, private=True) + for name in (*service_plan.CORE_UNITS, 'plan.json', 'handoff.json'): + owned_bytes(controller/name, private=True) + plan = service_plan.read_plan(controller) + if (plan['config']['release'] != manifest['release'] + or plan['config'].get('maintenanceSocket') != service_plan.MAINTENANCE_SOCKET): + raise ValueError('Controller plan does not select this release and maintenance socket') + saved = json.loads(owned_bytes(controller/'handoff.json', private=True)) + if (saved.get('format') != 'sleepypod-handoff/1' or saved.get('unitHashes') != plan['units'] + or saved.get('phase') not in ('installed_not_started', 'trial_restored')): + raise ValueError('Recover the controller handoff before provisioning maintenance') + activation = controller/'activation.json' + if activation.exists() or activation.is_symlink(): + record = json.loads(owned_bytes(activation, private=True)) + if (record.get('format') != 'sleepypod-activation/1' or record.get('unitHashes') != plan['units'] + or record.get('phase') != 'rolled_back' or record.get('bootOwner') != 'free-sleep' + or record.get('activeOwner') != 'free-sleep'): + raise ValueError('Roll back the active controller before maintenance provisioning') + if recovery: + # Cleanup must not require a working release or credentials. The private + # journal and exact installed unit hashes identify what may be removed. + record = json.loads(owned_bytes(journal_path(directory), private=True)) + revision = record.get('sourceCommit', '') + if not isinstance(revision, str) or not re.fullmatch(r'[a-f0-9]{40}', revision): + raise ValueError('Recovery journal lacks its original source identity') + else: + environment = Path(plan['config']['environment']) + secure_path(environment, private=True) + if b'SLEEPYPOD_MAINTENANCE_SOCKET' in owned_bytes(environment, private=True): + raise ValueError('Remove the conflicting maintenance setting from the private environment before provisioning') + # Validate the existing owner key without copying it into any journal or output. + handoff.read_owner_key(environment) + revision = verify_release(Path(manifest['release'])) + return {'manifest': manifest, 'controller': controller, 'plan': plan, 'handoff': saved, 'revision': revision} + + +def require_retained_owner(context): + handoff.validate_installed(context['plan']) + handoff.validate_legacy(context['handoff']) + for name in service_plan.CORE_UNITS: + if digest(owned_bytes(UNIT_DIRECTORY/name)) != context['plan']['units'][name]: + raise ValueError('Selected Core unit changed before provisioning') + state = handoff.unit_state(name) + if not handoff.stopped(state) or state.get('UnitFileState') != 'disabled': + raise ValueError('Core must be stopped and disabled before maintenance provisioning') + for name in service_plan.LEGACY_UNITS: + state = handoff.unit_state(name) + if state.get('ActiveState') != 'active' or state.get('MainPID') in (None, '0') or state.get('UnitFileState') != 'enabled': + raise ValueError('Retained Free Sleep must be active and enabled') + + +def socket_present(): + path = Path(service_plan.MAINTENANCE_SOCKET) + return path.exists() or path.is_symlink() + + +def helper_stopped(name, state): + """Sockets have no MainPID; service processes still require an explicit zero.""" + if name not in UNITS or state.get('ActiveState') not in ('inactive', 'failed'): + return False + if name == maintenance_plan.SOCKET: + return state.get('MainPID') in (None, '0') + return state.get('MainPID') == '0' + + +def verify_socket(): + info = Path(service_plan.MAINTENANCE_SOCKET).lstat() + if (not stat.S_ISSOCK(info.st_mode) or info.st_uid != OWNER_UID + or info.st_gid != pwd.getpwnam('dac').pw_gid or info.st_mode & 0o777 != 0o660): + raise ValueError('Socket permissions differ from the reviewed plan') + + +def sync_directory(directory): + descriptor = os.open(directory, os.O_RDONLY) + try: os.fsync(descriptor) + finally: os.close(descriptor) + + +def publish_unit(path, contents): + """Publish complete bytes with exclusive creation; never replace an existing unit.""" + descriptor, temporary = tempfile.mkstemp(prefix='.sleepypod-maintenance-', dir=path.parent) + temporary = Path(temporary) + try: + with os.fdopen(descriptor, 'wb') as output: + output.write(contents) + output.flush() + os.fsync(output.fileno()) + temporary.chmod(0o644) + os.link(temporary, path) + sync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) + + +def journal_path(directory): + return directory/'maintenance-install.json' + + +def write_phase(directory, journal, phase): + now = time.time() + journal.setdefault('startedAt', now) + journal['updatedAt'] = now + journal['phase'] = phase + handoff.private_json(journal_path(directory), journal) + + +def read_journal(directory, context): + journal = json.loads(owned_bytes(journal_path(directory), private=True)) + if (journal.get('format') != JOURNAL_FORMAT or journal.get('phase') not in PHASES + or journal.get('unitHashes') != context['manifest']['units'] + or journal.get('controllerPlan') != str(context['controller']) + or journal.get('sourceCommit') != context['revision']): + raise ValueError('Maintenance journal does not match the reviewed plan') + return journal + + +def validate_units(context, allow_missing=False): + """Refuse foreign unit files and overrides before any stop, disable or removal.""" + states = {} + for name in UNITS: + path = UNIT_DIRECTORY/name + state = handoff.unit_state(name) + if path.exists() or path.is_symlink(): + if digest(owned_bytes(path)) != context['manifest']['units'][name]: + raise ValueError('Maintenance unit changed; preserve it for review: '+name) + elif not allow_missing or not helper_stopped(name, state): + raise ValueError('Maintenance unit is missing or its cached process is active: '+name) + if state.get('DropInPaths') or state.get('FragmentPath') not in ('', str(path)): + raise ValueError('Unexpected maintenance service override: '+name) + if state.get('LoadState') not in ('loaded', 'not-found'): + raise ValueError('Maintenance service has an unexpected load state: '+name) + if name == maintenance_plan.SERVICE and state.get('UnitFileState') not in ('', 'static'): + raise ValueError('Maintenance service has unexpected boot configuration') + if name == maintenance_plan.SOCKET and state.get('UnitFileState') not in ('', 'disabled', 'enabled'): + raise ValueError('Maintenance socket has unexpected boot configuration') + states[name] = state + return states + + +def install(directory, context): + require_retained_owner(context) + if journal_path(directory).exists() or journal_path(directory).is_symlink(): + raise ValueError('Installation journal exists; inspect or roll back that attempt') + for name in UNITS: + path = UNIT_DIRECTORY/name + state = handoff.unit_state(name) + if path.exists() or path.is_symlink() or state.get('LoadState') != 'not-found' or not helper_stopped(name, state): + raise ValueError('Maintenance service already exists: '+name) + if socket_present(): + raise ValueError('Maintenance socket path already exists') + journal = {'format': JOURNAL_FORMAT, 'phase': 'installing', 'unitHashes': context['manifest']['units'], + 'controllerPlan': str(context['controller']), 'sourceCommit': context['revision'], + 'coreEnvironmentChanged': False, 'controllerStarted': False} + write_phase(directory, journal, 'installing') + for name in UNITS: publish_unit(UNIT_DIRECTORY/name, owned_bytes(directory/name, private=True)) + handoff.systemctl('daemon-reload') + states = validate_units(context) + if any(not helper_stopped(name, state) for name, state in states.items()) or states[maintenance_plan.SOCKET].get('UnitFileState') != 'disabled': + raise ValueError('Installed maintenance services did not remain stopped') + require_retained_owner(context) + write_phase(directory, journal, 'installed_stopped') + return journal + + +def enable(directory, context): + require_retained_owner(context) + journal = read_journal(directory, context) + if journal['phase'] != 'installed_stopped': + raise ValueError('Enable only a fully installed, stopped maintenance plan') + states = validate_units(context) + if any(not helper_stopped(name, state) for name, state in states.items()) or states[maintenance_plan.SOCKET].get('UnitFileState') != 'disabled': + raise ValueError('Maintenance state changed before enablement') + write_phase(directory, journal, 'enabling') + handoff.systemctl('enable', '--now', maintenance_plan.SOCKET) + state = validate_units(context)[maintenance_plan.SOCKET] + if state.get('ActiveState') != 'active' or state.get('UnitFileState') != 'enabled': + raise ValueError('Socket enablement could not be confirmed; inspect and roll back') + verify_socket() + require_retained_owner(context) + write_phase(directory, journal, 'enabled') + return journal + + +def rollback(directory, context): + """Recover partial installs by exact hashes; leave the controller, keys and receipts alone.""" + require_retained_owner(context) + journal = read_journal(directory, context) + states = validate_units(context, allow_missing=True) + if journal['phase'] == 'rolled_back': + if socket_present() or any((UNIT_DIRECTORY/name).exists() or state['LoadState'] != 'not-found' for name, state in states.items()): + raise ValueError('Maintenance reappeared after rollback') + return journal + write_phase(directory, journal, 'rolling_back') + # Reload first so units published just before a crash are visible to systemd. + handoff.systemctl('daemon-reload') + states = validate_units(context, allow_missing=True) + loaded = [name for name, state in states.items() if state['LoadState'] == 'loaded'] + if loaded: handoff.systemctl('stop', *loaded) + if (UNIT_DIRECTORY/maintenance_plan.SOCKET).exists(): handoff.systemctl('disable', maintenance_plan.SOCKET) + states = validate_units(context, allow_missing=True) + if (any(not helper_stopped(name, state) for name, state in states.items()) + or states[maintenance_plan.SOCKET].get('UnitFileState') == 'enabled' or socket_present()): + raise ValueError('Maintenance processes did not stop; preserve units for recovery') + for name in UNITS: + path = UNIT_DIRECTORY/name + if path.exists(): + if digest(owned_bytes(path)) != context['manifest']['units'][name]: + raise ValueError('Maintenance unit changed during rollback') + path.unlink() + sync_directory(UNIT_DIRECTORY) + handoff.systemctl('daemon-reload') + final = validate_units(context, allow_missing=True) + if socket_present() or any(state['LoadState'] != 'not-found' or not helper_stopped(name, state) for name, state in final.items()): + raise ValueError('Maintenance removal could not be confirmed') + require_retained_owner(context) + write_phase(directory, journal, 'rolled_back') + return journal + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('inspect', 'install', 'enable', 'rollback')) + parser.add_argument('--plan', required=True, type=Path) + arguments = parser.parse_args() + if os.geteuid() != 0: raise ValueError('Run on the Pod as root') + os.umask(0o077) + context = load_context(arguments.plan, recovery=arguments.action == 'rollback') + secure_path(UNIT_DIRECTORY) + with ExitStack() as locks: + if arguments.action != 'inspect': + locks.enter_context(maintenance_broker.operation_lock(arguments.plan, 'install.lock')) + locks.enter_context(maintenance_broker.operation_lock(context['controller'], 'handoff.lock', create=False)) + # Revalidate after both locks; a plan may have changed while acquiring them. + current = load_context(arguments.plan, recovery=arguments.action == 'rollback') + if current != context: raise ValueError('Maintenance or controller plan changed while acquiring locks') + if arguments.action == 'inspect': + require_retained_owner(context) + result = {'sourceCommit': context['revision'], 'controllerStopped': True, + 'journalPresent': journal_path(arguments.plan).exists(), 'changesMade': False} + else: + result = {'install': install, 'enable': enable, 'rollback': rollback}[arguments.action](arguments.plan, context) + print(json.dumps(result)) + + +if __name__ == '__main__': main() diff --git a/scripts/deployment/maintenance_plan.py b/scripts/deployment/maintenance_plan.py new file mode 100644 index 000000000..90e0ef6f1 --- /dev/null +++ b/scripts/deployment/maintenance_plan.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Render root-owned maintenance units for a specific reviewed controller plan.""" +import argparse +import hashlib +import json +from pathlib import Path + +from service_plan import pod_path + +ROOT = Path('/persistent/sleepypod-unified') +SERVICE = 'sleepypod-control-maintenance.service' +SOCKET = 'sleepypod-control-maintenance.socket' +ENVIRONMENT_LINE = 'SLEEPYPOD_MAINTENANCE_SOCKET=/run/sleepypod-maintenance/control.sock' + + +def render(controller_plan, release): + """Keep Core unprivileged; expose only one local, group-restricted socket.""" + controller_plan, release = Path(pod_path(str(controller_plan))), Path(pod_path(str(release))) + if (controller_plan.parent != ROOT or not controller_plan.name.startswith('service-plan-') + or release.parent != ROOT/'releases'): + raise ValueError('Use separate managed controller-plan and release directories') + return { + SOCKET: '''# Generated maintenance socket; no network listener. +[Unit] +Description=SleepyPod restricted maintenance socket + +[Socket] +ListenStream=/run/sleepypod-maintenance/control.sock +SocketUser=root +SocketGroup=dac +SocketMode=0660 +DirectoryMode=0755 +Accept=no +Backlog=8 +RemoveOnStop=true + +[Install] +WantedBy=sockets.target +''', + SERVICE: f'''# Generated for one reviewed controller plan; does not own bed hardware. +[Unit] +Description=SleepyPod restricted maintenance supervisor +BindsTo={SOCKET} +After={SOCKET} +RequiresMountsFor={controller_plan} {release} +StartLimitIntervalSec=60 +StartLimitBurst=3 + +[Service] +Type=simple +User=root +Group=root +UMask=0077 +ExecStart=/usr/bin/python3 -I {release}/app/scripts/deployment/maintenance_broker.py --plan {controller_plan} +RuntimeDirectory=sleepypod-maintenance-state +RuntimeDirectoryMode=0700 +RuntimeDirectoryPreserve=yes +Restart=on-failure +RestartSec=5 +TimeoutStopSec=5 +KillMode=control-group +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_UNIX +RestrictSUIDSGID=true +CapabilityBoundingSet= +ReadWritePaths=/run/sleepypod-maintenance-state +MemoryMax=64M +TasksMax=16 +CPUQuota=10% +''', + } + + +def prepare(controller_plan, release, output): + units = render(controller_plan, release) + if output.exists() or output.is_symlink(): + raise ValueError('Output must be a new directory') + output.mkdir(mode=0o700) + for name, contents in units.items(): + (output/name).write_text(contents) + (output/name).chmod(0o600) + manifest = {'format': 'sleepypod-maintenance-plan/1', 'controllerPlan': str(controller_plan), + 'release': str(release), 'environmentLine': ENVIRONMENT_LINE, + 'units': {name: hashlib.sha256(contents.encode()).hexdigest() for name, contents in units.items()}, + 'servicesStarted': False, 'coreEnvironmentChanged': False} + (output/'maintenance-plan.json').write_text(json.dumps(manifest, indent=2)+'\n') + (output/'maintenance-plan.json').chmod(0o600) + return manifest + + +def read_plan(directory): + manifest_path = directory/'maintenance-plan.json' + if manifest_path.is_symlink(): raise ValueError('Expected a regular maintenance manifest') + manifest = json.loads(manifest_path.read_text()) + if manifest.get('format') != 'sleepypod-maintenance-plan/1' or manifest.get('environmentLine') != ENVIRONMENT_LINE: + raise ValueError('Unexpected maintenance plan') + expected = render(manifest['controllerPlan'], manifest['release']) + if set(manifest.get('units', {})) != set(expected): raise ValueError('Unexpected maintenance services') + for name, contents in expected.items(): + path = directory/name + if path.is_symlink() or path.read_text() != contents or hashlib.sha256(contents.encode()).hexdigest() != manifest['units'][name]: + raise ValueError('Maintenance service differs from the reviewed template') + return manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--controller-plan', required=True, type=Path) + parser.add_argument('--release', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + arguments = parser.parse_args() + prepare(arguments.controller_plan, arguments.release, arguments.output) + print(json.dumps({'plan': str(arguments.output), 'servicesStarted': False, 'coreEnvironmentChanged': False})) + + +if __name__ == '__main__': main() diff --git a/scripts/deployment/maintenance_status.py b/scripts/deployment/maintenance_status.py new file mode 100644 index 000000000..4182252e2 --- /dev/null +++ b/scripts/deployment/maintenance_status.py @@ -0,0 +1,61 @@ +"""Validate the small administrative status contract without recording personal data.""" +from datetime import datetime +import re +import time + +PATH = '/api/v1/maintenance/status' +FORMAT = 'sleepypod-maintenance-status' +FIELDS = {'apiVersion', 'kind', 'generatedAt', 'controller', 'device', + 'temperatureRuntime', 'thermalIdle', 'privacyBoundary'} + + +def require_current(generated_at, now=None): + if not isinstance(generated_at, str) or not generated_at.endswith('Z'): + raise ValueError('Maintenance observation has no valid UTC timestamp') + try: + observed = datetime.fromisoformat(generated_at[:-1] + '+00:00').timestamp() + except (ValueError, OverflowError) as error: + raise ValueError('Maintenance observation has no valid UTC timestamp') from error + if not 0 <= (time.time() if now is None else now) - observed < 15: + raise ValueError('Maintenance observation is stale or in the future') + + +def parse(value, expected_revision, now=None): + """Reject unknown protocol shapes; never journal an unexpected server payload.""" + if not isinstance(value, dict) or set(value) != FIELDS: + raise ValueError('Unexpected maintenance status fields') + if type(value['apiVersion']) is not int or value['apiVersion'] != 1 or value['kind'] != FORMAT: + raise ValueError('Unsupported maintenance status protocol') + controller, device = value['controller'], value['device'] + if (not isinstance(controller, dict) or set(controller) != {'id', 'revision'} + or controller['id'] != 'sleepypod-core'): + raise ValueError('Unexpected maintenance controller') + revision = controller['revision'] + if (not isinstance(revision, str) or not re.fullmatch('[a-f0-9]{7,40}', revision) + or not isinstance(expected_revision, str) or not re.fullmatch('[a-f0-9]{40}', expected_revision) + or not expected_revision.startswith(revision)): + raise ValueError('Controller revision differs from the staged release') + if not isinstance(device, dict) or set(device) != {'freshness'} or device['freshness'] not in ('fresh', 'stale', 'unavailable'): + raise ValueError('Invalid maintenance device observation') + if (value['temperatureRuntime'] not in ('ready', 'not_ready') + or value['privacyBoundary'] not in ('legacy', 'personal_required', 'unavailable') + or (value['thermalIdle'] is not None and type(value['thermalIdle']) is not bool)): + raise ValueError('Invalid maintenance readiness') + if ((device['freshness'] != 'fresh' or value['privacyBoundary'] == 'unavailable') + and (value['thermalIdle'] is not None or value['temperatureRuntime'] != 'not_ready')): + raise ValueError('Unavailable observations cannot assert maintenance readiness') + require_current(value['generatedAt'], now) + return {'controller': controller, 'device': device, 'temperatureRuntime': value['temperatureRuntime'], + 'maintenanceProtocol': 1, 'generatedAt': value['generatedAt'], + 'thermalIdle': value['thermalIdle'], 'privacyBoundary': value['privacyBoundary']} + + +def require_idle(observation): + """Idle covers heating countdowns and priming, not occupancy, vibration or base motion.""" + require_current(observation.get('generatedAt')) + if (type(observation.get('maintenanceProtocol')) is not int or observation['maintenanceProtocol'] != 1 + or observation.get('device', {}).get('freshness') != 'fresh' + or observation.get('temperatureRuntime') != 'ready' + or observation.get('privacyBoundary') not in ('legacy', 'personal_required') + or observation.get('thermalIdle') is not True): + raise RuntimeError('Maintenance requires a fresh, available, thermally idle controller') diff --git a/scripts/deployment/network_trial.py b/scripts/deployment/network_trial.py new file mode 100644 index 000000000..754f75c0e --- /dev/null +++ b/scripts/deployment/network_trial.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Bounded, attended network isolation; never changes Pod hardware or routines.""" +import argparse +from contextlib import contextmanager +import fcntl +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import re +import shlex +import signal +import stat +import subprocess +import time +import uuid + + +STATE_ROOT = Path('/run/sleepypod-network-trial') +FIREWALLS = ('/usr/sbin/iptables', '/usr/sbin/ip6tables') +SYSTEMCTL = '/bin/systemctl' +SYSTEMD_RUN = '/usr/bin/systemd-run' +PYTHON = '/usr/bin/python3' +ROUTE = '/sbin/ip' +BOOT_ID = Path('/proc/sys/kernel/random/boot_id') +CONTROL_PORTS = '8822,3000,3001' +MAX_SECONDS = 300 +MIN_SECONDS = 120 + + +def run(arguments, check=True): + result = subprocess.run(arguments, text=True, capture_output=True, timeout=5) + if check and result.returncode: + raise RuntimeError(f'{Path(arguments[0]).name} failed: {result.stderr.strip()[:300]}') + return result + + +def secure_path(path, directory=False): + """Recovery code and state must not be replaceable by another local user.""" + if not path.is_absolute(): + raise ValueError('Use an absolute path') + for parent in reversed((path, *path.parents)): + metadata = parent.lstat() + if stat.S_ISLNK(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_mode & 0o022: + raise ValueError(f'Path must be root-owned, not linked or writable by others: {parent}') + expected = stat.S_ISDIR if directory else stat.S_ISREG + if not expected(path.stat().st_mode): + raise ValueError('Unexpected file type') + + +def control_address(value): + address = ipaddress.IPv4Address(value) + private_ranges = ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16') + if not any(address in ipaddress.IPv4Network(network) for network in private_ranges): + raise ValueError('Recovery must use a directly connected private LAN IPv4 address') + return str(address) + + +def firewall_rules(binary): + return [shlex.split(line) for line in run([binary, '-w', '2', '-S']).stdout.splitlines() if line] + + +def preflight(address, script): + """Read-only: require the current direct SSH peer and both address families.""" + if os.geteuid() != 0: + raise ValueError('Run on the Pod as root') + address = control_address(address) + peer = os.environ.get('SSH_CONNECTION', '').split() + if len(peer) != 4 or peer[0] != address or peer[3] != '8822': + raise ValueError('Control address must be this SSH session peer on Pod port 8822') + secure_path(script) + for binary in (*FIREWALLS, SYSTEMCTL, SYSTEMD_RUN, PYTHON, ROUTE): + if not os.access(binary, os.X_OK): + raise ValueError(f'Missing required executable: {binary}') + routes = json.loads(run([ROUTE, '-j', 'route', 'get', address]).stdout) + if len(routes) != 1 or routes[0].get('gateway') or routes[0].get('dev') == 'lo' or routes[0].get('prefsrc') != peer[2]: + raise ValueError('Recovery peer must have a direct LAN route from the SSH destination') + run([SYSTEMCTL, 'is-active', '--quiet', 'sleepypod.service']) + snapshots = {} + for binary in FIREWALLS: + rules = firewall_rules(binary) + if any(any(token.startswith('SPQ_') for token in rule) for rule in rules): + raise ValueError('A previous or foreign trial chain exists; inspect and recover it first') + snapshots[Path(binary).name] = {'rules': len(rules), 'sha256': hashlib.sha256(json.dumps(rules).encode()).hexdigest()} + return {'controlAddress': address, 'interface': routes[0]['dev'], 'firewalls': snapshots, + 'scriptSha256': hashlib.sha256(script.read_bytes()).hexdigest(), 'physicalOperationVerified': False} + + +def plans(address, trial_id): + """Preserve loopback and one management peer; block existing sessions too.""" + result = [] + for family, binary in enumerate(FIREWALLS): + for direction in ('INPUT', 'OUTPUT'): + incoming = direction == 'INPUT' + chain = f'SPQ_{trial_id[:12]}_{family}{direction[0]}' + rules = [['-i' if incoming else '-o', 'lo', '-j', 'RETURN']] + if family == 0: + rules.append(['-s' if incoming else '-d', address + '/32', '-p', 'tcp', '-m', 'multiport', + '--dports' if incoming else '--sports', CONTROL_PORTS, '-j', 'RETURN']) + # A short trial must not accidentally discard a DHCP lease renewal. + rules.append(['-p', 'udp', '-m', 'udp', '--sport', '67' if incoming else '68', + '--dport', '68' if incoming else '67', '-j', 'RETURN']) + rules.append(['-j', 'DROP']) + jump = ['-m', 'comment', '--comment', 'sleepypod-trial-' + trial_id, '-j', chain] + result.append({'binary': binary, 'direction': direction, 'chain': chain, 'rules': rules, 'jump': jump}) + return result + + +@contextmanager +def state_lock(): + if not STATE_ROOT.exists(): + STATE_ROOT.mkdir(mode=0o700) + secure_path(STATE_ROOT, directory=True) + lock = STATE_ROOT / 'lock' + descriptor = os.open(lock, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + secure_path(lock) + fcntl.flock(descriptor, fcntl.LOCK_EX) + yield + finally: + os.close(descriptor) + + +def save(record): + temporary = STATE_ROOT / 'receipt.new' + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'w') as stream: + json.dump(record, stream, indent=2) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(STATE_ROOT / 'receipt.json') + descriptor = os.open(STATE_ROOT, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def read_record(): + path = STATE_ROOT / 'receipt.json' + if not path.exists(): + return None + secure_path(path) + record = json.loads(path.read_text()) + if not re.fullmatch('[0-9a-f]{32}', record.get('id', '')) or record.get('version') != 1: + raise ValueError('Invalid trial receipt') + control_address(record['controlAddress']) + if record['bootId'] != BOOT_ID.read_text().strip(): + raise ValueError('Receipt belongs to another boot; inspect networking before continuing') + return record + + +def unit_name(record): + return 'sleepypod-net-recover-' + record['id'] + + +def arm_recovery(record, script): + name = unit_name(record) + run([SYSTEMD_RUN, '--quiet', '--unit=' + name, '--on-active=' + str(record['seconds']) + 's', + '--timer-property=AccuracySec=1s', '--timer-property=RandomizedDelaySec=0', + '--property=Type=oneshot', '--property=Restart=on-failure', '--property=RestartSec=3s', + '--property=StartLimitIntervalSec=0', '--property=TimeoutStartSec=45s', + PYTHON, '-B', str(script), 'recover', '--trial-id', record['id']]) + run([SYSTEMCTL, 'is-active', '--quiet', name + '.timer']) + + +def restore(record): + """Unlink only this trial, then remove its unchanged private chains.""" + errors = [] + for plan in plans(record['controlAddress'], record['id']): + binary, chain = plan['binary'], plan['chain'] + try: + rules = firewall_rules(binary) + jump = ['-A', plan['direction'], *plan['jump']] + # Remove exactly our tagged jumps, never replace the rest of the firewall. + for _index in range(rules.count(jump)): + run([binary, '-w', '2', '-D', plan['direction'], *plan['jump']]) + current = [rule for rule in rules if len(rule) > 1 and rule[:2] == ['-A', chain]] + expected = [['-A', chain, *rule] for rule in plan['rules']] + if current != expected[:len(current)]: + raise ValueError(f'Foreign rules in {chain}; left its detached chain for inspection') + if ['-N', chain] in rules: + run([binary, '-w', '2', '-F', chain]) + run([binary, '-w', '2', '-X', chain]) + except (RuntimeError, ValueError, subprocess.SubprocessError) as error: + errors.append(str(error)) + if not errors: + try: + timer = unit_name(record) + '.timer' + stopped = run([SYSTEMCTL, 'stop', timer], check=False) + if stopped.returncode: + # Arming may have failed before systemd created either unit. + loaded = run([SYSTEMCTL, 'show', timer, '-p', 'LoadState', '--value'], check=False) + if loaded.stdout.strip() != 'not-found': + raise RuntimeError('Could not confirm recovery timer stopped') + except (RuntimeError, subprocess.SubprocessError) as error: + errors.append(str(error)) + record.update(phase='restore_failed' if errors else 'restored', restoredAt=time.time(), errors=errors) + save(record) + if errors: + raise RuntimeError('Recovery incomplete; recovery service will retry: ' + '; '.join(errors)) + return record + + +def apply(address, seconds, script): + if not MIN_SECONDS <= seconds <= MAX_SECONDS: + raise ValueError(f'Trial duration must be {MIN_SECONDS}–{MAX_SECONDS} seconds') + with state_lock(): + previous = read_record() + if previous and previous['phase'] != 'restored': + raise ValueError('An unfinished trial exists; recover it before starting another') + observation = preflight(address, script) + record = {'version': 1, 'id': uuid.uuid4().hex, 'phase': 'prepared', 'seconds': seconds, + 'bootId': BOOT_ID.read_text().strip(), 'createdAt': time.time(), **observation} + # Persist intent before arming recovery or installing any firewall rules. + save(record) + try: + arm_recovery(record, script) + record['phase'] = 'armed' + save(record) + deadline = time.monotonic() + 20 + for plan in plans(address, record['id']): + commands = [[plan['binary'], '-w', '2', '-N', plan['chain']]] + commands.extend([plan['binary'], '-w', '2', '-A', plan['chain'], *rule] for rule in plan['rules']) + commands.append([plan['binary'], '-w', '2', '-I', plan['direction'], '1', *plan['jump']]) + for command in commands: + if time.monotonic() > deadline: + raise RuntimeError('Applying the trial exceeded its short setup deadline') + run(command) + record.update(phase='isolated', isolatedAt=time.time()) + save(record) + return record + except BaseException: + # SIGKILL or SSH loss cannot cancel the independent systemd timer. + restore(record) + raise + + +def recover(trial_id): + with state_lock(): + record = read_record() + if not record or record['id'] != trial_id: + raise ValueError('Recovery ID does not match the saved trial; no rules changed') + return restore(record) + + +def rehearse(address, script): + """Run the real timer/recovery path without adding any isolation rules.""" + with state_lock(): + previous = read_record() + if previous and previous['phase'] != 'restored': + raise ValueError('An unfinished trial exists; recover it before rehearsing') + observation = preflight(address, script) + record = {'version': 1, 'id': uuid.uuid4().hex, 'phase': 'rehearsal', 'seconds': 10, + 'bootId': BOOT_ID.read_text().strip(), 'createdAt': time.time(), **observation} + save(record) + try: + arm_recovery(record, script) + except BaseException: + restore(record) + raise + return record + + +def interrupted(_number, _frame): + raise KeyboardInterrupt('Trial interrupted; restoring networking') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + check = commands.add_parser('preflight') + check.add_argument('--control-address', required=True) + rehearsal = commands.add_parser('rehearse-recovery') + rehearsal.add_argument('--control-address', required=True) + start = commands.add_parser('apply') + start.add_argument('--control-address', required=True) + start.add_argument('--seconds', type=int, default=180) + start.add_argument('--attended', action='store_true', required=True) + recovery = commands.add_parser('recover') + recovery.add_argument('--trial-id', required=True) + arguments = parser.parse_args() + script = Path(__file__).absolute() + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGHUP, interrupted) + if arguments.command == 'preflight': + result = preflight(arguments.control_address, script) + elif arguments.command == 'rehearse-recovery': + result = rehearse(arguments.control_address, script) + elif arguments.command == 'apply': + result = apply(arguments.control_address, arguments.seconds, script) + else: + result = recover(arguments.trial_id) + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/prepare_data.py b/scripts/deployment/prepare_data.py new file mode 100644 index 000000000..a28181b25 --- /dev/null +++ b/scripts/deployment/prepare_data.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Capture quiesced Free Sleep data, restore its services, then import for review.""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import signal +import stat +import subprocess +import sys +import time + +import activation_checks +import handoff +from service_plan import CORE_UNITS, LEGACY_UNITS, read_plan + +ROOT = Path(__file__).resolve().parents[2] +FORMAT = 'sleepypod-quiesced-import/1' + + +def digest(path): + checksum = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + checksum.update(chunk) + return checksum.hexdigest() + + +def source_hashes(legacy): + return {name: activation_checks.source_digest(legacy, name, required) + for name, required in activation_checks.SOURCE_FILES.items()} + + +def require_core_stopped(): + for name in CORE_UNITS: + state = handoff.unit_state(name) + if not handoff.stopped(state) or state['UnitFileState'] != 'disabled': + raise ValueError('Core must remain stopped and disabled: ' + name) + + +def validate_plan(directory): + """Share the installed handoff identity; never operate on replacement services.""" + handoff.require_no_activation(directory) + plan = read_plan(directory) + journal = json.loads((directory / 'handoff.json').read_text()) + if (journal.get('format') != 'sleepypod-handoff/1' or journal.get('unitHashes') != plan['units'] + or journal.get('phase') not in ('installed_not_started', 'trial_restored') + or set(journal.get('legacy', {})) != set(LEGACY_UNITS)): + raise ValueError('Recover the handoff before preparing data') + handoff.validate_installed(plan) + handoff.validate_legacy(journal) + require_core_stopped() + for name in LEGACY_UNITS: + state = handoff.unit_state(name) + if state['UnitFileState'] != 'enabled' or state.get('KillMode') != 'control-group': + raise ValueError('Retained services must be enabled with control-group ownership') + return plan, journal + + +def run_tool(arguments, log, timeout): + """Keep tool output private; subprocess failures never dump backup data to the console.""" + descriptor = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, 'wb') as output: + subprocess.run(arguments, stdout=output, stderr=subprocess.STDOUT, check=True, timeout=timeout) + + +def phase(output, record, value): + record['phase'] = value + handoff.private_json(output / 'preparation.json', record) + + +def restore_retained(directory, output, record): + """Restore only the unchanged original writers; no database, boot or actuator changes.""" + try: + validate_plan(directory) + # Restart before another journal write: a full backup disk must not prevent restoration. + handoff.systemctl('start', *LEGACY_UNITS) + states = {name: handoff.unit_state(name) for name in LEGACY_UNITS} + if any(state['ActiveState'] != 'active' or state['MainPID'] == '0' + or state['UnitFileState'] != 'enabled' for state in states.values()): + raise RuntimeError('Retained controller services did not recover') + record['restoredIdleVerified'] = handoff.restored_idle() + record['servicesAfter'] = {name: {'active': True, 'pid': value['MainPID']} + for name, value in states.items()} + record['restoredAt'] = time.time() + except BaseException: + record['restoredIdleVerified'] = False + try: + phase(output, record, 'recovery_required') + except OSError: + pass # Keep the original restoration failure when storage also fails. + raise + phase(output, record, 'free_sleep_restored') + + +def prepare(directory, legacy, backup_tool, expected_backup_hash, output): + """Bound writer downtime to capture. Migration and all warnings wait until restoration.""" + legacy = legacy.absolute() + output = output.parent.resolve(strict=True) / output.name + if not legacy.is_dir() or legacy.is_symlink() or legacy.resolve() != legacy: + raise ValueError('Use the canonical existing retained data directory') + if output.exists() or output.is_symlink(): + raise ValueError('Output must be a new directory') + for protected in (legacy, directory.resolve(), ROOT): + if output == protected or protected in output.parents or output in protected.parents: + raise ValueError('Output must be separate from data, the plan and source code') + if (not backup_tool.is_file() or backup_tool.is_symlink() + or not re.fullmatch('[a-f0-9]{64}', expected_backup_hash) + or digest(backup_tool) != expected_backup_hash): + raise ValueError('Backup tool does not match its independently reviewed SHA-256') + plan, journal = validate_plan(directory) + for field in ('release', 'modules', 'data', 'environment'): + protected = Path(plan['config'][field]).resolve() + if output == protected or protected in output.parents or output in protected.parents: + raise ValueError('Output overlaps an installed controller path') + for name in LEGACY_UNITS: + state = handoff.unit_state(name) + if state['ActiveState'] != 'active' or state['MainPID'] == '0': + raise ValueError('Free Sleep must be running before preparation') + # Fail before downtime if the same interpreter cannot resolve the source timezone. + importer = ROOT / 'scripts/migration/free_sleep.py' + settings = json.loads((legacy / 'lowdb/settingsDB.json').read_text()) + subprocess.run([sys.executable, str(importer), '--check-timezone', settings['timeZone']], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, timeout=15) + original_hashes = source_hashes(legacy) + handoff.require_thermal_idle(handoff.local_json('/api/deviceStatus')) + output.mkdir(mode=0o700) + record = {'format': FORMAT, 'createdAt': time.time(), 'planHash': digest(directory / 'plan.json'), + 'legacy': journal['legacy'], 'legacyData': str(legacy), 'sourceHashes': original_hashes, + 'backupToolSha256': expected_backup_hash, 'importerSha256': digest(importer), + 'coreStarted': False, 'bootSelectionChanged': False, 'liveDataReplaced': False, + 'readyForCutover': False, 'restoredIdleVerified': False, 'captureComplete': False} + phase(output, record, 'prepared') + try: + phase(output, record, 'stopping_free_sleep') + handoff.stop_units(LEGACY_UNITS) + require_core_stopped() + if original_hashes != source_hashes(legacy) or digest(backup_tool) != expected_backup_hash: + raise ValueError('Configuration or backup tool changed before capture') + record['quiescedAt'] = time.time() + phase(output, record, 'capturing') + run_tool([sys.executable, str(backup_tool), '--data', str(legacy), '--output', str(output / 'backup')], + output / 'capture.log', 300) + if not all(handoff.stopped(handoff.unit_state(name)) for name in LEGACY_UNITS): + raise RuntimeError('Retained writers restarted during capture') + require_core_stopped() + manifest = json.loads((output / 'backup/manifest.json').read_text()) + if (manifest.get('format') != 'free-sleep-migration/1' + or original_hashes != source_hashes(legacy) + or any(manifest.get('files', {}).get('data/' + name) != expected + for name, expected in original_hashes.items())): + raise ValueError('Captured configuration differs from the quiesced sources') + record.update(captureComplete=True, capturedAt=time.time(), + backupManifestSha256=digest(output / 'backup/manifest.json')) + phase(output, record, 'captured') + except BaseException as error: + record['captureErrorType'] = type(error).__name__ + raise + finally: + restore_retained(directory, output, record) + try: + phase(output, record, 'importing') + run_tool([sys.executable, str(importer), '--backup', str(output / 'backup'), + '--output', str(output / 'candidate')], output / 'import.log', 300) + report = json.loads((output / 'candidate/migration-report.json').read_text()) + if (report.get('format') != 'sleepypod-free-sleep-import/1' + or report.get('readyForCutover') is not False or report.get('controllersChanged') is not False): + raise ValueError('Expected an inert migration review report') + record.update(importedAt=time.time(), reviewItems=len(report['reviewRequired']), + migrationReportSha256=digest(output / 'candidate/migration-report.json')) + phase(output, record, 'imported_for_review') + return record + except BaseException as error: + record['importErrorType'] = type(error).__name__ + phase(output, record, 'import_failed_free_sleep_restored') + raise + + +def recover(directory, output): + """After an interrupted capture, restore unchanged services without repeating any capture/import.""" + record = json.loads((output / 'preparation.json').read_text()) + if record.get('format') != FORMAT or record.get('planHash') != digest(directory / 'plan.json'): + raise ValueError('Preparation journal belongs to another plan') + _, journal = validate_plan(directory) + if record.get('legacy') != journal['legacy']: + raise ValueError('Retained service identity differs from the preparation journal') + if record.get('phase') not in ('stopping_free_sleep', 'capturing', 'captured', + 'restoring_free_sleep', 'recovery_required'): + raise ValueError('No interrupted writer capture requires recovery') + restore_retained(directory, output, record) + return record + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('capture', 'recover')) + parser.add_argument('--plan', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--legacy-data', type=Path) + parser.add_argument('--backup-tool', type=Path) + parser.add_argument('--backup-sha256') + args = parser.parse_args() + if args.action == 'capture' and any(value is None for value in (args.legacy_data, args.backup_tool, args.backup_sha256)): + parser.error('Capture requires --legacy-data, --backup-tool and its independently reviewed --backup-sha256') + if os.geteuid() != 0: + raise ValueError('Run the reviewed preparation on the Pod as root') + for folder in (args.plan, *((args.output,) if args.action == 'recover' else ())): + info = folder.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise ValueError('Existing plan and recovery directories must be root-owned and private') + os.umask(0o077) + for event in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(event, handoff.interrupt_trial) + descriptor = os.open(args.plan / 'handoff.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = (prepare(args.plan, args.legacy_data, args.backup_tool, args.backup_sha256, args.output) + if args.action == 'capture' else recover(args.plan, args.output)) + print(json.dumps({key: result[key] for key in ('phase', 'captureComplete', 'restoredIdleVerified', + 'coreStarted', 'readyForCutover')})) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/prepare_offline.py b/scripts/deployment/prepare_offline.py new file mode 100644 index 000000000..fd0d2234f --- /dev/null +++ b/scripts/deployment/prepare_offline.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Prepare a verified, inert Linux ARM64 runtime bundle without touching a Pod.""" +import argparse +import hashlib +import json +import os +import re +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import tarfile +import urllib.request + +NODE_VERSION = '24.22.1' +NODE_ARCHIVE = f'node-v{NODE_VERSION}-linux-arm64.tar.gz' +NODE_SHA256 = '33c37be234ba8bee041961047b19160f4d7d0f164e8e246f78bdd10feff148b3' +SQLITE_VERSION = '12.11.1' +SQLITE_ARCHIVE = f'better-sqlite3-v{SQLITE_VERSION}-node-v137-linux-arm64.tar.gz' +SQLITE_SHA256 = '60da4cbe1b1714c8db62f2ee71a9928cde5303dab57bde14c02debd12a784439' +PNPM_VERSION = '10.34.6' + + +def checksum(path): + digest = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def download(url, destination, expected): + """Retain a downloaded artifact only after checking the reviewed digest.""" + if destination.exists(): + if checksum(destination) != expected: + raise ValueError(f'Cached checksum mismatch: {destination.name}') + return + temporary = destination.with_suffix(destination.suffix + '.partial') + try: + with urllib.request.urlopen(url, timeout=60) as response, temporary.open('xb') as output: + shutil.copyfileobj(response, output) + if checksum(temporary) != expected: + raise ValueError(f'Download checksum mismatch: {destination.name}') + temporary.rename(destination) + finally: + temporary.unlink(missing_ok=True) + + +def extract_verified(archive, destination, prefix): + """Extract regular files/directories, then contained relative symlinks; never follow archive links.""" + with tarfile.open(archive) as source: + members = source.getmembers() + names = set() + links = set() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or '..' in path.parts or not path.parts or path.parts[0] != prefix: + raise ValueError('Unsafe archive path') + if member.name in names: + raise ValueError('Duplicate archive path') + names.add(member.name) + if not (member.isfile() or member.isdir() or member.issym()): + raise ValueError('Unsupported archive member') + if member.issym(): + links.add(path) + target = (destination / member.name).parent / member.linkname + if Path(member.linkname).is_absolute() or not target.resolve().is_relative_to(destination.resolve()): + raise ValueError('Escaping archive link') + for member in members: + path = PurePosixPath(member.name) + if any(parent in links for parent in path.parents): + raise ValueError('Archive member descends through a link') + for member in members: + target = destination / member.name + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + target.parent.mkdir(parents=True, exist_ok=True) + with source.extractfile(member) as content, target.open('xb') as output: + shutil.copyfileobj(content, output) + target.chmod(0o755 if member.mode & 0o111 else 0o644) + for member in members: + if member.issym(): + target = destination / member.name + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(member.linkname) + + +def excluded(name): + """Next tracing may include development databases; none belong in a release.""" + return (name in {'node_modules', '.git', '.codex', '.venv', '__pycache__', 'test-results', '.DS_Store'} + or name.startswith('.env') or '.db' in name or name.endswith(('.sqlite', '.sqlite3', '.pem', '.key'))) + + +def copy_build(source, destination): + for entry in source.iterdir(): + if excluded(entry.name): + continue + target = destination / entry.name + if entry.is_symlink(): + raise ValueError(f'Unexpected build symlink: {entry.name}') + if entry.is_dir(): + target.mkdir() + copy_build(entry, target) + elif entry.is_file(): + shutil.copy2(entry, target) + else: + raise ValueError(f'Unexpected build entry: {entry.name}') + + +def inventory(directory): + """Verify all links stay in the bundle and every native binary targets Linux ARM64.""" + records = [] + for current, directories, files in os.walk(directory, followlinks=False): + for name in sorted(directories + files): + path = Path(current) / name + relative = path.relative_to(directory).as_posix() + if path.is_symlink(): + if not path.resolve(strict=True).is_relative_to(directory.resolve()): + raise ValueError(f'Escaping bundle link: {relative}') + records.append({'path': relative, 'link': os.readlink(path)}) + elif path.is_file(): + if re.search(r'\.(?:node|dylib|so(?:\.\d+)*)$', path.name): + with path.open('rb') as binary: + header = binary.read(20) + if len(header) < 20 or header[:5] != b'\x7fELF\x02' or header[5] != 1 or int.from_bytes(header[18:20], 'little') != 183: + raise ValueError(f'Non-ARM64 native binary: {relative}') + records.append({'path': relative, 'sha256': checksum(path), 'mode': oct(path.stat().st_mode & 0o777)}) + return sorted(records, key=lambda record: record['path']) + + +def prepare(repo, output): + """Use a clean committed build; install target dependencies without running install scripts.""" + if output.exists() or output.is_symlink(): + raise ValueError('Output must be a new directory') + if output.is_relative_to(repo): + raise ValueError('Output must be outside the source checkout') + if subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=all'], cwd=repo).strip(): + raise ValueError('Commit the reviewed source before packaging') + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo, text=True).strip() + standalone = repo / '.next/standalone' + build = json.loads((standalone / '.git-info').read_text()) + if not re.fullmatch(r'[a-f0-9]{7,40}', build.get('commitHash', '')) or not commit.startswith(build['commitHash']): + raise ValueError('Rebuild after committing; build identity does not match HEAD') + config = json.loads((repo / '.next/required-server-files.json').read_text())['config'] + if config.get('env', {}).get('NEXT_PUBLIC_DEMO'): + raise ValueError('A demo build cannot become a Pod runtime') + if subprocess.check_output(['pnpm', '--version'], text=True).strip() != PNPM_VERSION: + raise ValueError(f'Use pnpm {PNPM_VERSION} for the release bundle') + for required in ['server.js', '.next/BUILD_ID', 'public/vendor/three.module.min.js', + 'src/db/migrations/meta/_journal.json', 'src/db/biometrics-migrations/meta/_journal.json', + 'biometrics-worker/worker.cjs']: + if not (standalone / required).is_file(): + raise ValueError(f'Build is missing {required}') + output.mkdir(mode=0o700) + try: + application = output / 'app' + application.mkdir() + copy_build(standalone, application) + probe = application / 'scripts/deployment' + probe.mkdir(parents=True, exist_ok=True) + shutil.copy2(repo / 'scripts/deployment/probe_runtime.mjs', probe / 'probe_runtime.mjs') + shutil.copy2(repo / 'scripts/deployment/maintenance_broker.py', probe / 'maintenance_broker.py') + shutil.copy2(repo / 'pnpm-lock.yaml', application / 'pnpm-lock.yaml') + package = json.loads((repo / 'package.json').read_text()) + package.pop('scripts', None) + for relative in package.get('pnpm', {}).get('patchedDependencies', {}).values(): + patch = PurePosixPath(relative) + if patch.is_absolute() or '..' in patch.parts or excluded(patch.name): + raise ValueError('Unsafe dependency patch path') + target = application / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(repo / relative, target) + (application / 'package.json').write_text(json.dumps(package, indent=2) + '\n') + (application / 'pnpm-workspace.yaml').write_text('supportedArchitectures:\n os: [linux]\n cpu: [arm64]\n libc: [glibc]\nonlyBuiltDependencies: []\n') + subprocess.run(['pnpm', 'install', '--prod', '--frozen-lockfile', '--ignore-scripts'], cwd=application, check=True) + sqlite = (application / 'node_modules/better-sqlite3').resolve(strict=True) + if json.loads((sqlite / 'package.json').read_text())['version'] != SQLITE_VERSION: + raise ValueError('Review and pin the new SQLite prebuild before packaging') + artifacts = output / 'artifacts' + artifacts.mkdir() + download(f'https://nodejs.org/dist/v{NODE_VERSION}/{NODE_ARCHIVE}', artifacts / NODE_ARCHIVE, NODE_SHA256) + download(f'https://github.com/WiseLibs/better-sqlite3/releases/download/v{SQLITE_VERSION}/{SQLITE_ARCHIVE}', artifacts / SQLITE_ARCHIVE, SQLITE_SHA256) + runtime = output / 'runtime' + runtime.mkdir() + extract_verified(artifacts / NODE_ARCHIVE, runtime, f'node-v{NODE_VERSION}-linux-arm64') + extract_verified(artifacts / SQLITE_ARCHIVE, sqlite, 'build') + with (output / 'source.tar.gz').open('xb') as source: + subprocess.run(['git', 'archive', '--format=tar.gz', commit], cwd=repo, stdout=source, check=True) + shutil.copy2(repo / 'LICENSE', output / 'LICENSE') + current = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo, text=True).strip() + if current != commit or subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=all'], cwd=repo).strip(): + raise ValueError('Source changed during packaging; rebuild the committed revision') + manifest = {'format': 'sleepypod-offline-runtime/1', 'sourceCommit': commit, + 'maintenanceProtocol': 1, + 'node': {'version': NODE_VERSION, 'sha256': NODE_SHA256}, + 'sqlite': {'version': SQLITE_VERSION, 'sha256': SQLITE_SHA256}, + 'pnpm': PNPM_VERSION, 'controllerStarted': False, + 'application': inventory(application), 'runtime': inventory(runtime), + 'sourceSha256': checksum(output / 'source.tar.gz')} + (output / 'bundle-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + archive = output / 'sleepypod-runtime.tar.gz' + with tarfile.open(archive, 'w:gz', dereference=False) as bundle: + for name in ['app', 'runtime', 'source.tar.gz', 'LICENSE', 'bundle-manifest.json']: + bundle.add(output / name, arcname=name) + (output / 'sleepypod-runtime.sha256').write_text(f'{checksum(archive)} {archive.name}\n') + print(json.dumps({'archive': str(archive), 'sourceCommit': commit, 'controllerStarted': False})) + except BaseException: + # Only this newly created directory is removed; existing output was refused above. + shutil.rmtree(output) + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + prepare(Path(__file__).resolve().parents[2], args.output.absolute()) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/probe_maintenance.py b/scripts/deployment/probe_maintenance.py new file mode 100644 index 000000000..5d23431c7 --- /dev/null +++ b/scripts/deployment/probe_maintenance.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Inert Linux IPC probe: real dac credentials, synthetic service state, no systemctl.""" +import ctypes +import hashlib +import json +import os +from pathlib import Path +import pwd +import signal +import socket +import tempfile +import time +import uuid + +import maintenance_broker as broker + + +class InertSystem(broker.Systemd): + """Exercise production authorization against synthetic units; never submit effects.""" + def __init__(self, account_uid, units): + super().__init__(account_uid, units=units) + self.peer_pid = None + self.submissions = [] + + def properties(self, unit): + groups = (self.proc/str(self.peer_pid)/'cgroup').read_text().splitlines() + control_group = next(line.split(':', 2)[2] for line in groups if line.split(':', 2)[1] in ('', 'name=systemd')) + if 'NoNewPrivs:\t1' not in (self.proc/str(self.peer_pid)/'status').read_text(): + raise ValueError('Probe child did not retain NoNewPrivileges') + return {'ActiveState': 'active', 'SubState': 'running', 'MainPID': str(self.peer_pid), + 'InvocationID': 'a'*32, 'ControlGroup': control_group, + 'FragmentPath': str(self.units/unit), 'DropInPaths': '', 'Job': '', 'NoNewPrivileges': 'yes'} + + def submit(self, unit): + self.submissions.append(unit) + return True + + +def client(socket_path, account, requests, output): + """Drop credentials before connecting, so SO_PEERCRED reflects the real caller.""" + os.setgroups([]) + os.setgid(account.pw_gid) + os.setuid(account.pw_uid) + if ctypes.CDLL(None, use_errno=True).prctl(38, 1, 0, 0, 0) != 0: + raise OSError('Unable to set NoNewPrivileges') + results = [] + for request in requests: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(3) + connection.connect(str(socket_path)) + connection.sendall((json.dumps(request)+'\n').encode()) + reply = bytearray() + while not reply.endswith(b'\n'): + chunk = connection.recv(1024) + if not chunk: raise ValueError('Incomplete probe reply') + reply.extend(chunk) + results.append(json.loads(reply)['outcome']) + os.write(output, json.dumps(results).encode()) + + +def round_trip(listener, instance, account, requests): + listener_path = Path(listener.getsockname()) + reader, writer = os.pipe() + child = os.fork() + if child == 0: + try: + os.close(reader) + listener.close() + client(listener_path, account, requests, writer) + os._exit(0) + except Exception: + os._exit(1) + os.close(writer) + instance.system.peer_pid = child + try: + for _request in requests: + connection, _address = listener.accept() + with connection: + broker.serve_connection(connection, instance) + # The client has finished its last read before it closes its output pipe. + result = os.read(reader, 4096) + _pid, status = os.waitpid(child, 0) + child = None + if status != 0: raise ValueError('Probe client failed') + return json.loads(result) + finally: + os.close(reader) + if child is not None: + os.kill(child, signal.SIGKILL) + os.waitpid(child, 0) + + +def main(): + if os.geteuid() != 0 or not hasattr(socket, 'SO_PEERCRED'): + raise ValueError('Run the inert probe as root on Linux') + account = pwd.getpwnam('dac') + with tempfile.TemporaryDirectory(prefix='sleepypod-maintenance-probe-', dir='/run') as temporary: + root = Path(temporary) + os.chown(root, 0, account.pw_gid) + root.chmod(0o750) + plan, state, units = (root/name for name in ('plan', 'state', 'units')) + for directory in (plan, state, units): directory.mkdir(mode=0o700) + hashes = {} + for unit in broker.PLAN_UNITS: + contents = b'# Inert probe fixture; never installed or executed.\n' + (units/unit).write_bytes(contents) + (units/unit).chmod(0o400) + hashes[unit] = hashlib.sha256(contents).hexdigest() + (plan/'plan.json').write_text(json.dumps({'format': 'sleepypod-service-plan/1', 'units': hashes})) + (plan/'plan.json').chmod(0o600) + (plan/'handoff.lock').touch(mode=0o400) + system = InertSystem(account.pw_uid, units) + instance = broker.Broker(plan, broker.StateStore(state), system) + listener_path = str(root/'control.sock') + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as listener: + listener.bind(listener_path) + os.chown(listener_path, 0, account.pw_gid) + os.chmod(listener_path, 0o660) + listener.listen(1) + listener.settimeout(5) + def request(): + issued = int(time.time()*1000) + return {'version': 1, 'requestId': str(uuid.uuid4()), 'invocationId': 'a'*32, + 'unit': broker.CORE, 'issuedAt': issued, 'expiresAt': issued+5000} + refused = round_trip(listener, instance, pwd.getpwuid(0), [request()]) + permitted = request() + expired = {**request(), 'issuedAt': 1, 'expiresAt': 5001} + accepted = round_trip(listener, instance, account, [permitted, permitted, expired]) + if refused != ['rejected'] or accepted != ['accepted', 'accepted', 'rejected'] or system.submissions != [broker.CORE]: + raise ValueError('Unexpected inert probe outcome') + print(json.dumps({'rootCaller': refused, 'dacNoNewPrivileges': accepted, + 'simulatedSubmissions': len(system.submissions), 'physicalEffects': False, + 'serviceState': 'synthetic', 'temporaryFilesRemovedOnExit': True})) + + +if __name__ == '__main__': main() diff --git a/scripts/deployment/probe_modules.py b/scripts/deployment/probe_modules.py new file mode 100644 index 000000000..66e4593a8 --- /dev/null +++ b/scripts/deployment/probe_modules.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Check installed sensor dependencies without importing daemons or opening hardware.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile + +MODULE_IMPORTS = { + 'piezo-processor': ['cbor2', 'nats', 'numpy', 'scipy.signal', 'heartpy'], + 'sleep-detector': ['cbor2', 'nats', 'numpy'], + 'environment-monitor': ['cbor2', 'nats'], + 'calibrator': ['cbor2', 'nats'], + 'cover-buttons': ['cbor2'], +} + +IMPORT_PROBE = '''import importlib, importlib.metadata, json, sys +for name in sys.argv[1:]: + importlib.import_module(name) +if 'numpy' in sys.argv: + import numpy as np + assert float(np.arange(5).sum()) == 10 +if 'scipy.signal' in sys.argv: + from scipy.signal import welch + spectrum = welch(np.sin(np.arange(2048) / 20), fs=500) + assert all(np.isfinite(part).all() for part in spectrum) +print(json.dumps({'python': sys.version.split()[0], 'imports': 'ok', + 'packages': {item.metadata['Name']: item.version for item in importlib.metadata.distributions()}})) +''' + + +def probe_module(root, name): + """Use the real venv in isolation; tests must not stub missing runtime imports.""" + folder = root / name + fingerprints = { + filename: hashlib.sha256((folder / filename).read_bytes()).hexdigest() + for filename in ['pyproject.toml', 'uv.lock'] + } + with tempfile.TemporaryDirectory(prefix='sleepypod-import-probe-') as temporary: + result = subprocess.run( + [str(folder / '.venv/bin/python'), '-I', '-c', IMPORT_PROBE, *MODULE_IMPORTS[name]], + cwd=temporary, capture_output=True, text=True, timeout=45, + env={**os.environ, 'MPLCONFIGDIR': temporary}, + ) + if result.returncode: + raise RuntimeError(result.stderr[-2000:] or f'Interpreter exited {result.returncode}') + report = json.loads(result.stdout) + if report.get('imports') != 'ok' or not isinstance(report.get('packages'), dict): + raise ValueError('Invalid dependency probe response') + return {**report, 'files': fingerprints} + + +def probe_modules(root, names): + results = {} + for name in names: + try: + results[name] = {'ok': True, **probe_module(root, name)} + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error: + results[name] = {'ok': False, 'error': str(error)[-2000:]} + return {'probeVersion': 1, 'ok': all(item['ok'] for item in results.values()), + 'applicationImported': False, 'servicesStarted': False, 'modules': results} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--modules-root', type=Path, required=True) + parser.add_argument('--module', choices=MODULE_IMPORTS, action='append') + parser.add_argument('--output', type=Path, help='Optional new private JSON receipt; never overwritten') + arguments = parser.parse_args() + report = probe_modules(arguments.modules_root.resolve(), arguments.module or list(MODULE_IMPORTS)) + encoded = json.dumps(report, indent=2) + '\n' + if arguments.output: + descriptor = os.open(arguments.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, 'w') as output: + output.write(encoded) + print(encoded, end='') + return 0 if report['ok'] else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/deployment/probe_runtime.mjs b/scripts/deployment/probe_runtime.mjs new file mode 100644 index 000000000..ac74521bb --- /dev/null +++ b/scripts/deployment/probe_runtime.mjs @@ -0,0 +1,30 @@ +/* Only in-memory SQLite and image encoding: never import the application or hardware. */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import process from 'node:process' +import Database from 'better-sqlite3' + +async function main() { + assert.equal(process.platform, 'linux') + assert.equal(process.arch, 'arm64') + assert.equal(process.versions.node, '24.22.1') + assert.equal(process.versions.modules, '137') + // Resolve Next's image dependency from its own package, as its optimizer does. + const require = createRequire(import.meta.url) + const sharp = createRequire(require.resolve('next/package.json'))('sharp') + const database = new Database(':memory:') + try { + assert.equal(database.prepare('SELECT 17 AS result').get().result, 17) + assert.equal(database.pragma('integrity_check', { simple: true }), 'ok') + } + finally { database.close() } + const image = await sharp({ create: { width: 1, height: 1, channels: 3, background: '#000000' } }).png().toBuffer() + assert.equal((await sharp(image).metadata()).width, 1) + process.stdout.write(`${JSON.stringify({ platform: process.platform, architecture: process.arch, + node: process.versions.node, sqlite: 'ok', imageCodec: 'ok', applicationStarted: false })}\n`) +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`) + process.exitCode = 1 +}) diff --git a/scripts/deployment/recovery_privacy.py b/scripts/deployment/recovery_privacy.py new file mode 100644 index 000000000..0348cf574 --- /dev/null +++ b/scripts/deployment/recovery_privacy.py @@ -0,0 +1,38 @@ +"""Hold legacy recovery once personal data exists, pending a durable recovery protocol.""" +from contextlib import closing +import sqlite3 +import stat + +REQUIRED_TABLES = {'device_settings', 'side_settings', 'alarm_schedules', 'power_schedules'} + + +def require_legacy_recovery_data(directory): + """Check without reading private rows; even unassigned drafts prohibit legacy recovery.""" + path = directory / 'sleepypod.db' + try: + if (not directory.is_absolute() or any(parent.is_symlink() for parent in (directory, *directory.parents)) + or not directory.is_dir() or not stat.S_ISREG(path.lstat().st_mode)): + raise ValueError('Recovery requires a regular configuration database') + for suffix in ('-wal', '-shm', '-journal'): + sidecar = path.with_name(path.name + suffix) + if sidecar.exists() or sidecar.is_symlink(): + if not stat.S_ISREG(sidecar.lstat().st_mode): + raise ValueError('Recovery database has an unsafe sidecar') + with closing(sqlite3.connect(path.resolve().as_uri() + '?mode=ro', uri=True, timeout=1)) as database: + # One read transaction includes committed WAL data and pins the schema. + database.execute('BEGIN') + tables = [row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")] + if (not REQUIRED_TABLES.issubset(tables) + or database.execute('PRAGMA quick_check').fetchall() != [('ok',)]): + raise ValueError('Cannot verify the household recovery boundary; configuration is incomplete or corrupt') + for name in tables: + if name != 'households' and not name.startswith('household_'): + continue + # Legacy browser sessions exist before anyone creates a personal account. + if name == 'household_browser_scopes': + continue + quoted = '"' + name.replace('"', '""') + '"' + if database.execute('SELECT 1 FROM ' + quoted + ' LIMIT 1').fetchone(): + raise ValueError('Personal household data requires qualified recovery; keep the current release and data') + except (OSError, sqlite3.Error) as error: + raise ValueError('Cannot verify the household recovery boundary; keep the current release and data') from error diff --git a/scripts/deployment/replace_plan.py b/scripts/deployment/replace_plan.py new file mode 100644 index 000000000..a531114a8 --- /dev/null +++ b/scripts/deployment/replace_plan.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Replace stopped, disabled Core units with another reviewed release plan.""" +import argparse +from contextlib import ExitStack +import fcntl +import hashlib +import json +import os +from pathlib import Path +import stat +import tempfile + +import handoff +from maintenance_plan import SERVICE as MAINTENANCE_SERVICE, SOCKET as MAINTENANCE_SOCKET +from service_plan import CORE_UNITS, LEGACY_UNITS, read_plan + + +def replace_unit(path, content): + descriptor, temporary = tempfile.mkstemp(prefix='.sleepypod-unit-', dir=path.parent) + temporary = Path(temporary) + try: + with os.fdopen(descriptor, 'wb') as output: + output.write(content) + output.flush() + os.fsync(output.fileno()) + temporary.chmod(0o644) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def replace_plan(previous, directory): + """Switch only unit files; on failure restore exact prior bytes without starting Core.""" + if previous.resolve() == directory.resolve(): + raise ValueError('Use a distinct new service plan') + old_plan, plan = read_plan(previous), read_plan(directory) + old_journal = json.loads((previous / 'handoff.json').read_text()) + if old_journal.get('format') != 'sleepypod-handoff/1' or old_journal.get('unitHashes') != old_plan['units']: + raise ValueError('Previous journal does not belong to its plan') + if old_journal.get('phase') not in ('installed_not_started', 'trial_restored'): + raise ValueError('Recover the previous handoff before changing its release') + journal_path = directory / 'handoff.json' + if journal_path.exists() or journal_path.is_symlink(): + raise ValueError('New plan already has a handoff journal') + for name in ('data', 'environment', 'dacSocket', 'rawData'): + if old_plan['config'][name] != plan['config'][name]: + raise ValueError('A release replacement cannot change ' + name) + if 'maintenanceSocket' in old_plan['config'] or 'maintenanceSocket' in plan['config']: + for name in (MAINTENANCE_SOCKET, MAINTENANCE_SERVICE): + path = handoff.UNIT_DIRECTORY / name + if path.exists() or path.is_symlink() or handoff.unit_state(name).get('LoadState') != 'not-found': + raise ValueError('Roll back the bound maintenance helper before changing the controller plan') + handoff.validate_installed(old_plan) + handoff.validate_legacy(old_journal) + for name in CORE_UNITS: + state = handoff.unit_state(name) + if not handoff.stopped(state) or state['UnitFileState'] != 'disabled': + raise ValueError('Core must be stopped and disabled before replacing its plan') + for name in LEGACY_UNITS: + state = handoff.unit_state(name) + if state['ActiveState'] != 'active' or state['MainPID'] == '0' or state['UnitFileState'] != 'enabled': + raise ValueError('Retained controller must be active and enabled') + + journal = {'format': 'sleepypod-handoff/1', 'phase': 'replacing_stopped_release', + 'legacy': old_journal['legacy'], 'unitHashes': plan['units'], + 'createdUnits': [], 'replacedUnits': [], 'previousPlan': str(previous), + 'bootSelectionChanged': False} + handoff.private_json(journal_path, journal) + try: + for name in CORE_UNITS: + path = handoff.UNIT_DIRECTORY / name + if path.is_symlink() or hashlib.sha256(path.read_bytes()).hexdigest() != old_plan['units'][name]: + raise ValueError('Installed unit changed during replacement: ' + name) + replace_unit(path, (directory / name).read_bytes()) + journal['replacedUnits'].append(name) + handoff.private_json(journal_path, journal) + handoff.systemctl('daemon-reload') + handoff.validate_installed(plan) + journal['phase'] = 'installed_not_started' + handoff.private_json(journal_path, journal) + except BaseException: + # Refuse unrelated edits, including a change made while handling failure. + for name in CORE_UNITS: + path = handoff.UNIT_DIRECTORY / name + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if path.is_symlink() or digest not in (old_plan['units'][name], plan['units'][name]): + raise RuntimeError('Unit changed; replacement recovery needs review: ' + name) + for name in CORE_UNITS: + replace_unit(handoff.UNIT_DIRECTORY / name, (previous / name).read_bytes()) + handoff.systemctl('daemon-reload') + handoff.validate_installed(old_plan) + journal['phase'] = 'replacement_failed_previous_restored' + handoff.private_json(journal_path, journal) + raise + return journal + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--previous-plan', required=True, type=Path) + parser.add_argument('--plan', required=True, type=Path) + args = parser.parse_args() + if os.geteuid() != 0: + raise ValueError('Run on the Pod as root') + os.umask(0o077) + with ExitStack() as stack: + # Lock both plans in stable order, including against a concurrent trial. + for directory in sorted({args.previous_plan.absolute(), args.plan.absolute()}): + info = directory.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077: + raise ValueError('Plan directories must be root-owned and private') + descriptor = os.open(directory / 'handoff.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + lock = stack.enter_context(os.fdopen(descriptor, 'w')) + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + print(json.dumps(replace_plan(args.previous_plan, args.plan))) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/service_plan.py b/scripts/deployment/service_plan.py new file mode 100644 index 000000000..fda1bbbec --- /dev/null +++ b/scripts/deployment/service_plan.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Render isolated Pod services for review; never install, enable or start them.""" +import argparse +import hashlib +import json +from pathlib import Path +import re + +from prepare_offline import NODE_VERSION + +MODULES = ('piezo-processor', 'sleep-detector', 'environment-monitor', 'calibrator') +LEGACY_UNITS = ('free-sleep.service', 'free-sleep-stream.service') +CORE_UNIT = 'sleepypod.service' +CORE_UNITS = (CORE_UNIT,) + tuple('sleepypod-' + name + '.service' for name in MODULES) +PLAN_FORMAT = 'sleepypod-service-plan/1' +MAINTENANCE_SOCKET = '/run/sleepypod-maintenance/control.sock' + + +def pod_path(value): + """Avoid systemd expansion, line injection and accidental use of shared roots.""" + if not isinstance(value, str) or not re.fullmatch(r'/[A-Za-z0-9_./-]+', value): + raise ValueError('Use an absolute Pod path without whitespace or expansion characters') + path = Path(value) + if '..' in path.parts or str(path) != value or value == '/': + raise ValueError('Use a canonical non-root Pod path') + return value + + +def validate_config(config): + required = {'release', 'modules', 'data', 'environment', 'dacSocket', 'rawData'} + if not isinstance(config, dict) or set(config) not in (required, required | {'maintenanceSocket'}): + raise ValueError('Unexpected service configuration fields') + if 'maintenanceSocket' in config and config['maintenanceSocket'] != MAINTENANCE_SOCKET: + raise ValueError('Use the fixed private maintenance socket') + for value in config.values(): + pod_path(value) + # Never let a new runtime or database overwrite the retained controller. + for name in ('release', 'modules', 'data', 'environment'): + if not config[name].startswith('/persistent/sleepypod-unified/'): + raise ValueError('Managed paths must be beneath /persistent/sleepypod-unified/') + directories = [Path(config[name]) for name in ('release', 'modules', 'data')] + for index, directory in enumerate(directories): + for other in directories[index + 1:]: + if directory == other or directory in other.parents or other in directory.parents: + raise ValueError('Release, modules and data must be separate directories') + environment = Path(config['environment']) + if any(environment == directory or directory in environment.parents for directory in directories): + raise ValueError('Keep the credential environment outside source and data directories') + if not config['dacSocket'].endswith('/dac.sock'): + raise ValueError('Expected an explicitly inspected dac.sock path') + return config + + +def render(config): + """Use the existing dac account and exact private paths, without global runtime changes.""" + validate_config(config) + release, modules, data = (config[name] for name in ('release', 'modules', 'data')) + maintenance = f'Environment="SLEEPYPOD_MAINTENANCE_SOCKET={MAINTENANCE_SOCKET}"\n' if 'maintenanceSocket' in config else '' + shared = f'''User=dac +Group=dac +UMask=0077 +Environment="DATABASE_URL=file:{data}/sleepypod.db" +Environment="BIOMETRICS_DATABASE_URL=file:{data}/biometrics.db" +Environment="RAW_DATA_DIR={config['rawData']}" +Environment="CALIBRATION_TRIGGER_PATH={data}/.calibrate-trigger" +Environment="SLEEP_DETECTOR_STATE_PATH={data}/sleep-detector-state.json" +Environment="PYTHONDONTWRITEBYTECODE=1" +Environment="PYTHONNOUSERSITE=1" +Environment="OPENBLAS_NUM_THREADS=1" +Environment="OMP_NUM_THREADS=1" +Restart=on-failure +RestartSec=10 +TimeoutStopSec=30 +KillMode=control-group +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ProtectKernelTunables=true +ReadWritePaths={data} +''' + core = f'''# Generated by scripts/deployment/service_plan.py. Review before installation. +[Unit] +Description=SleepyPod unified controller (isolated runtime) +After=network.target nats-server.service {' '.join(LEGACY_UNITS)} +Conflicts={' '.join(LEGACY_UNITS)} +Wants={' '.join(CORE_UNITS[1:])} +RequiresMountsFor={release} {data} + +[Service] +Type=simple +{shared}WorkingDirectory={release}/app +EnvironmentFile={config['environment']} +{maintenance}Environment="NODE_ENV=production" +Environment="HOSTNAME=0.0.0.0" +Environment="PORT=3000" +Environment="PIEZO_WS_PORT=3001" +Environment="PIEZO_SENSOR_SOURCE=nats" +Environment="DAC_SOCK_PATH={config['dacSocket']}" +Environment="HOMEKIT_DIR={data}/homekit" +Environment="EXPORT_STAGING_DIR={data}/export-staging" +Environment="PATH={release}/runtime/node-v{NODE_VERSION}-linux-arm64/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" +ReadWritePaths={Path(config['dacSocket']).parent} +ExecStart={release}/runtime/node-v{NODE_VERSION}-linux-arm64/bin/node {release}/app/server.js +MemoryMax=1536M + +[Install] +WantedBy=multi-user.target +''' + units = {CORE_UNIT: core} + for name in MODULES: + units['sleepypod-' + name + '.service'] = f'''# Generated by scripts/deployment/service_plan.py. Review before installation. +[Unit] +Description=SleepyPod {name} (isolated data) +After=sleepypod.service nats-server.service {' '.join(LEGACY_UNITS)} +BindsTo=sleepypod.service +PartOf=sleepypod.service +Conflicts={' '.join(LEGACY_UNITS)} +RequiresMountsFor={modules} {data} + +[Service] +Type=simple +{shared}WorkingDirectory={modules}/{name} +Environment="MPLCONFIGDIR=/tmp/sleepypod-matplotlib" +ExecStart={modules}/{name}/.venv/bin/python {modules}/{name}/main.py +MemoryMax={'1024M' if name == 'calibrator' else '256M'} +CPUQuota={'25%' if name == 'calibrator' else '50%'} + +[Install] +WantedBy=multi-user.target +''' + return units + + +def prepare(config, output): + units = render(config) + if output.exists() or output.is_symlink(): + raise ValueError('Output must be a new directory') + output.mkdir(mode=0o700) + for name, content in units.items(): + target = output / name + target.write_text(content) + target.chmod(0o600) + manifest = {'format': PLAN_FORMAT, 'config': config, + 'units': {name: hashlib.sha256(content.encode()).hexdigest() for name, content in units.items()}, + 'servicesStarted': False, 'bootSelectionChanged': False} + (output / 'plan.json').write_text(json.dumps(manifest, indent=2) + '\n') + (output / 'plan.json').chmod(0o600) + return manifest + + +def read_plan(directory): + if (directory / 'plan.json').is_symlink(): + raise ValueError('Plan must be a regular file') + plan = json.loads((directory / 'plan.json').read_text()) + if plan.get('format') != PLAN_FORMAT: + raise ValueError('Unsupported service plan') + expected = render(plan['config']) + if set(plan.get('units', {})) != set(expected): + raise ValueError('Unexpected service membership') + for name, content in expected.items(): + target = directory / name + if target.is_symlink() or target.read_text() != content: + raise ValueError('Service differs from the reviewed template: ' + name) + if hashlib.sha256(content.encode()).hexdigest() != plan['units'][name]: + raise ValueError('Service checksum mismatch: ' + name) + return plan + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', required=True, type=Path) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + prepare(json.loads(args.config.read_text()), args.output) + print(json.dumps({'plan': str(args.output), 'servicesStarted': False})) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/stage_sensors.py b/scripts/deployment/stage_sensors.py new file mode 100644 index 000000000..99ef97aa9 --- /dev/null +++ b/scripts/deployment/stage_sensors.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Stage immutable sensor source from a verified release, reusing unchanged locked environments.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import tarfile + +import maintenance_install +import service_plan + + +def release_files(release): + """Only regular, bounded source files from the release's hashed archive are eligible.""" + maintenance_install.verify_release(release) + manifest = json.loads(maintenance_install.owned_bytes(release/'bundle-manifest.json', limit=32*1024*1024)) + archive = maintenance_install.owned_bytes(release/'source.tar.gz', limit=128*1024*1024) + if hashlib.sha256(archive).hexdigest() != manifest.get('sourceSha256'): + raise ValueError('Sensor source archive checksum mismatch') + files = {} + total = 0 + with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as source: + for member in source: + path = PurePosixPath(member.name) + if path.is_absolute() or '..' in path.parts: + raise ValueError('Unsafe sensor archive path') + if not path.parts or path.parts[0] != 'modules': + continue + if member.isdir(): + continue + relative = Path(*path.parts[1:]) + total += member.size + if (not member.isfile() or '.venv' in relative.parts or not relative.parts + or relative in files or member.size > 16*1024*1024 or total > 128*1024*1024): + raise ValueError('Unsupported or excessive sensor archive entry') + files[relative] = source.extractfile(member).read() + if not files: + raise ValueError('Release contains no sensor source') + return files + + +def check_paths(previous, candidate): + root = maintenance_install.ROOT/'sensors' + for directory in (previous, candidate): + if directory.name != 'modules' or directory.parent.parent != root: + raise ValueError('Use separate versioned managed sensor directories') + if previous == candidate: + raise ValueError('Do not overwrite active sensor source') + maintenance_install.secure_path(previous) + maintenance_install.secure_path(candidate.parent) + + +def environments(previous, files): + """Dependency upgrades need a new locked install; source-only updates may reuse venvs.""" + result = {} + for name in service_plan.MODULES: + for filename in ('pyproject.toml', 'uv.lock'): + path = previous/name/filename + maintenance_install.secure_path(path) + if maintenance_install.owned_bytes(path, limit=8*1024*1024) != files.get(Path(name)/filename): + raise ValueError('Sensor dependencies changed; prepare a new locked environment: '+name) + environment = previous/name/'.venv' + # A previous source-only release may itself reuse an older environment. + resolved = environment.resolve(strict=True) + if not resolved.is_relative_to(maintenance_install.ROOT/'sensors'): + raise ValueError('Sensor environment escapes managed storage') + maintenance_install.secure_path(resolved) + if not (resolved/'bin/python').is_file(): + raise ValueError('Sensor interpreter is missing: '+name) + result[name] = resolved + return result + + +def verify(previous, candidate, release): + """Check the complete new source tree and exact environment links before switching services.""" + check_paths(previous, candidate) + maintenance_install.secure_path(candidate) + files = release_files(release) + runtimes = environments(previous, files) + expected = set(files) + for name, runtime in runtimes.items(): + relative = Path(name)/'.venv' + expected.add(relative) + link = candidate/relative + if not link.is_symlink() or os.readlink(link) != str(runtime): + raise ValueError('Sensor environment link changed: '+name) + actual = set() + for folder, directories, names in os.walk(candidate, followlinks=False): + for name in directories + names: + path = Path(folder)/name + if path.is_symlink() or not path.is_dir(): + actual.add(path.relative_to(candidate)) + if actual != expected: + raise ValueError('Sensor source membership changed') + for relative, contents in files.items(): + path = candidate/relative + maintenance_install.secure_path(path) + if maintenance_install.owned_bytes(path, limit=16*1024*1024) != contents: + raise ValueError('Sensor source differs from release: '+str(relative)) + return {'verifiedSourceFiles': len(files), 'reusedEnvironments': list(runtimes), 'servicesStarted': False} + + +def prepare(previous, candidate, release): + check_paths(previous, candidate) + if candidate.exists() or candidate.is_symlink(): + raise ValueError('Sensor destination must be new') + files = release_files(release) + runtimes = environments(previous, files) + candidate.mkdir(mode=0o755) + candidate.chmod(0o755) + try: + for relative, contents in files.items(): + target = candidate/relative + target.parent.mkdir(parents=True, exist_ok=True) + for parent in target.parents: + if parent == candidate: + break + parent.chmod(0o755) + target.write_bytes(contents) + target.chmod(0o644) + for name, runtime in runtimes.items(): + (candidate/name/'.venv').symlink_to(runtime) + return verify(previous, candidate, release) + except BaseException: + shutil.rmtree(candidate) + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--previous', required=True, type=Path) + parser.add_argument('--candidate', required=True, type=Path) + parser.add_argument('--release', required=True, type=Path) + arguments = parser.parse_args() + if os.geteuid() != 0: + raise ValueError('Run as root on the Pod') + print(json.dumps(prepare(arguments.previous, arguments.candidate, arguments.release))) + + +if __name__ == '__main__': + main() diff --git a/scripts/deployment/test_activate.py b/scripts/deployment/test_activate.py new file mode 100644 index 000000000..39a5b5d50 --- /dev/null +++ b/scripts/deployment/test_activate.py @@ -0,0 +1,435 @@ +import hashlib +import json +from pathlib import Path +import sqlite3 +import tempfile +import time +import unittest +from unittest.mock import patch + +import activate +import activation_checks +import handoff +from service_plan import CORE_UNIT, CORE_UNITS, LEGACY_UNITS +import test_handoff + + +def idle(core=False): + side = {'heatingDuration': 0} if core else {'secondsRemaining': 0, 'isOn': False, 'isAlarmVibrating': False} + return {'isPriming': False, ('leftSide' if core else 'left'): side, + ('rightSide' if core else 'right'): dict(side)} + + +def observation(): + return {'controller': {'id': 'sleepypod-core', 'revision': 'aaaaaaa'}, + 'device': {'freshness': 'fresh'}, 'temperatureRuntime': 'ready'} + + +class ActivationTests(unittest.TestCase): + def setUp(self): + test_handoff.HandoffTests.setUp(self) + self.failure = None + self.elapsed = 0 + self.overlaps = [] + handoff.install(self.directory) + self.journal = json.loads((self.directory / 'handoff.json').read_text()) + self.journal.update(phase='trial_restored', trialStartedAt=time.time() - 35, + trialEndedAt=time.time() - 1, settleSeconds=30, trialSucceeded=True, + requireIdle=True, restoredIdleVerified=True, observation=observation()) + self.save_trial() + replacements = [(handoff, 'prerequisites', lambda _: 'a' * 40), + (handoff, 'read_owner_key', lambda _: 'key-never-recorded'), + (handoff, 'local_json', lambda path, *_: idle(path == '/api/device/status')), + (handoff, 'restored_idle', lambda *_: True), + (activation_checks, 'candidate_ready', lambda _: None), + (activation_checks, 'unchanged_source', lambda *_: {'settings': 'digest'})] + for module, name, replacement in replacements: + context = patch.object(module, name, replacement) + context.start() + self.addCleanup(context.stop) + + def save_trial(self): + (self.directory / 'handoff.json').write_text(json.dumps(self.journal)) + + def control(self, *arguments): + test_handoff.HandoffTests.control(self, *arguments) + action, *names = arguments + if action == 'start' and names == [CORE_UNIT]: + for name in CORE_UNITS: + self.states[name].update(ActiveState='active', MainPID='456') + if action in ('enable', 'disable'): + for name in names: + self.states[name]['UnitFileState'] = 'enabled' if action == 'enable' else 'disabled' + if action == 'start': + if any(self.states[name]['MainPID'] != '0' for name in LEGACY_UNITS) and any(self.states[name]['MainPID'] != '0' for name in CORE_UNITS): + self.overlaps.append(arguments) + if arguments == self.failure: + self.failure = None + raise OSError('injected partial service operation failure') + return '' + + def sleep(self, seconds): + self.elapsed += seconds + + def run_activation(self, observe=None): + return activate.activate(self.directory, self.root / 'legacy', 60, + observe=observe or (lambda *_: observation()), + sleep=self.sleep, monotonic=lambda: self.elapsed) + + def assert_restored(self): + activate.assert_boot_selection(core=False) + activate.assert_ownership(core=False) + self.assertEqual(self.overlaps, []) + + def test_selects_core_after_stable_window_then_rolls_back_without_demanding_idle(self): + result = self.run_activation() + self.assertEqual(result['phase'], 'active') + self.assertGreaterEqual(self.elapsed, 30) + activate.assert_boot_selection(core=True) + self.assertEqual(self.overlaps, []) + self.assertFalse(result['physicalOperationVerified']) + text = (self.directory / 'activation.json').read_text() + self.assertNotIn('key-never-recorded', text) + self.assertEqual((self.directory / 'activation.json').stat().st_mode & 0o777, 0o600) + # Rollback after activation must not demand/cancel an idle bed or change data. + with patch.object(handoff, 'restored_idle', side_effect=AssertionError('live heating is allowed')): + restored = activate.rollback(self.directory) + activate.rollback(self.directory) + self.assertEqual(restored['phase'], 'rolled_back') + self.assert_restored() + + def test_legacy_rollback_holds_personal_data_before_and_after_stopping(self): + self.run_activation() + for effects, should_stop in [([ValueError('personal recovery')], False), ([None, ValueError('personal recovery')], True)]: + self.calls.clear() + with patch.object(handoff, 'require_legacy_recovery_data', side_effect=effects), self.assertRaisesRegex(ValueError, 'personal recovery'): + activate.rollback(self.directory) + self.assertEqual(any(call[0] == 'stop' for call in self.calls), should_stop) + self.assertFalse(any(call[0] in ('start', 'disable', 'enable') for call in self.calls)) + + def test_unqualified_trial_never_mutates_services(self): + original = dict(self.journal) + cases = {'phase': 'installed_not_started', 'trialSucceeded': False, 'requireIdle': False, + 'restoredIdleVerified': False, 'settleSeconds': 0, + 'trialEndedAt': time.time() - 86401, 'trialStartedAt': float('nan')} + for field, value in cases.items(): + with self.subTest(field=field): + self.journal = {**original, field: value} + self.save_trial() + with self.assertRaises(ValueError): + self.run_activation() + self.assertEqual(self.calls, [('daemon-reload',)]) + self.assertFalse((self.directory / 'activation.json').exists()) + + def test_foreign_units_are_rejected_before_service_changes(self): + (self.unit_directory / CORE_UNIT).write_text('unrelated service') + with self.assertRaises(ValueError): + self.run_activation() + self.assertEqual(self.calls, [('daemon-reload',)]) + + def test_incomplete_candidate_is_rejected_before_service_changes(self): + with patch.object(activation_checks, 'candidate_ready', side_effect=ValueError('Imported history is missing')): + with self.assertRaisesRegex(ValueError, 'Imported history'): + self.run_activation() + self.assertEqual(self.calls, [('daemon-reload',)]) + self.assertFalse((self.directory / 'activation.json').exists()) + + def test_configuration_change_after_stop_restores_without_starting_core(self): + with patch.object(activation_checks, 'unchanged_source', side_effect=[{'settings': 'digest'}, ValueError('changed')]): + with self.assertRaisesRegex(ValueError, 'changed'): + self.run_activation() + self.assertNotIn(('start', CORE_UNIT), self.calls) + self.assert_restored() + + def test_failed_core_start_restores(self): + self.start_failure = True + with self.assertRaises(OSError): + self.run_activation() + self.assert_restored() + + def test_surviving_legacy_process_never_allows_core_start(self): + self.stop_failure = True + with self.assertRaises(RuntimeError): + self.run_activation() + self.assertNotIn(('start', CORE_UNIT), self.calls) + self.assert_restored() + + def test_partial_enable_failure_restores_boot_selection(self): + self.failure = ('enable', CORE_UNIT) + with self.assertRaises(OSError): + self.run_activation() + self.assert_restored() + + def test_partial_disable_failure_restores_boot_selection(self): + self.failure = ('disable', *LEGACY_UNITS) + with self.assertRaises(OSError): + self.run_activation() + self.assert_restored() + + def test_loss_of_readiness_after_enable_restores_boot_selection(self): + def observed(*_): + value = observation() + if self.states[CORE_UNIT]['UnitFileState'] == 'enabled': + value['device']['freshness'] = 'stale' + return value + with self.assertRaisesRegex(RuntimeError, 'readiness changed'): + self.run_activation(observed) + self.assert_restored() + + def test_failed_sensor_withholds_activation(self): + def observed(*_): + self.states[CORE_UNITS[1]].update(ActiveState='failed', MainPID='0') + return observation() + with self.assertRaisesRegex(RuntimeError, 'not active'): + self.run_activation(observed) + self.assert_restored() + + def test_stale_window_resets_the_required_settling_time(self): + def observed(*_): + value = observation() + if self.elapsed == 20: + value['device']['freshness'] = 'stale' + return value + self.run_activation(observed) + self.assertGreaterEqual(self.elapsed, 52) + + def test_readiness_timeout_restores_without_changing_boot_owner(self): + with self.assertRaisesRegex(RuntimeError, '30 seconds'): + self.run_activation(lambda *_: {**observation(), 'temperatureRuntime': 'unavailable'}) + self.assertNotIn(('enable', CORE_UNIT), self.calls) + self.assert_restored() + + def test_unexpected_heat_after_failed_activation_requires_review(self): + self.start_failure = True + with patch.object(handoff, 'restored_idle', side_effect=RuntimeError('heating is active')): + with self.assertRaisesRegex(RuntimeError, 'heating is active'): + self.run_activation() + self.assertEqual(json.loads((self.directory / 'activation.json').read_text())['phase'], 'recovery_required') + self.assert_restored() + + def test_nonzero_heating_during_activation_restores_without_enabling_core(self): + def status(path, *_): + value = idle(path == '/api/device/status') + if path == '/api/device/status': + value['leftSide']['heatingDuration'] = 100 + return value + with patch.object(handoff, 'local_json', status), self.assertRaisesRegex(RuntimeError, 'countdown'): + self.run_activation() + self.assertNotIn(('enable', CORE_UNIT), self.calls) + self.assert_restored() + + def test_interrupt_restores_and_propagates(self): + with self.assertRaises(KeyboardInterrupt): + self.run_activation(lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) + self.assert_restored() + + def test_foreign_unit_during_restore_leaves_explicit_recovery_record(self): + self.run_activation() + (self.unit_directory / LEGACY_UNITS[0]).write_text('unrelated') + with self.assertRaises(ValueError): + activate.rollback(self.directory) + record = json.loads((self.directory / 'activation.json').read_text()) + self.assertEqual(record['phase'], 'recovery_required') + self.assertEqual(record['bootOwner'], 'unknown') + self.assertTrue(all(handoff.stopped(self.states[name]) for name in CORE_UNITS)) + + def test_existing_activation_prevents_trial_or_duplicate_activation(self): + self.run_activation() + calls = list(self.calls) + with self.assertRaisesRegex(ValueError, 'activation journal'): + self.run_activation() + with self.assertRaisesRegex(ValueError, 'activate.py rollback'): + handoff.trial(self.directory, 60) + with self.assertRaisesRegex(ValueError, 'activate.py rollback'): + handoff.require_no_activation(self.directory) + self.assertEqual(self.calls, calls) + + +class ReadOnlyChecksTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.data = self.root / 'candidate' + self.live = self.root / 'live' + self.data.mkdir() + for root in (self.data / 'legacy', self.live): + (root / 'lowdb').mkdir(parents=True) + (root / 'lowdb/settingsDB.json').write_text('{"value":1}') + (root / 'lowdb/schedulesDB.json').write_text('{"value":2}') + + def test_source_comparison_detects_edits_additions_removal_and_symlinks(self): + activation_checks.unchanged_source(self.data, self.live) + path = self.live / 'lowdb/settingsDB.json' + path.write_text('changed') + with self.assertRaisesRegex(ValueError, 'changed since import'): + activation_checks.unchanged_source(self.data, self.live) + path.unlink() + with self.assertRaises(FileNotFoundError): + activation_checks.unchanged_source(self.data, self.live) + path.symlink_to(self.data / 'legacy/lowdb/settingsDB.json') + with self.assertRaisesRegex(ValueError, 'regular file'): + activation_checks.unchanged_source(self.data, self.live) + path.unlink() + path.write_text('{"value":1}') + (self.live / 'left_cap_baseline.json').write_text('{}') + with self.assertRaisesRegex(ValueError, 'changed since import'): + activation_checks.unchanged_source(self.data, self.live) + + def test_rejects_comparing_retained_candidate_to_itself(self): + with self.assertRaisesRegex(ValueError, 'separate'): + activation_checks.unchanged_source(self.data, self.data / 'legacy') + + def prepare_candidate(self): + """Create actual current schemas and an importer-produced standalone history snapshot.""" + from scripts.migration import free_sleep as migration + from scripts.migration import imported_history + root = Path(__file__).resolve().parents[2] + for name, migrations in (('sleepypod.db', 'migrations'), ('biometrics.db', 'biometrics-migrations')): + migration.create_database(self.data / name, root / 'src/db' / migrations).close() + self.source = self.data / 'legacy/free-sleep.db' + self.history = self.data / imported_history.FILENAME + original = sqlite3.connect(self.source) + original.executescript(imported_history.SCHEMA) + original.execute("INSERT INTO vitals VALUES (1,'left',1700000000,60,30,12)") + original.commit() + original.close() + metadata = imported_history.prepare(self.source, self.history, 'America/Los_Angeles', 1700000000000) + self.report = {'format': 'sleepypod-free-sleep-import/1', 'importedHistory': metadata, + 'databaseHashes': {self.history.name: hashlib.sha256(self.history.read_bytes()).hexdigest()}} + self.save_report() + database = sqlite3.connect(self.data / 'sleepypod.db') + self.addCleanup(database.close) + database.execute('INSERT INTO device_settings(id,mqtt_enabled) VALUES (1,0)') + for side in ('left', 'right'): + database.execute('INSERT INTO side_settings(side,name) VALUES (?,?)', (side, side)) + database.execute('INSERT INTO comfort_states(side,state) VALUES (?,?)', (side, '{"config":{"mode":"preview"}}')) + database.commit() + return database + + def save_report(self): + (self.data / 'migration-report.json').write_text(json.dumps(self.report)) + + def test_candidate_guard_uses_real_schema_and_is_read_only(self): + database = self.prepare_candidate() + before = {str(path.relative_to(self.data)): path.read_bytes() for path in self.data.rglob('*') if path.is_file()} + activation_checks.candidate_ready(self.data) + self.assertEqual(before, {str(path.relative_to(self.data)): path.read_bytes() for path in self.data.rglob('*') if path.is_file()}) + for field in ('autopilot_enabled', 'mqtt_enabled', 'homekit_enabled', 'prime_pod_daily', 'reboot_daily', 'pump_stall_protection_enabled', 'pump_stall_auto_recovery_enabled'): + with self.subTest(field=field): + database.execute('UPDATE device_settings SET ' + field + '=1') + database.commit() + with self.assertRaises(ValueError): + activation_checks.candidate_ready(self.data) + database.execute('UPDATE device_settings SET ' + field + '=0') + database.commit() + for field in ('always_on', 'auto_on_enabled', 'auto_off_enabled'): + with self.subTest(field=field): + database.execute('UPDATE side_settings SET ' + field + '=1') + database.commit() + with self.assertRaisesRegex(ValueError, 'presence-based'): + activation_checks.candidate_ready(self.data) + database.execute('UPDATE side_settings SET ' + field + '=0') + database.commit() + cases = [ + ('base_schedules', "INSERT INTO base_schedules(day_of_week,time,head,feet) VALUES ('daily','23:00',5,0)"), + ('automations', "INSERT INTO automations(name,trigger,conditions,actions) VALUES ('test','{}','{}','[]')"), + ('run_once_sessions', "INSERT INTO run_once_sessions(side,set_points,wake_time,expires_at) VALUES ('left','[]','09:00',1000000)"), + ] + for table, statement in cases: + with self.subTest(table=table): + database.execute(statement) + database.commit() + with self.assertRaisesRegex(ValueError, 'separate review'): + activation_checks.candidate_ready(self.data) + database.execute('DELETE FROM ' + table) + database.commit() + database.execute('UPDATE comfort_states SET state=? WHERE side=?', ('{"config":{"mode":"active"}}', 'left')) + database.commit() + with self.assertRaisesRegex(ValueError, 'Off or Preview'): + activation_checks.candidate_ready(self.data) + + def test_missing_history_never_passes_candidate_check(self): + self.prepare_candidate() + self.history.unlink() + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + self.assertFalse(self.history.exists()) + + def test_missing_original_history_never_passes_candidate_check(self): + self.prepare_candidate() + self.source.unlink() + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + self.assertFalse(self.source.exists()) + + def test_older_report_without_history_evidence_requires_reimport(self): + self.prepare_candidate() + self.report = {'format': 'sleepypod-free-sleep-import/1'} + self.save_report() + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + + def test_changed_or_replaced_history_and_original_are_rejected(self): + self.prepare_candidate() + for path in (self.history, self.source): + with self.subTest(path=path.name): + original = path.read_bytes() + database = sqlite3.connect(path) + database.execute('UPDATE vitals SET heart_rate=80') + database.commit() + database.close() + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + path.write_bytes(original) + + def test_history_symlink_and_incomplete_sidecars_are_rejected(self): + self.prepare_candidate() + saved = self.root / 'saved-history.db' + self.history.rename(saved) + self.history.symlink_to(saved) + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + self.history.unlink() + saved.rename(self.history) + for suffix in ('-wal', '-shm', '-journal'): + sidecar = self.history.with_name(self.history.name + suffix) + sidecar.write_bytes(b'') + with self.subTest(suffix=suffix), self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + sidecar.unlink() + activation_checks.candidate_ready(self.data) + + def test_history_metadata_must_match_the_import_report(self): + self.prepare_candidate() + self.report['importedHistory']['timeZone'] = 'UTC' + self.save_report() + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + + def test_malformed_history_hash_and_corrupt_sqlite_are_rejected(self): + self.prepare_candidate() + expected = self.report['databaseHashes'][self.history.name] + for digest in (None, '', 'invalid', '0' * 64, []): + with self.subTest(digest=digest): + self.report['databaseHashes'][self.history.name] = digest + self.save_report() + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + self.report['databaseHashes'][self.history.name] = expected + self.history.write_bytes(b'not a SQLite file') + self.report['databaseHashes'][self.history.name] = hashlib.sha256(self.history.read_bytes()).hexdigest() + self.save_report() + with self.assertRaisesRegex(ValueError, 'history'): + activation_checks.candidate_ready(self.data) + + def test_future_receipt_or_different_revision_is_rejected(self): + journal = {'trialStartedAt': 100, 'trialEndedAt': 140, 'observation': observation()} + with self.assertRaisesRegex(ValueError, 'dates'): + activation_checks.require_trial(journal, 'a' * 40, 139) + with self.assertRaisesRegex(ValueError, 'this release'): + activation_checks.require_trial(journal, 'b' * 40, 141) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_handoff.py b/scripts/deployment/test_handoff.py new file mode 100644 index 000000000..1148ddfdb --- /dev/null +++ b/scripts/deployment/test_handoff.py @@ -0,0 +1,330 @@ +import hashlib +import json +from pathlib import Path +import stat +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import handoff +import service_plan + + +CONFIG = { + 'release': '/persistent/sleepypod-unified/releases/abc1234', + 'modules': '/persistent/sleepypod-unified/sensors/abc1234/modules', + 'data': '/persistent/sleepypod-unified/data', + 'environment': '/persistent/sleepypod-unified/controller.env', + 'dacSocket': '/persistent/deviceinfo/dac.sock', + 'rawData': '/persistent', +} + + +class ServicePlanTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + + def test_plan_is_inert_and_limits_service_ownership(self): + directory = self.root / 'plan' + plan = service_plan.prepare(CONFIG, directory) + self.assertEqual(service_plan.read_plan(directory), plan) + self.assertFalse(plan['servicesStarted']) + self.assertFalse(plan['bootSelectionChanged']) + self.assertEqual(set(plan['units']), set(service_plan.CORE_UNITS)) + self.assertEqual(directory.stat().st_mode & 0o777, 0o700) + units = service_plan.render(CONFIG) + for name, content in units.items(): + self.assertIn('User=dac\n', content) + self.assertIn('KillMode=control-group\n', content) + self.assertIn('Conflicts=free-sleep.service free-sleep-stream.service\n', content) + self.assertNotIn('ExecStartPre=', content) + self.assertNotIn('User=root', content) + if name != 'sleepypod.service': + self.assertIn('BindsTo=sleepypod.service\n', content) + self.assertNotIn('EnvironmentFile=', content) + self.assertNotIn('cover-buttons', '\n'.join(units)) + + def test_unsafe_shared_overlapping_or_expandable_paths_are_rejected(self): + cases = [('release', '/home/dac/free-sleep'), ('release', '/'), + ('release', '/persistent/sleepypod-unified/a/../b'), + ('release', '/persistent/sleepypod-unified/a%u'), + ('release', '/persistent/sleepypod-unified/a\nUser=root'), + ('release', '/persistent/sleepypod-unified/a b'), + ('modules', CONFIG['release'] + '/modules'), + ('data', CONFIG['release']), + ('environment', CONFIG['data'] + '/secret'), + ('dacSocket', '/tmp/other.sock')] + for field, value in cases: + with self.subTest(field=field, value=value), self.assertRaises(ValueError): + service_plan.render({**CONFIG, field: value}) + + def test_existing_destination_is_not_replaced(self): + with self.assertRaises(ValueError): + service_plan.prepare(CONFIG, self.root) + self.assertEqual(list(self.root.iterdir()), []) + + def test_optional_maintenance_socket_is_explicit_and_leaves_existing_plans_unchanged(self): + self.assertNotIn('SLEEPYPOD_MAINTENANCE_SOCKET', service_plan.render(CONFIG)['sleepypod.service']) + units = service_plan.render({**CONFIG, 'maintenanceSocket': service_plan.MAINTENANCE_SOCKET}) + self.assertIn('Environment="SLEEPYPOD_MAINTENANCE_SOCKET=/run/sleepypod-maintenance/control.sock"', units['sleepypod.service']) + self.assertTrue(all('SLEEPYPOD_MAINTENANCE_SOCKET' not in text for name, text in units.items() if name != 'sleepypod.service')) + for socket in ('/tmp/control.sock', None, True, '/run/sleepypod-maintenance/control.sock\nUser=root'): + with self.assertRaises(ValueError): service_plan.render({**CONFIG, 'maintenanceSocket': socket}) + + def test_rehashed_service_tampering_is_rejected(self): + directory = self.root / 'plan' + plan = service_plan.prepare(CONFIG, directory) + target = directory / 'sleepypod.service' + target.write_text(target.read_text().replace('User=dac', 'User=root')) + plan['units'][target.name] = hashlib.sha256(target.read_bytes()).hexdigest() + (directory / 'plan.json').write_text(json.dumps(plan)) + with self.assertRaisesRegex(ValueError, 'reviewed template'): + service_plan.read_plan(directory) + + def test_unit_symlink_is_rejected(self): + directory = self.root / 'plan' + service_plan.prepare(CONFIG, directory) + target = directory / 'sleepypod.service' + saved = self.root / 'saved.service' + target.rename(saved) + target.symlink_to(saved) + with self.assertRaises(ValueError): + service_plan.read_plan(directory) + + +class HandoffTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.directory = self.root / 'plan' + self.plan = service_plan.prepare(CONFIG, self.directory) + self.unit_directory = self.root / 'units' + self.unit_directory.mkdir() + self.calls = [] + self.states = {} + self.start_failure = False + self.stop_failure = False + for name in service_plan.LEGACY_UNITS: + path = self.unit_directory / name + path.write_text('original retained service\n') + self.states[name] = {'LoadState': 'loaded', 'ActiveState': 'active', 'MainPID': '123', + 'UnitFileState': 'enabled', 'FragmentPath': str(path), 'DropInPaths': ''} + for name in service_plan.CORE_UNITS: + self.states[name] = {'LoadState': 'not-found', 'ActiveState': 'inactive', 'MainPID': '0', + 'UnitFileState': '', 'FragmentPath': '', 'DropInPaths': ''} + for name, replacement in [('UNIT_DIRECTORY', self.unit_directory), ('systemctl', self.control), ('unit_state', lambda name: dict(self.states[name])), + ('require_legacy_recovery_data', lambda _: None)]: + context = patch.object(handoff, name, replacement) + context.start() + self.addCleanup(context.stop) + + def control(self, *arguments): + self.calls.append(arguments) + action, *names = arguments + if action == 'daemon-reload': + for name in service_plan.CORE_UNITS: + self.states[name].update(LoadState='loaded', UnitFileState='disabled', + FragmentPath=str(self.unit_directory / name)) + if action == 'stop': + for name in names: + if self.stop_failure and name in service_plan.LEGACY_UNITS: + continue + self.states[name].update(ActiveState='inactive', MainPID='0') + if action == 'start': + if names == ['sleepypod.service'] and self.start_failure: + raise OSError('startup failed') + for name in names: + self.states[name].update(ActiveState='active', MainPID='456') + return '' + + def test_restore_holds_personal_data_before_and_after_stopping_writers(self): + handoff.install(self.directory) + journal = json.loads((self.directory/'handoff.json').read_text()) + for effects, should_stop in [([ValueError('personal recovery')], False), ([None, ValueError('personal recovery')], True)]: + self.calls.clear() + with patch.object(handoff, 'require_legacy_recovery_data', side_effect=effects), self.assertRaisesRegex(ValueError, 'personal recovery'): + handoff.restore(self.plan, journal) + self.assertEqual(any(call[0] == 'stop' for call in self.calls), should_stop) + self.assertFalse(any(call[0] == 'start' for call in self.calls)) + + def prepare_trial(self): + handoff.install(self.directory) + for name, replacement in [('prerequisites', lambda plan: 'a' * 40), + ('read_owner_key', lambda path: 'test-key-never-recorded')]: + context = patch.object(handoff, name, replacement) + context.start() + self.addCleanup(context.stop) + + def run_trial(self, observation=None): + clock = iter(range(100)) + return handoff.trial(self.directory, 10, observe=observation or (lambda *_: { + 'controller': {'id': 'sleepypod-core'}, 'device': {'freshness': 'fresh'}, + 'temperatureRuntime': 'ready'}), sleep=lambda _: None, monotonic=lambda: next(clock)) + + def test_install_never_starts_or_enables_services(self): + result = handoff.install(self.directory) + self.assertEqual(result['phase'], 'installed_not_started') + self.assertEqual(self.calls, [('daemon-reload',)]) + self.assertFalse(result['bootSelectionChanged']) + self.assertEqual(set(result['createdUnits']), set(service_plan.CORE_UNITS)) + with self.assertRaises(ValueError): + handoff.install(self.directory) + + def test_install_refuses_existing_foreign_units(self): + (self.unit_directory / 'sleepypod.service').write_text('unrelated') + with self.assertRaises(ValueError): + handoff.install(self.directory) + self.assertEqual(self.calls, []) + self.assertEqual((self.unit_directory / 'sleepypod.service').read_text(), 'unrelated') + + def test_trial_has_single_owner_and_always_restores_original_boot_selection(self): + self.prepare_trial() + result = self.run_trial() + self.assertTrue(result['trialSucceeded']) + self.assertEqual(result['phase'], 'trial_restored') + self.assertEqual(self.calls[1:], [('stop', *service_plan.LEGACY_UNITS), ('start', service_plan.CORE_UNIT), + ('stop', *service_plan.CORE_UNITS), ('start', *service_plan.LEGACY_UNITS)]) + self.assertNotIn('test-key-never-recorded', (self.directory / 'handoff.json').read_text()) + self.assertFalse(result['restoration']['physicalOperationVerified']) + + def test_failed_core_start_restores_legacy(self): + self.prepare_trial() + self.start_failure = True + with self.assertRaisesRegex(OSError, 'startup failed'): + self.run_trial() + self.assertEqual(self.calls[-2:], [('stop', *service_plan.CORE_UNITS), ('start', *service_plan.LEGACY_UNITS)]) + self.assertEqual(json.loads((self.directory / 'handoff.json').read_text())['phase'], 'trial_restored') + + def test_legacy_stop_failure_never_starts_core(self): + self.prepare_trial() + self.stop_failure = True + with self.assertRaisesRegex(RuntimeError, 'still present'): + self.run_trial() + self.assertNotIn(('start', service_plan.CORE_UNIT), self.calls) + self.assertEqual(self.calls[-1], ('start', *service_plan.LEGACY_UNITS)) + + def test_stale_or_failed_observations_restore_without_success_claim(self): + for behavior in ('stale', 'failure'): + with self.subTest(behavior=behavior): + if behavior == 'stale': + self.prepare_trial() + def observe(*_args): + if behavior == 'failure': + raise OSError('not ready') + return {'device': {'freshness': 'stale'}} + with self.assertRaisesRegex(RuntimeError, 'fresh device report'): + self.run_trial(observe) + result = json.loads((self.directory / 'handoff.json').read_text()) + self.assertFalse(result['trialSucceeded']) + self.assertEqual(result['phase'], 'trial_restored') + + def test_interrupt_still_restores_and_propagates(self): + self.prepare_trial() + def interrupted(*_args): + raise KeyboardInterrupt() + with self.assertRaises(KeyboardInterrupt): + self.run_trial(interrupted) + self.assertEqual(self.calls[-1], ('start', *service_plan.LEGACY_UNITS)) + + def test_modified_unit_or_old_journal_stops_before_any_service_mutation(self): + self.prepare_trial() + for changed in (self.unit_directory / 'sleepypod.service', self.unit_directory / 'free-sleep.service'): + original = changed.read_bytes() + changed.write_text('changed') + with self.assertRaises(ValueError): + self.run_trial() + changed.write_bytes(original) + self.assertEqual(self.calls, [('daemon-reload',)]) + journal = self.directory / 'handoff.json' + value = json.loads(journal.read_text()) + value['phase'] = 'core_trial' + journal.write_text(json.dumps(value)) + with self.assertRaisesRegex(ValueError, 'Recover'): + self.run_trial() + + def test_restore_stops_core_before_rejecting_changed_legacy(self): + self.prepare_trial() + journal = json.loads((self.directory / 'handoff.json').read_text()) + (self.unit_directory / 'free-sleep.service').write_text('changed') + with self.assertRaises(ValueError): + handoff.restore(self.plan, journal) + self.assertEqual(self.calls[-1], ('stop', *service_plan.CORE_UNITS)) + + def test_credentials_require_private_file_and_never_accept_ambiguous_values(self): + path = self.root / 'owner.env' + info = SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=0) + with patch.object(Path, 'lstat', return_value=info): + for content in ('', 'SLEEPYPOD_AUTH_TOKEN=short', 'SLEEPYPOD_AUTH_TOKEN="' + 'a' * 40 + '"', + 'SLEEPYPOD_AUTH_TOKEN=' + 'a' * 40 + '\nSLEEPYPOD_AUTH_TOKEN=' + 'b' * 40): + path.write_text(content) + with self.assertRaises(ValueError): + handoff.read_owner_key(path) + path.write_text('SLEEPYPOD_AUTH_TOKEN=' + 'a' * 40) + self.assertEqual(handoff.read_owner_key(path), 'a' * 40) + info.st_mode = stat.S_IFREG | 0o644 + with self.assertRaises(ValueError): + handoff.read_owner_key(path) + + def test_settling_requires_consecutive_fresh_observations(self): + self.prepare_trial() + current = [0] + observations = [] + + def observe(*_args): + observations.append(current[0]) + return {'device': {'freshness': 'stale' if current[0] == 2 else 'fresh'}} + + result = handoff.trial(self.directory, 10, observe=observe, settle_seconds=3, + monotonic=lambda: current[0], sleep=lambda seconds: current.__setitem__(0, current[0] + seconds)) + self.assertEqual(observations, [0, 2, 4, 6, 8]) + self.assertTrue(result['trialSucceeded']) + + def test_idle_trial_rejects_active_legacy_before_stopping_it(self): + self.prepare_trial() + with patch.object(handoff, 'local_json', return_value={'isPriming': True}): + with self.assertRaisesRegex(RuntimeError, 'priming'): + handoff.trial(self.directory, 10, require_idle=True) + self.assertEqual(self.calls, [('daemon-reload',)]) + + def test_idle_trial_rejects_heating_drift_and_checks_restored_api(self): + self.prepare_trial() + idle = {'isPriming': False, **{side: {'isOn': False, 'isAlarmVibrating': False, 'secondsRemaining': 0} + for side in ('left', 'right')}} + drift = {'isPriming': False, 'leftSide': {'heatingDuration': 28800}, 'rightSide': {'heatingDuration': 0}} + with patch.object(handoff, 'local_json', side_effect=[idle, drift, idle]): + with self.assertRaisesRegex(RuntimeError, 'countdown'): + handoff.trial(self.directory, 10, require_idle=True, observe=lambda *_: {'device': {'freshness': 'fresh'}}) + journal = json.loads((self.directory / 'handoff.json').read_text()) + self.assertFalse(journal['trialSucceeded']) + self.assertTrue(journal['restoredIdleVerified']) + self.assertEqual(journal['phase'], 'trial_restored') + + def test_post_restoration_power_change_revokes_trial_success(self): + self.prepare_trial() + idle = {'isPriming': False, **{side: {'isOn': False, 'isAlarmVibrating': False, 'secondsRemaining': 0} + for side in ('left', 'right')}} + core = {'isPriming': False, 'leftSide': {'heatingDuration': 0}, 'rightSide': {'heatingDuration': 0}} + changed = {**idle, 'left': {**idle['left'], 'secondsRemaining': 60}} + with patch.object(handoff, 'local_json', side_effect=[idle, core, changed]): + with self.assertRaisesRegex(RuntimeError, 'countdown'): + handoff.trial(self.directory, 10, require_idle=True, observe=lambda *_: {'device': {'freshness': 'fresh'}}) + journal = json.loads((self.directory / 'handoff.json').read_text()) + self.assertFalse(journal['trialSucceeded']) + self.assertFalse(journal['restoredIdleVerified']) + + def test_idle_check_accepts_retained_targets_but_rejects_missing_or_boolean_duration(self): + value = {'isPriming': False, 'leftSide': {'targetLevel': 5, 'heatingDuration': 0}, + 'rightSide': {'targetLevel': -20, 'heatingDuration': 0}} + handoff.require_thermal_idle(value, core=True) + for duration in (None, False, -1, float('nan'), 1): + value['leftSide']['heatingDuration'] = duration + with self.assertRaises(RuntimeError): + handoff.require_thermal_idle(value, core=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_maintenance_broker.py b/scripts/deployment/test_maintenance_broker.py new file mode 100644 index 000000000..a766bb969 --- /dev/null +++ b/scripts/deployment/test_maintenance_broker.py @@ -0,0 +1,300 @@ +import copy +from contextlib import nullcontext +import hashlib +import json +import os +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, patch + +import maintenance_broker as broker + + +def request(**changes): + return {'version': 1, 'requestId': '11111111-1111-4111-8111-111111111111', + 'invocationId': 'a' * 32, 'unit': broker.CORE, 'issuedAt': 100000, + 'expiresAt': 105000, **changes} + + +class Store: + def __init__(self): + self.value = None + self.writes = [] + + def read(self): + return copy.deepcopy(self.value) + + def write(self, value): + self.value = copy.deepcopy(value) + self.writes.append(copy.deepcopy(value)) + + +class RequestTests(unittest.TestCase): + def test_valid_targets_and_clock_boundaries(self): + for unit in broker.TARGETS: + broker.validate_request(request(unit=unit), 100000) + broker.validate_request(request(unit=unit), 105000) + + def test_rejects_invalid_authority_and_timing(self): + for changed in ({'unit': 'sshd.service'}, {'unit': 'free-sleep.service'}, {'unit': '--all'}, + {'unit': 'sleepypod.service;reboot'}, {'unit': 'sleepypod-calibrator.service'}, + {'version': True}, {'invocationId': 'a' * 31}, {'requestId': 'reused'}, + {'issuedAt': 100001}, {'expiresAt': 99999}, {'expiresAt': 105001}, + {'issuedAt': True}, {'issuedAt': 100000.0}, {'additional': 'field'}): + with self.subTest(changed=changed), self.assertRaises(broker.Rejected): + broker.validate_request(request(**changed), 100000) + with self.assertRaises(broker.Rejected): + broker.validate_request(request(), 105001) + + def test_duplicate_and_nonfinite_json_refused(self): + for value in ('{"unit":"one","unit":"two"}', '{"value":NaN}', '{"value":Infinity}'): + with self.assertRaises(ValueError): broker.unique_json(value) + + +class BrokerTests(unittest.TestCase): + def setUp(self): + self.store = Store() + self.system = Mock() + self.system.submit.return_value = True + self.now, self.monotonic = 100, 100 + self.lock = patch.object(broker, 'operation_lock', return_value=nullcontext()).start() + patch.object(broker, 'read_hashes', return_value={unit: 'a' * 64 for unit in broker.PLAN_UNITS}).start() + self.addCleanup(patch.stopall) + self.instance = broker.Broker(Path('/reviewed'), self.store, self.system, + now=lambda: self.now, monotonic=lambda: self.monotonic) + + def send(self, **changes): + return self.instance.request(request(**changes), (111, 1000, 1000)) + + def test_persists_before_command_and_deduplicates(self): + def submit(unit): + self.assertEqual(unit, broker.CORE) + self.assertEqual(self.store.value['outcome'], 'submitting') + return True + self.system.submit.side_effect = submit + first = self.send() + self.assertEqual(first['outcome'], 'accepted') + self.assertEqual(self.send(), first) + self.assertEqual(self.system.submit.call_count, 1) + self.assertEqual([item['outcome'] for item in self.store.writes], ['submitting', 'accepted']) + + def test_same_id_cannot_change_target(self): + self.send() + with self.assertRaises(broker.Rejected): self.send(unit=broker.TARGETS[1]) + self.assertEqual(self.system.submit.call_count, 1) + + def test_new_ids_respect_cooldown_even_after_core_invocation_changes(self): + self.send() + with self.assertRaisesRegex(broker.Rejected, '60 seconds'): + self.send(requestId='22222222-2222-4222-8222-222222222222', invocationId='b' * 32) + self.now = self.monotonic = 160 + self.assertEqual(self.send(requestId='22222222-2222-4222-8222-222222222222', + issuedAt=160000, expiresAt=165000)['outcome'], 'accepted') + + def test_timeout_is_unknown_and_cannot_replay_after_broker_restart(self): + self.system.submit.side_effect = subprocess.TimeoutExpired('systemctl', 3) + self.assertEqual(self.send()['outcome'], 'unknown') + self.instance = broker.Broker(Path('/reviewed'), self.store, self.system, + now=lambda: self.now, monotonic=lambda: self.monotonic) + self.assertEqual(self.send()['outcome'], 'unknown') + self.now = self.monotonic = 1000 + with self.assertRaisesRegex(broker.Rejected, 'unknown outcome'): + self.send(requestId='22222222-2222-4222-8222-222222222222', issuedAt=1000000, expiresAt=1005000) + self.assertEqual(self.system.submit.call_count, 1) + + def test_crash_receipt_is_unknown_without_reexecution(self): + self.store.value = {'request': request(), 'outcome': 'submitting', 'message': 'Unconfirmed', 'monotonic': 99} + self.assertEqual(self.send()['outcome'], 'unknown') + self.system.submit.assert_not_called() + + def test_durable_write_failure_prevents_command(self): + self.store.write = Mock(side_effect=OSError('disk full')) + with self.assertRaises(OSError): self.send() + self.system.submit.assert_not_called() + + def test_failed_outcome_write_leaves_attempt_unknown_without_replay(self): + original_write = self.store.write + def write(value): + if value['outcome'] != 'submitting': raise OSError('receipt flush failed') + original_write(value) + self.store.write = write + with self.assertRaises(OSError): self.send() + self.assertEqual(self.store.value['outcome'], 'submitting') + self.assertEqual(self.send()['outcome'], 'unknown') + self.assertEqual(self.system.submit.call_count, 1) + + def test_freshness_checked_again_after_slow_authorization(self): + def authorize(*_args): self.now += 3 + self.system.authorize.side_effect = authorize + self.assertEqual(self.send()['outcome'], 'rejected') + self.system.submit.assert_not_called() + + def test_changed_owner_before_submission_refuses_action(self): + self.system.authorize.side_effect = [None, broker.Rejected('changed')] + self.assertEqual(self.send()['outcome'], 'rejected') + self.system.submit.assert_not_called() + + def test_lock_contention_and_unreadable_ledger_refuse_action(self): + self.lock.side_effect = broker.Rejected('migration in progress') + with self.assertRaises(broker.Rejected): self.send() + self.system.submit.assert_not_called() + self.lock.side_effect = None + self.store.read = Mock(side_effect=ValueError('corrupt receipt')) + with self.assertRaises(ValueError): self.send() + self.system.submit.assert_not_called() + + def test_unconfirmed_result_is_unknown_without_an_update_fallback(self): + self.system.submit.return_value = False + self.assertEqual(self.send()['outcome'], 'unknown') + self.system.submit.assert_called_once_with(broker.CORE) + + +class AuthorizationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + (self.root/'111').mkdir() + (self.root/'111/cgroup').write_text('1:name=systemd:/system.slice/sleepypod.service\n0::/other\n') + self.system = broker.Systemd(1000, proc=self.root, units=self.root/'units') + self.core = {'ActiveState': 'active', 'SubState': 'running', 'MainPID': '111', + 'InvocationID': 'a' * 32, 'ControlGroup': '/system.slice/sleepypod.service', + 'FragmentPath': str(self.root/'units'/broker.CORE), 'DropInPaths': '', 'Job': '', 'NoNewPrivileges': 'yes'} + self.system.properties = Mock(side_effect=lambda unit: self.core if unit == broker.CORE + else {**self.core, 'FragmentPath': str(self.root/'units'/unit)}) + patch.object(broker, 'regular_bytes', return_value=b'reviewed').start() + self.addCleanup(patch.stopall) + self.hashes = {unit: hashlib.sha256(b'reviewed').hexdigest() for unit in broker.PLAN_UNITS} + + def authorize(self, peer=(111, 1000, 1000)): + self.system.authorize(peer, request(), self.hashes) + + def test_current_main_process_and_exact_cgroup_accepted(self): + self.authorize() + (self.root/'111/cgroup').write_text('0::/system.slice/sleepypod.service\n') + self.authorize() + + def test_shared_user_is_not_enough(self): + for peer in [(111, 0, 0), (222, 1000, 1000), (0, 1000, 1000)]: + with self.subTest(peer=peer), self.assertRaises(broker.Rejected): self.authorize(peer) + + def test_changed_invocation_inactive_owner_or_privilege_policy_refused(self): + for key, value in [('InvocationID', 'b' * 32), ('ActiveState', 'inactive'), ('SubState', 'stop-sigterm'), + ('MainPID', '0'), ('NoNewPrivileges', 'no'), ('ControlGroup', '')]: + original = self.core[key] + self.core[key] = value + with self.subTest(key=key), self.assertRaises(broker.Rejected): self.authorize() + self.core[key] = original + + def test_cgroup_child_prefix_does_not_impersonate_main_group(self): + (self.root/'111/cgroup').write_text('1:name=systemd:/system.slice/sleepypod.service/child\n') + with self.assertRaises(broker.Rejected): self.authorize() + + def test_reviewed_unit_ownership_override_and_pending_jobs(self): + for key, value in [('FragmentPath', '/tmp/other'), ('DropInPaths', '/tmp/override.conf'), ('Job', '17')]: + original = self.core[key] + self.core[key] = value + with self.subTest(key=key), self.assertRaises(broker.Rejected): self.authorize() + self.core[key] = original + self.hashes[broker.CORE] = 'a' * 64 + with self.assertRaises(broker.Rejected): self.authorize() + + def test_fixed_systemctl_commands_never_use_shell_or_start_inactive_core(self): + for target in broker.TARGETS: + with patch.object(broker.subprocess, 'run', return_value=Mock(returncode=0)) as run: + self.assertTrue(self.system.submit(target)) + self.assertEqual(run.call_args.args[0], ['/bin/systemctl', '--no-block', '--job-mode=fail', + 'try-restart' if target == broker.CORE else 'restart', target]) + self.assertNotIn('shell', run.call_args.kwargs) + + def test_unconfirmed_systemctl_result_is_not_proof_of_rejection(self): + with patch.object(broker.subprocess, 'run', return_value=Mock(returncode=1)): + with self.assertRaises(RuntimeError): self.system.submit(broker.CORE) + with patch.object(broker.subprocess, 'run') as run: + with self.assertRaises(broker.Rejected): self.system.submit('--all') + run.assert_not_called() + + def test_core_restart_also_refuses_changed_dependent_unit(self): + self.hashes['sleepypod-calibrator.service'] = 'b' * 64 + with self.assertRaises(broker.Rejected): self.authorize() + + +class StorageTests(unittest.TestCase): + def test_read_only_existing_lock_coordinates_without_creating_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + root.chmod(0o700) + with self.assertRaises(FileNotFoundError): + with broker.operation_lock(root, 'handoff.lock', owner=os.getuid(), create=False): pass + lock = root/'handoff.lock' + lock.write_text('existing lock') + lock.chmod(0o400) + with broker.operation_lock(root, 'handoff.lock', owner=os.getuid(), create=False): + with self.assertRaises(broker.Rejected): + with broker.operation_lock(root, 'handoff.lock', owner=os.getuid(), create=False): pass + self.assertEqual(lock.read_text(), 'existing lock') + self.assertEqual(lock.stat().st_mode & 0o777, 0o400) + + def test_atomic_receipt_survives_new_store_and_refuses_corruption(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + root.chmod(0o700) + store = broker.StateStore(root, owner=os.getuid()) + value = {'request': request(), 'outcome': 'submitting', 'message': 'Unconfirmed', 'monotonic': 17} + store.write(value) + self.assertEqual(broker.StateStore(root, owner=os.getuid()).read(), value) + self.assertEqual(store.path.stat().st_mode & 0o777, 0o600) + store.path.write_text('{broken') + with self.assertRaises(ValueError): store.read() + + def test_symlink_receipt_and_shared_directory_refused(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + root.chmod(0o700) + store = broker.StateStore(root, owner=os.getuid()) + target = root/'other' + target.write_text('{}') + store.path.symlink_to(target) + with self.assertRaises(OSError): store.read() + root.chmod(0o755) + with self.assertRaises(ValueError): broker.StateStore(root, owner=os.getuid()) + + def test_lock_is_shared_and_not_followed_through_symlink(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + root.chmod(0o700) + with broker.operation_lock(root, 'lock', owner=os.getuid()): + with self.assertRaises(broker.Rejected): + with broker.operation_lock(root, 'lock', owner=os.getuid()): pass + (root/'lock').unlink() + (root/'lock').symlink_to(root/'other') + with self.assertRaises(OSError): + with broker.operation_lock(root, 'lock', owner=os.getuid()): pass + + +class FrameTests(unittest.TestCase): + def test_peer_credentials_and_partial_reads_reach_broker(self): + connection = Mock() + payload = json.dumps(request()).encode() + b'\n' + connection.recv.side_effect = [payload[:31], payload[31:]] + connection.getsockopt.return_value = struct.pack('3i', 111, 1000, 1000) + instance = Mock() + instance.request.return_value = broker.response(request(), 'accepted', 'Requested') + with patch.object(broker.socket, 'SO_PEERCRED', 17, create=True): broker.serve_connection(connection, instance) + instance.request.assert_called_once_with(request(), (111, 1000, 1000)) + self.assertEqual(json.loads(connection.sendall.call_args.args[0])['outcome'], 'accepted') + + def test_oversized_truncated_multiple_or_invalid_frames_never_dispatch(self): + for payload in [b'x'*1025, b'{}', b'{}\n{}\n', b'[]\n', b'{"a":1,"a":2}\n']: + connection = Mock() + connection.recv.side_effect = [payload, b''] + connection.getsockopt.return_value = struct.pack('3i', 111, 1000, 1000) + instance = Mock() + with patch.object(broker.socket, 'SO_PEERCRED', 17, create=True): broker.serve_connection(connection, instance) + instance.request.assert_not_called() + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/deployment/test_maintenance_install.py b/scripts/deployment/test_maintenance_install.py new file mode 100644 index 000000000..885c1a62c --- /dev/null +++ b/scripts/deployment/test_maintenance_install.py @@ -0,0 +1,369 @@ +import copy +import json +import os +from pathlib import Path +import socket +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import handoff +import maintenance_install as provision +import maintenance_plan +import service_plan +from test_handoff import CONFIG + + +class ProvisioningTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.directory = self.root/'maintenance-plan-fixture' + self.units = self.root/'units' + self.units.mkdir() + self.calls, self.states = [], {} + self.listener = None + self.socket = self.root/'control.sock' + self.addCleanup(self.close_socket) + self.manifest = maintenance_plan.prepare('/persistent/sleepypod-unified/service-plan-fixture', CONFIG['release'], self.directory) + self.plan = service_plan.prepare({**CONFIG, 'maintenanceSocket': service_plan.MAINTENANCE_SOCKET}, self.root/'core-plan') + for name in service_plan.CORE_UNITS: + (self.units/name).write_bytes((self.root/'core-plan'/name).read_bytes()) + self.states[name] = self.state(name, enabled='disabled') + for name in service_plan.LEGACY_UNITS: + (self.units/name).write_text('Retained controller fixture\n') + self.states[name] = self.state(name, active='active', pid='123', enabled='enabled') + self.saved = {'legacy': {name: {'state': copy.deepcopy(self.states[name]), + 'hashes': {str(self.units/name): provision.digest((self.units/name).read_bytes())}} + for name in service_plan.LEGACY_UNITS}} + self.context = {'manifest': self.manifest, 'controller': Path(self.manifest['controllerPlan']), + 'plan': self.plan, 'handoff': self.saved, 'revision': 'a'*40} + for target, name, value in [(provision, 'OWNER_UID', os.getuid()), (provision, 'UNIT_DIRECTORY', self.units), + (handoff, 'UNIT_DIRECTORY', self.units), (handoff, 'systemctl', self.control), + (handoff, 'unit_state', self.unit_state), (service_plan, 'MAINTENANCE_SOCKET', str(self.socket))]: + context = patch.object(target, name, value) + context.start() + self.addCleanup(context.stop) + context = patch.object(provision.pwd, 'getpwnam', return_value=SimpleNamespace(pw_gid=os.getgid())) + context.start() + self.addCleanup(context.stop) + self.foreign = self.units/'unrelated.service' + self.foreign.write_text('unrelated service') + self.credentials = self.root/'controller.env' + self.credentials.write_bytes(b'SLEEPYPOD_AUTH_TOKEN=fixture-key-not-used\n') + + def state(self, name, active='inactive', pid='0', enabled='static', loaded='loaded'): + result = {'LoadState': loaded, 'ActiveState': active, 'UnitFileState': enabled, + 'FragmentPath': str(self.units/name) if loaded == 'loaded' else '', 'DropInPaths': ''} + # Real systemd socket units have no MainPID, including when not found. + if name != maintenance_plan.SOCKET: + result['MainPID'] = pid + return result + + def unit_state(self, name): + return copy.deepcopy(self.states.get(name, self.state(name, loaded='not-found', enabled=''))) + + def close_socket(self): + if self.listener: + self.listener.close() + self.listener = None + self.socket.unlink(missing_ok=True) + + def control(self, *arguments): + self.calls.append(arguments) + action, *names = arguments + if action == 'daemon-reload': + for name in provision.UNITS: + if (self.units/name).exists(): + self.states[name] = self.states.get(name, self.state(name, enabled='disabled' if name == maintenance_plan.SOCKET else 'static')) + else: self.states.pop(name, None) + elif action == 'enable': + self.assertEqual(names, ['--now', maintenance_plan.SOCKET]) + self.states[maintenance_plan.SOCKET].update(ActiveState='active', UnitFileState='enabled') + self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.listener.bind(str(self.socket)) + self.socket.chmod(0o660) + elif action == 'stop': + self.assertTrue(set(names) <= set(provision.UNITS)) + for name in names: + self.states[name]['ActiveState'] = 'inactive' + if name != maintenance_plan.SOCKET: + self.states[name]['MainPID'] = '0' + self.close_socket() + elif action == 'disable': + self.assertEqual(names, [maintenance_plan.SOCKET]) + self.states[maintenance_plan.SOCKET]['UnitFileState'] = 'disabled' + else: raise AssertionError('Unexpected service action: '+str(arguments)) + return '' + + def assert_preserved(self): + self.assertEqual(self.foreign.read_text(), 'unrelated service') + self.assertEqual(self.credentials.read_bytes(), b'SLEEPYPOD_AUTH_TOKEN=fixture-key-not-used\n') + for name in service_plan.CORE_UNITS: + self.assertEqual(self.states[name]['ActiveState'], 'inactive') + self.assertEqual((self.units/name).read_bytes(), (self.root/'core-plan'/name).read_bytes()) + for name in service_plan.LEGACY_UNITS: self.assertEqual(self.states[name]['MainPID'], '123') + + def test_install_enable_and_rollback_leave_controller_and_credentials_untouched(self): + self.assertEqual(provision.install(self.directory, self.context)['phase'], 'installed_stopped') + self.assertEqual(self.calls, [('daemon-reload',)]) + self.assertFalse(self.socket.exists()) + self.assertEqual(provision.enable(self.directory, self.context)['phase'], 'enabled') + self.assertTrue(self.socket.exists()) + self.assertEqual(provision.rollback(self.directory, self.context)['phase'], 'rolled_back') + self.assertFalse(self.socket.exists()) + self.assertTrue(all(not (self.units/name).exists() for name in provision.UNITS)) + before = list(self.calls) + self.assertEqual(provision.rollback(self.directory, self.context)['phase'], 'rolled_back') + self.assertEqual(self.calls, before) + self.assert_preserved() + + def test_refuses_live_or_boot_selected_core_and_unavailable_legacy_before_writes(self): + for name, changes in [('sleepypod.service', {'ActiveState': 'active', 'MainPID': '456'}), + ('sleepypod-calibrator.service', {'UnitFileState': 'enabled'}), + ('free-sleep.service', {'MainPID': '0'})]: + original = self.states[name].copy() + self.states[name].update(changes) + with self.assertRaises(ValueError): provision.install(self.directory, self.context) + self.states[name] = original + self.assertFalse(provision.journal_path(self.directory).exists()) + self.assertEqual(self.calls, []) + + def test_missing_service_pid_is_not_treated_like_a_stopped_socket(self): + state = self.state(maintenance_plan.SERVICE, loaded='not-found', enabled='') + state.pop('MainPID') + self.states[maintenance_plan.SERVICE] = state + with self.assertRaisesRegex(ValueError, 'already exists'): + provision.install(self.directory, self.context) + self.assertFalse(provision.journal_path(self.directory).exists()) + self.assertEqual(self.calls, []) + + def test_preexisting_or_symlinked_service_is_never_overwritten(self): + target = self.units/maintenance_plan.SERVICE + target.symlink_to(self.foreign) + with self.assertRaises(ValueError): provision.install(self.directory, self.context) + self.assertTrue(target.is_symlink()) + self.assertEqual(self.foreign.read_text(), 'unrelated service') + self.assertFalse(provision.journal_path(self.directory).exists()) + + def test_journal_precedes_atomic_publication_and_crash_can_be_rolled_back(self): + original = provision.publish_unit + def publish(path, contents): + self.assertEqual(provision.read_journal(self.directory, self.context)['phase'], 'installing') + if path.name == maintenance_plan.SERVICE: raise OSError('simulated power loss') + original(path, contents) + with patch.object(provision, 'publish_unit', side_effect=publish): + with self.assertRaisesRegex(OSError, 'power loss'): provision.install(self.directory, self.context) + self.assertTrue((self.units/maintenance_plan.SOCKET).exists()) + self.assertFalse((self.units/maintenance_plan.SERVICE).exists()) + self.assertEqual(provision.rollback(self.directory, self.context)['phase'], 'rolled_back') + self.assert_preserved() + + def test_unknown_enable_outcome_recovers_actual_enabled_state_without_replay(self): + provision.install(self.directory, self.context) + original = self.control + def control(*arguments): + result = original(*arguments) + if arguments[0] == 'enable': raise TimeoutError('reply lost after enable') + return result + with patch.object(handoff, 'systemctl', side_effect=control): + with self.assertRaises(TimeoutError): provision.enable(self.directory, self.context) + self.assertEqual(provision.read_journal(self.directory, self.context)['phase'], 'enabling') + with self.assertRaises(ValueError): provision.enable(self.directory, self.context) + self.assertEqual(provision.rollback(self.directory, self.context)['phase'], 'rolled_back') + self.assertEqual(sum(call[0] == 'enable' for call in self.calls), 1) + self.assert_preserved() + + def test_changed_unit_or_override_is_preserved_before_stop(self): + provision.install(self.directory, self.context) + original = (self.units/maintenance_plan.SERVICE).read_bytes() + (self.units/maintenance_plan.SERVICE).write_text('foreign replacement') + before = list(self.calls) + with self.assertRaisesRegex(ValueError, 'changed'): provision.rollback(self.directory, self.context) + self.assertEqual((self.units/maintenance_plan.SERVICE).read_text(), 'foreign replacement') + self.assertEqual(self.calls, before) + (self.units/maintenance_plan.SERVICE).write_bytes(original) + self.states[maintenance_plan.SERVICE]['DropInPaths'] = '/foreign/override.conf' + with self.assertRaisesRegex(ValueError, 'override'): provision.rollback(self.directory, self.context) + self.assertEqual(self.calls, before) + + def test_failed_stop_preserves_units_and_recovery_can_continue(self): + provision.install(self.directory, self.context) + provision.enable(self.directory, self.context) + self.states[maintenance_plan.SERVICE].update(ActiveState='active', MainPID='999') + original = self.control + with patch.object(handoff, 'systemctl', side_effect=lambda *args: '' if args[0] == 'stop' else original(*args)): + with self.assertRaisesRegex(ValueError, 'did not stop'): provision.rollback(self.directory, self.context) + self.assertTrue(all((self.units/name).exists() for name in provision.UNITS)) + self.assertEqual(provision.rollback(self.directory, self.context)['phase'], 'rolled_back') + self.assert_preserved() + + def test_refuses_permissive_socket_and_preserves_recovery_journal(self): + provision.install(self.directory, self.context) + original = self.control + def control(*arguments): + result = original(*arguments) + if arguments[0] == 'enable': self.socket.chmod(0o666) + return result + with patch.object(handoff, 'systemctl', side_effect=control): + with self.assertRaisesRegex(ValueError, 'permissions'): provision.enable(self.directory, self.context) + self.assertEqual(provision.read_journal(self.directory, self.context)['phase'], 'enabling') + provision.rollback(self.directory, self.context) + self.assert_preserved() + + def test_active_cached_service_without_reviewed_file_requires_manual_review(self): + provision.install(self.directory, self.context) + (self.units/maintenance_plan.SERVICE).unlink() + self.states[maintenance_plan.SERVICE].update(ActiveState='active', MainPID='999') + before = list(self.calls) + with self.assertRaisesRegex(ValueError, 'cached process'): provision.rollback(self.directory, self.context) + self.assertEqual(self.calls, before) + + def test_changed_controller_ownership_never_records_success(self): + original = self.control + def control(*arguments): + result = original(*arguments) + self.states['free-sleep.service']['MainPID'] = '0' + return result + with patch.object(handoff, 'systemctl', side_effect=control): + with self.assertRaisesRegex(ValueError, 'Retained'): provision.install(self.directory, self.context) + self.assertEqual(provision.read_journal(self.directory, self.context)['phase'], 'installing') + self.states['free-sleep.service']['MainPID'] = '123' + provision.rollback(self.directory, self.context) + self.assert_preserved() + + +class ContextTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.directory, self.controller = self.root/'maintenance-plan-fixture', self.root/'service-plan-fixture' + self.directory.mkdir(mode=0o700) + self.controller.mkdir(mode=0o700) + self.environment = self.root/'controller.env' + self.environment.write_text('SLEEPYPOD_AUTH_TOKEN=local-test-key-never-exported\n') + self.environment.chmod(0o600) + for directory, names in ((self.directory, (*provision.UNITS, 'maintenance-plan.json')), + (self.controller, (*service_plan.CORE_UNITS, 'plan.json'))): + for name in names: + (directory/name).write_text('delegated renderer fixture') + (directory/name).chmod(0o600) + self.plan = {'units': {name: 'b'*64 for name in service_plan.CORE_UNITS}, + 'config': {**CONFIG, 'maintenanceSocket': service_plan.MAINTENANCE_SOCKET, + 'release': str(self.root/'releases/reviewed'), 'environment': str(self.environment)}} + self.manifest = {'controllerPlan': str(self.controller), 'release': self.plan['config']['release'], + 'units': {name: 'c'*64 for name in provision.UNITS}} + self.write(self.controller/'handoff.json', {'format': 'sleepypod-handoff/1', 'phase': 'installed_not_started', 'unitHashes': self.plan['units']}) + patches = [patch.object(provision, 'ROOT', self.root), patch.object(provision, 'OWNER_UID', os.getuid()), + patch.object(provision, 'secure_path'), patch.object(provision, 'verify_release', return_value='a'*40), + patch.object(maintenance_plan, 'read_plan', return_value=self.manifest), + patch.object(service_plan, 'read_plan', return_value=self.plan), patch.object(handoff, 'read_owner_key')] + self.mocks = [context.start() for context in patches] + for context in patches: self.addCleanup(context.stop) + + def write(self, path, value): + path.write_text(json.dumps(value)) + path.chmod(0o600) + + def test_matching_context_does_not_export_credentials(self): + context = provision.load_context(self.directory) + self.assertEqual(context['revision'], 'a'*40) + self.assertNotIn('local-test-key', json.dumps(context, default=str)) + self.mocks[-1].assert_called_once_with(self.environment) + + def test_mismatched_release_or_missing_controller_opt_in_refused(self): + self.manifest['release'] += '-different' + with self.assertRaisesRegex(ValueError, 'does not select'): provision.load_context(self.directory) + self.manifest['release'] = self.plan['config']['release'] + self.plan['config'].pop('maintenanceSocket') + with self.assertRaisesRegex(ValueError, 'does not select'): provision.load_context(self.directory) + + def test_credential_environment_override_is_rejected_without_rewriting_it(self): + value = b'SLEEPYPOD_AUTH_TOKEN=private-fixture\nSLEEPYPOD_MAINTENANCE_SOCKET=/tmp/other\n' + self.environment.write_bytes(value) + with self.assertRaisesRegex(ValueError, 'conflicting'): provision.load_context(self.directory) + self.assertEqual(self.environment.read_bytes(), value) + + def test_active_cutover_refused_but_completed_controller_rollback_allows_cleanup(self): + record = {'format': 'sleepypod-activation/1', 'unitHashes': self.plan['units'], 'phase': 'active', + 'activeOwner': 'sleepypod-core', 'bootOwner': 'sleepypod-core'} + self.write(self.controller/'activation.json', record) + with self.assertRaisesRegex(ValueError, 'Roll back'): provision.load_context(self.directory) + record.update(phase='rolled_back', activeOwner='free-sleep', bootOwner='free-sleep') + self.write(self.controller/'activation.json', record) + self.assertEqual(provision.load_context(self.directory)['revision'], 'a'*40) + + def test_recovery_uses_bound_journal_without_requiring_runtime_or_credentials(self): + self.environment.unlink() + self.mocks[3].side_effect = FileNotFoundError('runtime unavailable') + journal = {'format': provision.JOURNAL_FORMAT, 'phase': 'enabling', 'sourceCommit': 'a'*40, + 'unitHashes': self.manifest['units'], 'controllerPlan': str(self.controller)} + self.write(provision.journal_path(self.directory), journal) + context = provision.load_context(self.directory, recovery=True) + self.assertEqual(provision.read_journal(self.directory, context), journal) + self.mocks[3].assert_not_called() + self.mocks[-1].assert_not_called() + journal['unitHashes'] = {} + self.write(provision.journal_path(self.directory), journal) + with self.assertRaisesRegex(ValueError, 'does not match'): provision.read_journal(self.directory, context) + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + broker = self.root/'app/scripts/deployment/maintenance_broker.py' + broker.parent.mkdir(parents=True) + broker.write_text('# inert package fixture\n') + (self.root/'app/.git-info').write_text(json.dumps({'commitHash': 'abcdef1'})) + self.manifest = {'format': 'sleepypod-offline-runtime/1', 'maintenanceProtocol': 1, + 'sourceCommit': 'abcdef1'+'0'*33, + 'application': [{'path': name, 'sha256': provision.digest((self.root/'app'/name).read_bytes())} + for name in ('scripts/deployment/maintenance_broker.py', '.git-info')]} + for target, name, value in [(provision, 'OWNER_UID', os.getuid()), (provision, 'secure_path', lambda *_args, **_kwargs: None)]: + context = patch.object(target, name, value) + context.start() + self.addCleanup(context.stop) + + def verify(self): + (self.root/'bundle-manifest.json').write_text(json.dumps(self.manifest)) + return provision.verify_release(self.root) + + def test_matching_broker_and_client_build_identity(self): + self.assertEqual(self.verify(), self.manifest['sourceCommit']) + + def test_old_protocol_duplicate_record_or_tampered_broker_refused(self): + for protocol in (None, True, 2): + self.manifest['maintenanceProtocol'] = protocol + with self.assertRaisesRegex(ValueError, 'protocol'): self.verify() + self.manifest['maintenanceProtocol'] = 1 + self.manifest['application'].append(self.manifest['application'][0]) + with self.assertRaisesRegex(ValueError, 'manifest'): self.verify() + self.manifest['application'].pop() + (self.root/'app/scripts/deployment/maintenance_broker.py').write_text('changed') + with self.assertRaisesRegex(ValueError, 'manifest'): self.verify() + + def test_wrong_build_identity_and_shared_writable_broker_refused(self): + self.manifest['sourceCommit'] = 'a'*40 + with self.assertRaisesRegex(ValueError, 'identity'): self.verify() + self.manifest['sourceCommit'] = 'abcdef1'+'0'*33 + (self.root/'app/scripts/deployment/maintenance_broker.py').chmod(0o666) + with self.assertRaisesRegex(ValueError, 'permissions'): self.verify() + + def test_atomic_publication_refuses_existing_files_and_symlinks(self): + target = self.root/'existing' + target.write_bytes(b'original') + with self.assertRaises(FileExistsError): provision.publish_unit(target, b'replacement') + self.assertEqual(target.read_bytes(), b'original') + link = self.root/'link' + link.symlink_to(target) + with self.assertRaises(FileExistsError): provision.publish_unit(link, b'replacement') + self.assertEqual(target.read_bytes(), b'original') + self.assertEqual(list(self.root.glob('.sleepypod-maintenance-*')), []) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/deployment/test_maintenance_plan.py b/scripts/deployment/test_maintenance_plan.py new file mode 100644 index 000000000..088362cea --- /dev/null +++ b/scripts/deployment/test_maintenance_plan.py @@ -0,0 +1,58 @@ +import json +from pathlib import Path +import tempfile +import unittest + +import maintenance_plan as plan + +CONTROLLER = '/persistent/sleepypod-unified/service-plan-reviewed' +RELEASE = '/persistent/sleepypod-unified/releases/reviewed' + + +class MaintenancePlanTests(unittest.TestCase): + def test_private_socket_and_hardened_root_service(self): + units = plan.render(CONTROLLER, RELEASE) + self.assertEqual(set(units), {plan.SERVICE, plan.SOCKET}) + socket = units[plan.SOCKET] + for setting in ('SocketMode=0660', 'SocketGroup=dac', 'SocketUser=root', 'Accept=no', + 'ListenStream=/run/sleepypod-maintenance/control.sock'): + self.assertIn(setting, socket) + service = units[plan.SERVICE] + for setting in ('NoNewPrivileges=true', 'CapabilityBoundingSet=\n', 'RestrictAddressFamilies=AF_UNIX', + 'ProtectSystem=strict', 'PrivateDevices=true', 'RuntimeDirectoryPreserve=yes', + 'RuntimeDirectoryMode=0700', 'User=root', 'Group=root', ' -I ', '--plan '+CONTROLLER): + self.assertIn(setting, service) + self.assertNotIn('EnvironmentFile=', service) + self.assertNotIn('sudo', service) + self.assertNotIn('sp-update', service) + self.assertNotIn('ReadWritePaths=/persistent', service) + + def test_paths_cannot_escape_managed_roots_or_inject_unit_directives(self): + for value in ('/tmp/plan', CONTROLLER+'/child', '/persistent/sleepypod-unified/../../etc', + CONTROLLER+'\nUser=dac', CONTROLLER+'%i', CONTROLLER+' other'): + with self.subTest(value=value), self.assertRaises(ValueError): plan.render(value, RELEASE) + with self.assertRaises(ValueError): plan.render(CONTROLLER, '/persistent/free-sleep') + + def test_round_trip_retains_reviewed_content_and_refuses_overwrite(self): + with tempfile.TemporaryDirectory() as directory: + output=Path(directory)/'plan' + expected=plan.prepare(CONTROLLER, RELEASE, output) + self.assertEqual(plan.read_plan(output), expected) + self.assertFalse(expected['servicesStarted']) + self.assertFalse(expected['coreEnvironmentChanged']) + self.assertEqual((output/'maintenance-plan.json').stat().st_mode & 0o777, 0o600) + with self.assertRaises(ValueError): plan.prepare(CONTROLLER, RELEASE, output) + (output/plan.SERVICE).write_text('ExecStart=/bin/reboot\n') + with self.assertRaises(ValueError): plan.read_plan(output) + + def test_unknown_membership_and_symlink_units_refused(self): + with tempfile.TemporaryDirectory() as directory: + output=Path(directory)/'plan' + plan.prepare(CONTROLLER, RELEASE, output) + service=output/plan.SERVICE + service.unlink() + service.symlink_to(output/'missing') + with self.assertRaises(ValueError): plan.read_plan(output) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/deployment/test_maintenance_status.py b/scripts/deployment/test_maintenance_status.py new file mode 100644 index 000000000..b259168fe --- /dev/null +++ b/scripts/deployment/test_maintenance_status.py @@ -0,0 +1,91 @@ +from datetime import datetime, timezone +import time +import unittest +import urllib.error +from unittest.mock import patch + +import handoff +import maintenance_status + + +def status(): + return {'apiVersion': 1, 'kind': 'sleepypod-maintenance-status', + 'generatedAt': datetime.now(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'), + 'controller': {'id': 'sleepypod-core', 'revision': 'abcdef1'}, + 'device': {'freshness': 'fresh'}, 'temperatureRuntime': 'ready', + 'thermalIdle': True, 'privacyBoundary': 'personal_required'} + + +class MaintenanceStatusTests(unittest.TestCase): + def test_private_status_never_reads_the_legacy_owner_api(self): + with patch.object(handoff, 'local_json', return_value=status()) as request: + observation = handoff.core_observation('private-fixture', 'abcdef1' + '0'*33) + handoff.require_core_thermal_idle('private-fixture', observation) + request.assert_called_once_with(maintenance_status.PATH, 'private-fixture') + self.assertEqual(observation['privacyBoundary'], 'personal_required') + self.assertNotIn('private-fixture', repr(observation)) + + def test_only_missing_endpoint_falls_back_for_an_older_release(self): + legacy = {'controller': {'id': 'sleepypod-core', 'revision': 'abcdef1'}, + 'observations': {'device': {'freshness': 'fresh'}}, + 'features': {'temperatureControl': {'runtime': 'ready'}}} + error = urllib.error.HTTPError(maintenance_status.PATH, 404, 'missing', {}, None) + with patch.object(handoff, 'local_json', side_effect=[error, legacy]) as request: + result = handoff.core_observation('private-fixture', 'abcdef1' + '0'*33) + self.assertEqual(request.call_count, 2) + self.assertNotIn('maintenanceProtocol', result) + + def test_auth_storage_redirect_and_network_errors_never_fall_back(self): + errors = [urllib.error.HTTPError(maintenance_status.PATH, code, 'error', {}, None) for code in (301, 400, 401, 403, 500, 503)] + errors += [OSError('offline'), ValueError('invalid JSON')] + for error in errors: + with self.subTest(error=type(error).__name__), patch.object(handoff, 'local_json', side_effect=error) as request: + with self.assertRaises(type(error)): + handoff.core_observation('private-fixture', 'abcdef1' + '0'*33) + self.assertEqual(request.call_count, 1) + + def test_malformed_or_private_payload_never_enters_a_journal_or_falls_back(self): + cases = [None, [], {}, {**status(), 'privateAccount': 'fixture'}, + {**status(), 'controller': {'id': 'sleepypod-core', 'revision': 'abcdef1', 'name': 'fixture'}}, + {**status(), 'device': {'freshness': 'fresh', 'left': 'fixture'}}] + for field, values in { + 'apiVersion': [True, 2, None], 'kind': ['other'], 'thermalIdle': [1, 0, 'true'], + 'temperatureRuntime': ['unknown', None], 'privacyBoundary': ['unknown', None], + 'generatedAt': [None, 'invalid', '2020-01-01T00:00:00Z', '2099-01-01T00:00:00Z'], + }.items(): + cases.extend({**status(), field: value} for value in values) + for value in cases: + with self.subTest(value=value), patch.object(handoff, 'local_json', return_value=value) as request: + with self.assertRaises(ValueError): + handoff.core_observation('private-fixture', 'abcdef1' + '0'*33) + self.assertEqual(request.call_count, 1) + + def test_revision_is_bound_to_the_staged_release(self): + for revision in ('bbbbbbb', 'abcdef', True, 'abcdef1-private', 'A'*40, None): + value = status() + value['controller']['revision'] = revision + with self.subTest(revision=revision), self.assertRaises(ValueError): + maintenance_status.parse(value, 'abcdef1' + '0'*33) + + def test_stale_or_unavailable_status_cannot_assert_readiness(self): + for changed in ({'device': {'freshness': 'stale'}}, {'device': {'freshness': 'unavailable'}}, {'privacyBoundary': 'unavailable'}): + with self.assertRaises(ValueError): + maintenance_status.parse({**status(), **changed}, 'abcdef1' + '0'*33) + value = {**status(), **changed, 'thermalIdle': None, 'temperatureRuntime': 'not_ready'} + observation = maintenance_status.parse(value, 'abcdef1' + '0'*33) + with self.assertRaises(RuntimeError): maintenance_status.require_idle(observation) + + def test_idle_is_rechecked_and_not_inferred_from_fresh_telemetry(self): + observation = maintenance_status.parse(status(), 'abcdef1' + '0'*33) + for field, values in {'thermalIdle': [False, None, 1], 'maintenanceProtocol': [True, 2], + 'temperatureRuntime': ['not_ready']}.items(): + for value in values: + with self.subTest(field=field, value=value), patch.object(handoff, 'local_json') as legacy: + with self.assertRaises(RuntimeError): + handoff.require_core_thermal_idle('key', {**observation, field: value}) + legacy.assert_not_called() + with patch.object(maintenance_status.time, 'time', return_value=time.time() + 20): + with self.assertRaises(ValueError): maintenance_status.require_idle(observation) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/deployment/test_network_trial.py b/scripts/deployment/test_network_trial.py new file mode 100644 index 000000000..4f0e73c8f --- /dev/null +++ b/scripts/deployment/test_network_trial.py @@ -0,0 +1,253 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import network_trial as trial + + +class FirewallMachine: + """Model kernel tables, retaining unrelated rules while faulting chosen operations.""" + def __init__(self): + self.tables = {binary: [['-P', 'INPUT', 'ACCEPT'], ['-P', 'OUTPUT', 'ACCEPT'], + ['-A', 'OUTPUT', '-d', '203.0.113.1/32', '-j', 'DROP']] + for binary in trial.FIREWALLS} + self.original = {binary: [list(rule) for rule in rules] for binary, rules in self.tables.items()} + self.calls = [] + self.timer = False + self.fail = lambda _command: False + self.route = [{'dst': '10.0.1.68', 'dev': 'wlan0', 'prefsrc': '10.0.1.69'}] + self.on_mutation = lambda: None + + def run(self, command, check=True): + self.calls.append(command) + if self.fail(command): + raise RuntimeError('Injected command failure') + binary, *arguments = command + output = '' + if binary in trial.FIREWALLS: + arguments = arguments[2:] # -w 2 + action, *rest = arguments + rules = self.tables[binary] + if action == '-S': + output = '\n'.join(' '.join(rule) for rule in rules) + else: + self.on_mutation() + if action == '-N': + rules.append(['-N', *rest]) + elif action == '-A': + rules.append(['-A', *rest]) + elif action == '-I': + rules.insert(0, ['-A', rest[0], *rest[2:]]) + elif action == '-D': + rules.remove(['-A', *rest]) + elif action == '-F': + self.tables[binary] = [rule for rule in rules if rule[:2] != ['-A', rest[0]]] + elif action == '-X': + rules.remove(['-N', *rest]) + else: + raise AssertionError(command) + elif binary == trial.ROUTE: + output = json.dumps(self.route) + elif binary == trial.SYSTEMD_RUN: + self.timer = True + elif binary == trial.SYSTEMCTL: + if arguments[0] == 'is-active' and arguments[-1].endswith('.timer'): + if not self.timer: + raise RuntimeError('Timer is not active') + elif arguments[0] == 'stop': + self.timer = False + else: + raise AssertionError(command) + return subprocess.CompletedProcess(command, 0, stdout=output, stderr='') + + +class NetworkTrialTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.script = self.root / 'network_trial.py' + self.script.write_text('reviewed fixture') + self.boot = self.root / 'boot-id' + self.boot.write_text('fixture-boot') + self.machine = FirewallMachine() + for target, value in [('STATE_ROOT', self.root / 'state'), ('BOOT_ID', self.boot), + ('secure_path', lambda *_args, **_kwargs: None), ('run', self.machine.run)]: + context = patch.object(trial, target, value) + context.start() + self.addCleanup(context.stop) + for context in [patch.dict(os.environ, {'SSH_CONNECTION': '10.0.1.68 55000 10.0.1.69 8822'}), + patch.object(trial.os, 'geteuid', return_value=0), patch.object(trial.os, 'access', return_value=True)]: + context.start() + self.addCleanup(context.stop) + + def apply(self, seconds=180): + return trial.apply('10.0.1.68', seconds, self.script) + + def test_preflight_is_read_only_and_requires_both_families(self): + result = trial.preflight('10.0.1.68', self.script) + self.assertEqual(set(result['firewalls']), {'iptables', 'ip6tables'}) + self.assertEqual(self.machine.tables, self.machine.original) + self.assertFalse(trial.STATE_ROOT.exists()) + self.machine.fail = lambda command: command[0] == trial.FIREWALLS[1] + with self.assertRaisesRegex(RuntimeError, 'Injected'): + self.apply() + self.assertFalse(self.machine.timer) + self.assertEqual(self.machine.tables, self.machine.original) + + def test_apply_arms_recovery_and_persists_intent_before_first_rule(self): + def assert_recovery(): + self.assertTrue(self.machine.timer) + self.assertEqual(trial.read_record()['phase'], 'armed') + self.machine.on_mutation = assert_recovery + record = self.apply() + self.machine.on_mutation = lambda: None + self.assertEqual(record['phase'], 'isolated') + self.assertFalse(record['physicalOperationVerified']) + for rules in self.machine.tables.values(): + self.assertEqual(sum(rule[:1] == ['-N'] for rule in rules), 2) + self.assertEqual(trial.recover(record['id'])['phase'], 'restored') + self.assertEqual(self.machine.tables, self.machine.original) + self.assertFalse(self.machine.timer) + self.assertEqual(trial.recover(record['id'])['phase'], 'restored') + + def test_timer_creation_or_verification_failure_never_changes_rules(self): + for failure in [lambda command: command[0] == trial.SYSTEMD_RUN, + lambda command: command[0] == trial.SYSTEMCTL and command[-1].endswith('.timer') and command[1] == 'is-active']: + self.machine.fail = failure + with self.assertRaisesRegex(RuntimeError, 'Injected'): + self.apply() + self.assertEqual(self.machine.tables, self.machine.original) + self.assertFalse(any(command[0] in trial.FIREWALLS and '-N' in command for command in self.machine.calls)) + + def test_partial_ipv6_failure_recovers_ipv4_and_ipv6(self): + self.machine.fail = lambda command: command[0] == trial.FIREWALLS[1] and '-I' in command + with self.assertRaisesRegex(RuntimeError, 'Injected'): + self.apply() + self.assertEqual(self.machine.tables, self.machine.original) + self.assertEqual(trial.read_record()['phase'], 'restored') + + def test_transient_cleanup_failure_keeps_recovery_available_for_retry(self): + record = self.apply() + self.machine.fail = lambda command: '-D' in command + with self.assertRaisesRegex(RuntimeError, 'Recovery incomplete'): + trial.recover(record['id']) + self.assertTrue(self.machine.timer) + self.assertEqual(trial.read_record()['phase'], 'restore_failed') + self.machine.fail = lambda _command: False + trial.recover(record['id']) + self.assertEqual(self.machine.tables, self.machine.original) + + def test_duplicate_start_and_wrong_recovery_id_leave_active_trial_unchanged(self): + record = self.apply() + before = json.dumps(self.machine.tables) + with self.assertRaisesRegex(ValueError, 'unfinished'): + self.apply() + with self.assertRaisesRegex(ValueError, 'does not match'): + trial.recover('0' * 32) + self.assertEqual(json.dumps(self.machine.tables), before) + trial.recover(record['id']) + + def test_foreign_rule_is_detached_but_never_flushed(self): + record = self.apply() + plan = trial.plans('10.0.1.68', record['id'])[0] + foreign = ['-A', plan['chain'], '-j', 'ACCEPT'] + self.machine.tables[plan['binary']].append(foreign) + with self.assertRaisesRegex(RuntimeError, 'Foreign rules'): + trial.recover(record['id']) + self.assertIn(foreign, self.machine.tables[plan['binary']]) + self.assertFalse(any('sleepypod-trial-' + record['id'] in rule for rules in self.machine.tables.values() for rule in rules)) + + def test_private_peer_route_and_recovery_port_required(self): + for address in ['127.0.0.1', '8.8.8.8', '100.64.0.1', '::1', '10.0.1.71']: + with self.assertRaises(ValueError): + trial.preflight(address, self.script) + self.machine.route[0]['gateway'] = '10.0.1.1' + with self.assertRaisesRegex(ValueError, 'direct LAN'): + self.apply() + self.assertFalse(self.machine.timer) + + def test_out_of_range_duration_rejected_without_state_or_commands(self): + for seconds in [-1, 0, 119, 301, 86400]: + with self.assertRaises(ValueError): + self.apply(seconds) + self.assertFalse(trial.STATE_ROOT.exists()) + self.assertEqual(self.machine.calls, []) + + def test_rule_policy_does_not_allow_established_remote_sessions_or_ipv6_bypass(self): + configuration = trial.plans('10.0.1.68', '1' * 32) + for plan in configuration: + self.assertEqual(plan['rules'][-1], ['-j', 'DROP']) + self.assertNotIn('ESTABLISHED', json.dumps(plan)) + if plan['binary'] == trial.FIREWALLS[1]: + self.assertEqual(len(plan['rules']), 2) # loopback, then drop + else: + self.assertIn('10.0.1.68/32', plan['rules'][1]) + self.assertIn('8822,3000,3001', plan['rules'][1]) + + def test_timer_uses_pinned_script_and_independent_retrying_system_service(self): + record = self.apply() + command = next(command for command in self.machine.calls if command[0] == trial.SYSTEMD_RUN) + self.assertIn('--on-active=180s', command) + self.assertIn('--property=Restart=on-failure', command) + self.assertIn('--property=StartLimitIntervalSec=0', command) + self.assertEqual(command[-5:], ['-B', str(self.script), 'recover', '--trial-id', record['id']]) + + def test_recovery_rehearsal_never_installs_isolation_rules(self): + record = trial.rehearse('10.0.1.68', self.script) + self.assertEqual(record['phase'], 'rehearsal') + self.assertTrue(self.machine.timer) + self.assertEqual(self.machine.tables, self.machine.original) + trial.recover(record['id']) + self.assertEqual(self.machine.tables, self.machine.original) + self.assertFalse(any(command[0] in trial.FIREWALLS and '-N' in command for command in self.machine.calls)) + + def test_arming_failure_before_unit_creation_can_still_be_recovered(self): + original = self.machine.run + def missing_timer(command, check=True): + if command[0] == trial.SYSTEMD_RUN: + raise RuntimeError('Arming failed before unit creation') + if command[:2] == [trial.SYSTEMCTL, 'stop']: + return subprocess.CompletedProcess(command, 5, stdout='', stderr='Unit not loaded') + if command[:2] == [trial.SYSTEMCTL, 'show']: + return subprocess.CompletedProcess(command, 4, stdout='not-found\n', stderr='') + return original(command, check=check) + with patch.object(trial, 'run', missing_timer), self.assertRaisesRegex(RuntimeError, 'Arming failed'): + self.apply() + self.assertEqual(trial.read_record()['phase'], 'restored') + self.assertEqual(self.machine.tables, self.machine.original) + + def test_unconfirmed_timer_stop_prevents_reusing_the_trial(self): + record = self.apply() + original = self.machine.run + def stop_failure(command, check=True): + if command[:2] == [trial.SYSTEMCTL, 'stop']: + return subprocess.CompletedProcess(command, 1, stdout='', stderr='Manager unavailable') + if command[:2] == [trial.SYSTEMCTL, 'show']: + return subprocess.CompletedProcess(command, 1, stdout='', stderr='Manager unavailable') + return original(command, check=check) + with patch.object(trial, 'run', stop_failure), self.assertRaisesRegex(RuntimeError, 'timer stopped'): + trial.recover(record['id']) + self.assertEqual(trial.read_record()['phase'], 'restore_failed') + with self.assertRaisesRegex(ValueError, 'unfinished'): + self.apply() + trial.recover(record['id']) + + +class SecurePathTests(unittest.TestCase): + def test_recovery_rejects_symlink_and_user_writable_parent(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + (root / 'script').write_text('fixture') + (root / 'linked').symlink_to(root / 'script') + for path in [root / 'linked', root / 'script']: + with self.assertRaises(ValueError): + trial.secure_path(path) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_network_trial_netns.py b/scripts/deployment/test_network_trial_netns.py new file mode 100644 index 000000000..75b59642d --- /dev/null +++ b/scripts/deployment/test_network_trial_netns.py @@ -0,0 +1,108 @@ +"""Opt-in Linux packet test. All firewall changes stay inside disposable namespaces.""" +import os +import select +import subprocess +import sys +import unittest +from unittest.mock import patch +import uuid + +import network_trial as trial + + +ENABLED = os.environ.get('SLEEPYPOD_NETWORK_NAMESPACE_TEST') == '1' + + +@unittest.skipUnless(ENABLED and sys.platform == 'linux' and os.geteuid() == 0, + 'Requires explicit Linux root network-namespace test opt-in') +class NetworkNamespaceTest(unittest.TestCase): + def command(self, *arguments, check=True): + return subprocess.run(arguments, check=check, capture_output=True, text=True, timeout=10) + + def namespace(self, name, *arguments): + return self.command('ip', 'netns', 'exec', name, *arguments) + + def setUp(self): + suffix = uuid.uuid4().hex[:8] + self.pod, self.peer = 'spq-pod-' + suffix, 'spq-peer-' + suffix + self.servers = [] + self.addCleanup(self.cleanup) + for name in (self.pod, self.peer): + self.command('ip', 'netns', 'add', name) + self.namespace(name, 'ip', 'link', 'set', 'lo', 'up') + self.command('ip', '-n', self.pod, 'link', 'add', 'bed0', 'type', 'veth', 'peer', 'name', 'peer0', 'netns', self.peer) + for name, interface, addresses in [(self.pod, 'bed0', ['192.168.70.2/24', 'fd35:abcd::2/64']), + (self.peer, 'peer0', ['192.168.70.3/24', '192.168.70.4/24', 'fd35:abcd::3/64'])]: + for address in addresses: + extra = ['nodad'] if ':' in address else [] + self.namespace(name, 'ip', 'address', 'add', address, 'dev', interface, *extra) + self.namespace(name, 'ip', 'link', 'set', interface, 'up') + self.listen(self.pod, '192.168.70.2', 3000) + self.listen(self.pod, 'fd35:abcd::2', 3001) + self.listen(self.peer, '192.168.70.3', 1883) + + def cleanup(self): + for process in self.servers: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + process.stdout.close() + for name in (self.pod, self.peer): + self.command('ip', 'netns', 'delete', name, check=False) + + def listen(self, namespace, address, port): + source = '''import socket,sys +address,port=sys.argv[1],int(sys.argv[2]) +listener=socket.socket(socket.AF_INET6 if ':' in address else socket.AF_INET) +listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) +listener.bind((address,port)); listener.listen(); print('ready',flush=True) +while True: + connection,_=listener.accept(); connection.close() +''' + process = subprocess.Popen(['ip', 'netns', 'exec', namespace, sys.executable, '-u', '-c', source, address, str(port)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.servers.append(process) + self.assertTrue(select.select([process.stdout], [], [], 5)[0], 'Test listener did not start') + self.assertEqual(process.stdout.readline().strip(), 'ready') + + def connects(self, namespace, source_address, destination, port): + source = '''import socket,sys +source,destination,port=sys.argv[1],sys.argv[2],int(sys.argv[3]) +connection=socket.socket(socket.AF_INET6 if ':' in source else socket.AF_INET) +connection.settimeout(1); connection.bind((source,0)) +try: connection.connect((destination,port)); print('connected') +except OSError: print('blocked') +finally: connection.close() +''' + return self.namespace(namespace, sys.executable, '-c', source, source_address, destination, str(port)).stdout.strip() == 'connected' + + def test_real_ipv4_ipv6_packets_and_restoration(self): + probes = [(self.peer, '192.168.70.3', '192.168.70.2', 3000), + (self.peer, '192.168.70.4', '192.168.70.2', 3000), + (self.peer, 'fd35:abcd::3', 'fd35:abcd::2', 3001), + (self.pod, '192.168.70.2', '192.168.70.3', 1883)] + self.assertEqual([self.connects(*probe) for probe in probes], [True] * 4) + self.namespace(self.pod, trial.FIREWALLS[0], '-A', 'OUTPUT', '-d', '203.0.113.1/32', '-j', 'DROP') + original = {binary: self.namespace(self.pod, binary, '-S').stdout for binary in trial.FIREWALLS} + record = {'id': uuid.uuid4().hex, 'controlAddress': '192.168.70.3'} + def run_in_pod(arguments, check=True): + if arguments[0] == trial.SYSTEMCTL: + return subprocess.CompletedProcess(arguments, 0, stdout='', stderr='') + return self.namespace(self.pod, *arguments) + with patch.object(trial, 'run', run_in_pod), patch.object(trial, 'save'): + for plan in trial.plans(record['controlAddress'], record['id']): + run_in_pod([plan['binary'], '-N', plan['chain']]) + for rule in plan['rules']: + run_in_pod([plan['binary'], '-A', plan['chain'], *rule]) + run_in_pod([plan['binary'], '-I', plan['direction'], '1', *plan['jump']]) + self.assertEqual([self.connects(*probe) for probe in probes], [True, False, False, False]) + trial.restore(record) + self.assertEqual([self.connects(*probe) for probe in probes], [True] * 4) + self.assertEqual({binary: self.namespace(self.pod, binary, '-S').stdout for binary in trial.FIREWALLS}, original) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_prepare_data.py b/scripts/deployment/test_prepare_data.py new file mode 100644 index 000000000..69655d193 --- /dev/null +++ b/scripts/deployment/test_prepare_data.py @@ -0,0 +1,281 @@ +"""Real backup/import files with simulated systemd; never contact a Pod or actuator.""" +import copy +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +import handoff +import activation_checks +import prepare_data +import service_plan +from test_handoff import CONFIG + + +BACKUP_SCRIPT = '''import hashlib,json,sys,tarfile +from pathlib import Path +source=Path(sys.argv[sys.argv.index('--data')+1]) +output=Path(sys.argv[sys.argv.index('--output')+1]); output.mkdir(mode=0o700) +manifest={'format':'free-sleep-migration/1','files':{},'archives':{}} +for category in ('data','private'): + archive_path=output/(category+'.tar.gz') + with tarfile.open(archive_path,'w:gz') as archive: + if category=='data': + for path in source.rglob('*'): + if path.is_file(): + name='data/'+str(path.relative_to(source)); archive.add(path,arcname=name) + manifest['files'][name]=hashlib.sha256(path.read_bytes()).hexdigest() + manifest['archives'][archive_path.name]=hashlib.sha256(archive_path.read_bytes()).hexdigest() +(output/'manifest.json').write_text(json.dumps(manifest)) +''' + + +class QuiescedImportTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.directory = self.root / 'plan' + self.plan = service_plan.prepare(CONFIG, self.directory) + self.legacy = self.root / 'retained-data' + self.legacy.mkdir() + self.output = self.root / 'new-preparation' + self.backup_tool = self.root / 'reviewed-backup.py' + self.backup_tool.write_text(BACKUP_SCRIPT) + self.backup_hash = prepare_data.digest(self.backup_tool) + self.unit_directory = self.root / 'units' + self.unit_directory.mkdir() + self.states, self.calls = {}, [] + self.stop_failure = self.start_failure = False + for name in service_plan.LEGACY_UNITS: + path = self.unit_directory / name + path.write_text('retained service\n') + self.states[name] = {'LoadState': 'loaded', 'ActiveState': 'active', 'MainPID': '100', + 'UnitFileState': 'enabled', 'KillMode': 'control-group', + 'FragmentPath': str(path), 'DropInPaths': ''} + for name, contents in service_plan.render(CONFIG).items(): + (self.unit_directory / name).write_text(contents) + self.states[name] = {'LoadState': 'loaded', 'ActiveState': 'inactive', 'MainPID': '0', + 'UnitFileState': 'disabled', 'FragmentPath': str(self.unit_directory / name), + 'DropInPaths': ''} + self.journal = {'format': 'sleepypod-handoff/1', 'phase': 'installed_not_started', + 'unitHashes': self.plan['units'], 'legacy': { + name: {'state': dict(self.states[name]), 'hashes': handoff.unit_hashes(self.states[name])} + for name in service_plan.LEGACY_UNITS}} + (self.directory / 'handoff.json').write_text(json.dumps(self.journal)) + idle = {'isPriming': False, **{side: {'isOn': False, 'isAlarmVibrating': False, 'secondsRemaining': 0} + for side in ('left', 'right')}} + for name, value in (('UNIT_DIRECTORY', self.unit_directory), ('systemctl', self.control), + ('unit_state', lambda name: dict(self.states[name])), + ('local_json', lambda *_: idle), ('restored_idle', lambda: True)): + context = patch.object(handoff, name, value) + context.start(); self.addCleanup(context.stop) + taps = {gesture: {'type': 'temperature', 'change': 'increment', 'amount': 1} + for gesture in ('doubleTap', 'tripleTap', 'quadTap')} + preference = {'name': 'Sleeper', 'awayMode': False, 'taps': taps, 'scheduleOverrides': {}} + settings = {'timeZone': 'America/Los_Angeles', 'temperatureFormat': 'fahrenheit', + 'primePodDaily': {'enabled': False, 'time': '00:15'}, 'rebootDaily': False, + 'left': preference, 'right': preference} + routine = {'power': {'enabled': False, 'on': '22:00', 'off': '07:00', 'onTemperature': 75}, + 'temperatures': {}, 'alarm': {'enabled': False, 'time': '06:30', 'duration': 10, + 'vibrationIntensity': 50, 'vibrationPattern': 'rise', 'alarmTemperature': 80}} + schedules = {side: {day: copy.deepcopy(routine) for day in + ('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday')} + for side in ('left', 'right')} + (self.legacy / 'lowdb').mkdir() + for name, value in (('settingsDB.json', settings), ('schedulesDB.json', schedules)): + (self.legacy / 'lowdb' / name).write_text(json.dumps(value)) + with sqlite3.connect(self.legacy / 'free-sleep.db') as database: + database.executescript(''' + CREATE TABLE vitals (id INTEGER PRIMARY KEY, side TEXT, timestamp INTEGER, heart_rate REAL, hrv REAL, breathing_rate REAL); + CREATE TABLE movement (id INTEGER PRIMARY KEY, side TEXT, timestamp INTEGER, total_movement INTEGER); + CREATE TABLE sleep_records (id INTEGER PRIMARY KEY, side TEXT, entered_bed_at INTEGER, left_bed_at INTEGER, + sleep_period_seconds INTEGER, times_exited_bed INTEGER, present_intervals TEXT, not_present_intervals TEXT); + ''') + + def control(self, *arguments): + self.calls.append(arguments) + action, *names = arguments + for name in names: + if action == 'stop' and not self.stop_failure: + self.states[name].update(ActiveState='inactive', MainPID='0') + if action == 'start' and not self.start_failure: + self.states[name].update(ActiveState='active', MainPID='200') + return '' + + def run_capture(self): + return prepare_data.prepare(self.directory, self.legacy, self.backup_tool, self.backup_hash, self.output) + + def record(self): + return json.loads((self.output / 'preparation.json').read_text()) + + def assert_restored(self): + self.assertTrue(all(self.states[name]['ActiveState'] == 'active' for name in service_plan.LEGACY_UNITS)) + self.assertTrue(all(self.states[name]['MainPID'] == '0' for name in service_plan.CORE_UNITS)) + self.assertTrue(all(call[0] in ('stop', 'start') and tuple(call[1:]) == service_plan.LEGACY_UNITS for call in self.calls)) + + def test_capture_restores_before_real_import_and_preserves_history(self): + before = {str(path.relative_to(self.legacy)): path.read_bytes() for path in self.legacy.rglob('*') if path.is_file()} + run_tool = prepare_data.run_tool + def observed(arguments, log, timeout): + expected = 'inactive' if log.name == 'capture.log' else 'active' + self.assertTrue(all(self.states[name]['ActiveState'] == expected for name in service_plan.LEGACY_UNITS)) + return run_tool(arguments, log, timeout) + with patch.object(prepare_data, 'run_tool', side_effect=observed): + result = self.run_capture() + self.assertEqual(result['phase'], 'imported_for_review') + self.assertTrue(result['captureComplete'] and result['restoredIdleVerified']) + self.assertFalse(result['coreStarted'] or result['readyForCutover'] or result['liveDataReplaced']) + self.assertLessEqual(result['capturedAt'], result['restoredAt']) + self.assertLessEqual(result['restoredAt'], result['importedAt']) + self.assert_restored() + self.assertEqual(before, {str(path.relative_to(self.legacy)): path.read_bytes() for path in self.legacy.rglob('*') if path.is_file()}) + self.assertTrue((self.output / 'candidate/free-sleep-history.db').is_file()) + activation_checks.candidate_ready(self.output / 'candidate') + with sqlite3.connect(self.output / 'candidate/sleepypod.db') as database: + self.assertEqual(database.execute('SELECT COUNT(*) FROM comfort_activations').fetchone(), (0,)) + self.assertEqual((self.output / 'preparation.json').stat().st_mode & 0o777, 0o600) + + def test_invalid_hash_existing_or_overlapping_destination_never_stops(self): + self.backup_hash = '0' * 64 + with self.assertRaisesRegex(ValueError, 'SHA-256'): self.run_capture() + self.backup_hash = prepare_data.digest(self.backup_tool) + self.output.mkdir() + with self.assertRaisesRegex(ValueError, 'new directory'): self.run_capture() + self.output = self.legacy / 'unsafe' + with self.assertRaisesRegex(ValueError, 'separate'): self.run_capture() + self.assertEqual(self.calls, []) + + def test_active_core_altered_service_or_active_heat_fail_before_downtime(self): + self.states[service_plan.CORE_UNIT].update(ActiveState='active', MainPID='101') + with self.assertRaisesRegex(ValueError, 'stopped and disabled'): self.run_capture() + self.states[service_plan.CORE_UNIT].update(ActiveState='inactive', MainPID='0') + with patch.object(handoff, 'require_thermal_idle', side_effect=RuntimeError('not idle')): + with self.assertRaisesRegex(RuntimeError, 'not idle'): self.run_capture() + (self.unit_directory / service_plan.LEGACY_UNITS[0]).write_text('unrelated unit') + with self.assertRaisesRegex(ValueError, 'configuration changed'): self.run_capture() + self.assertEqual(self.calls, []) + self.assertFalse(self.output.exists()) + + def test_unavailable_timezone_fails_before_capture_or_stop(self): + settings = self.legacy / 'lowdb/settingsDB.json' + value = json.loads(settings.read_text()); value['timeZone'] = 'Not/AZone' + settings.write_text(json.dumps(value)) + with self.assertRaises(prepare_data.subprocess.CalledProcessError): self.run_capture() + self.assertEqual(self.calls, []) + self.assertFalse(self.output.exists()) + + def test_output_cannot_land_inside_existing_core_data(self): + plan = copy.deepcopy(self.plan) + plan['config']['data'] = str(self.root) + with patch.object(prepare_data, 'validate_plan', return_value=(plan, self.journal)): + with self.assertRaisesRegex(ValueError, 'installed controller path'): self.run_capture() + self.assertEqual(self.calls, []) + + def test_failed_stop_or_capture_always_restores_without_import(self): + for failure in ('stop', 'capture', 'interrupt'): + with self.subTest(failure=failure): + self.output = self.root / ('attempt-' + failure) + self.stop_failure = failure == 'stop' + error = KeyboardInterrupt() if failure == 'interrupt' else OSError('capture failed') + with patch.object(prepare_data, 'run_tool', side_effect=error) as runner: + with self.assertRaises((RuntimeError, OSError, KeyboardInterrupt)): self.run_capture() + self.assertLessEqual(runner.call_count, 1) + self.assert_restored() + self.assertEqual(self.record()['phase'], 'free_sleep_restored') + self.assertFalse(self.record()['captureComplete']) + self.assertFalse((self.output / 'candidate').exists()) + + def test_configuration_change_after_stop_is_not_captured_or_overwritten(self): + original = self.control + changed = self.legacy / 'analytics/journal.json' + def change(*arguments): + result = original(*arguments) + if arguments[0] == 'stop': + changed.parent.mkdir(); changed.write_text('[]') + return result + with patch.object(handoff, 'systemctl', side_effect=change), patch.object(prepare_data, 'run_tool') as runner: + with self.assertRaisesRegex(ValueError, 'changed before capture'): self.run_capture() + runner.assert_not_called() + self.assertEqual(changed.read_text(), '[]') + self.assert_restored() + + def test_restart_during_capture_prevents_claim_of_quiesced_backup(self): + run_tool = prepare_data.run_tool + def restarted(*arguments): + result = run_tool(*arguments) + self.states[service_plan.LEGACY_UNITS[0]].update(ActiveState='active', MainPID='201') + return result + with patch.object(prepare_data, 'run_tool', side_effect=restarted): + with self.assertRaisesRegex(RuntimeError, 'restarted during capture'): self.run_capture() + self.assertFalse(self.record()['captureComplete']) + self.assert_restored() + + def test_source_change_during_capture_is_reported_and_preserved(self): + run_tool = prepare_data.run_tool + def changed(*arguments): + result = run_tool(*arguments) + (self.legacy / 'lowdb/schedulesDB.json').write_text('{"new":"owner edit"}') + return result + with patch.object(prepare_data, 'run_tool', side_effect=changed): + with self.assertRaisesRegex(ValueError, 'differs from the quiesced'): self.run_capture() + self.assertEqual((self.legacy / 'lowdb/schedulesDB.json').read_text(), '{"new":"owner edit"}') + self.assertFalse(self.record()['captureComplete']) + self.assert_restored() + + def test_import_failure_happens_only_after_original_services_recover(self): + run_tool = prepare_data.run_tool + def failed(arguments, log, timeout): + if log.name == 'import.log': + self.assert_restored() + raise OSError('import failed') + return run_tool(arguments, log, timeout) + with patch.object(prepare_data, 'run_tool', side_effect=failed): + with self.assertRaisesRegex(OSError, 'import failed'): self.run_capture() + self.assertEqual(self.record()['phase'], 'import_failed_free_sleep_restored') + self.assertTrue(self.record()['captureComplete']) + self.assert_restored() + + def test_restore_failure_keeps_recoverable_journal_without_repeating_capture(self): + self.start_failure = True + with self.assertRaisesRegex(RuntimeError, 'did not recover'): self.run_capture() + self.assertEqual(self.record()['phase'], 'recovery_required') + self.start_failure = False + with patch.object(prepare_data, 'run_tool') as runner: + result = prepare_data.recover(self.directory, self.output) + runner.assert_not_called() + self.assertTrue(result['restoredIdleVerified']) + self.assertFalse((self.output / 'candidate').exists()) + self.assert_restored() + + def test_full_disk_does_not_prevent_restart_after_capture_error(self): + write = handoff.private_json + def failed_write(path, value): + if value['phase'] == 'free_sleep_restored': raise OSError('disk full') + return write(path, value) + with patch.object(prepare_data, 'run_tool', side_effect=OSError('disk full')): + with patch.object(handoff, 'private_json', side_effect=failed_write): + with self.assertRaises(OSError): self.run_capture() + self.assert_restored() + + def test_changed_plan_and_completed_import_cannot_be_recovered_as_interrupted(self): + self.run_capture() + with self.assertRaisesRegex(ValueError, 'No interrupted'): prepare_data.recover(self.directory, self.output) + record = self.record(); record['planHash'] = '0' * 64 + (self.output / 'preparation.json').write_text(json.dumps(record)) + with self.assertRaisesRegex(ValueError, 'another plan'): prepare_data.recover(self.directory, self.output) + + def test_recovery_will_not_start_an_unrelated_replacement_service(self): + self.start_failure = True + with self.assertRaises(RuntimeError): self.run_capture() + (self.unit_directory / service_plan.LEGACY_UNITS[0]).write_text('unrelated replacement') + calls = list(self.calls) + with self.assertRaisesRegex(ValueError, 'configuration changed'): + prepare_data.recover(self.directory, self.output) + self.assertEqual(calls, self.calls) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_prepare_offline.py b/scripts/deployment/test_prepare_offline.py new file mode 100644 index 000000000..c34443f77 --- /dev/null +++ b/scripts/deployment/test_prepare_offline.py @@ -0,0 +1,120 @@ +import importlib.util +import io +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('prepare_offline', Path(__file__).with_name('prepare_offline.py')) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +class OfflineBundleTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.addCleanup(self.temporary.cleanup) + + def archive(self, members): + archive = self.root / 'fixture.tar.gz' + with tarfile.open(archive, 'w:gz') as output: + for name, content, link in members: + member = tarfile.TarInfo(name) + if link is not None: + member.type = tarfile.SYMTYPE + member.linkname = link + output.addfile(member) + else: + member.size = len(content) + output.addfile(member, io.BytesIO(content)) + return archive + + def test_extract_preserves_safe_relative_links(self): + archive = self.archive([('build/bin/node', b'binary', None), ('build/bin/link', b'', 'node')]) + destination = self.root / 'expanded' + destination.mkdir() + release.extract_verified(archive, destination, 'build') + self.assertEqual((destination / 'build/bin/link').read_bytes(), b'binary') + + def test_rejects_traversal_escaping_links_duplicates_and_link_children(self): + cases = [ + [('build/../outside', b'data', None)], + [('/build/file', b'data', None)], + [('build/link', b'', '../../outside')], + [('build/file', b'one', None), ('build/file', b'two', None)], + [('build/link', b'', 'folder'), ('build/link/file', b'data', None)], + ] + for members in cases: + with self.subTest(members=members): + destination = self.root / 'expanded' + destination.mkdir(exist_ok=True) + with self.assertRaises(ValueError): + release.extract_verified(self.archive(members), destination, 'build') + self.assertEqual(list(destination.iterdir()), []) + + def test_build_copy_excludes_databases_credentials_and_host_native_modules(self): + source = self.root / 'source' + source.mkdir() + for name in ['server.js', '.env', '.env.local', 'sleepypod.dev.db', 'biometrics.db-wal', 'owner.key']: + (source / name).write_text('fixture') + (source / 'node_modules').mkdir() + (source / 'node_modules/native.node').write_bytes(b'MachO') + destination = self.root / 'copy' + destination.mkdir() + release.copy_build(source, destination) + self.assertEqual([entry.name for entry in destination.iterdir()], ['server.js']) + + def test_build_copy_refuses_unexpected_symlinks(self): + source = self.root / 'source' + source.mkdir() + (source / 'secret').symlink_to('/etc/passwd') + destination = self.root / 'copy' + destination.mkdir() + with self.assertRaises(ValueError): + release.copy_build(source, destination) + + def test_inventory_rejects_host_native_code_and_external_links(self): + native = self.root / 'library.so.42' + native.write_bytes(b'MachO') + with self.assertRaises(ValueError): + release.inventory(self.root) + native.unlink() + (self.root / 'outside').symlink_to('/etc/passwd') + with self.assertRaises(ValueError): + release.inventory(self.root) + + def test_inventory_accepts_arm64_elf_and_internal_links(self): + header = bytearray(20) + header[:6] = b'\x7fELF\x02\x01' + header[18:20] = (183).to_bytes(2, 'little') + native = self.root / 'library.node' + native.write_bytes(header) + (self.root / 'link').symlink_to('library.node') + records = release.inventory(self.root) + self.assertEqual(records[0]['sha256'], release.checksum(native)) + self.assertEqual(records[1]['link'], 'library.node') + + def test_cached_checksum_mismatch_does_not_fetch_or_rewrite(self): + artifact = self.root / 'archive.tar.gz' + artifact.write_bytes(b'original') + with patch('urllib.request.urlopen') as fetch: + with self.assertRaises(ValueError): + release.download('https://example.invalid/archive', artifact, '0' * 64) + fetch.assert_not_called() + self.assertEqual(artifact.read_bytes(), b'original') + + def test_existing_output_is_preserved_before_any_command(self): + existing = self.root / 'keep' + existing.mkdir() + (existing / 'owner-data').write_text('preserved') + with patch('subprocess.check_output') as command: + with self.assertRaises(ValueError): + release.prepare(self.root / 'repo', existing) + command.assert_not_called() + self.assertEqual((existing / 'owner-data').read_text(), 'preserved') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_probe_modules.py b/scripts/deployment/test_probe_modules.py new file mode 100644 index 000000000..40e811935 --- /dev/null +++ b/scripts/deployment/test_probe_modules.py @@ -0,0 +1,58 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('probe_modules', Path(__file__).with_name('probe_modules.py')) +probe = importlib.util.module_from_spec(spec) +spec.loader.exec_module(probe) + + +class ModuleProbeTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.root = Path(self.directory.name) + for name in probe.MODULE_IMPORTS: + folder = self.root / name + folder.mkdir() + (folder / 'pyproject.toml').write_text('project') + (folder / 'uv.lock').write_text('locked dependencies') + + def tearDown(self): + self.directory.cleanup() + + def test_uses_venv_isolation_real_imports_and_lock_identity(self): + response = subprocess.CompletedProcess([], 0, json.dumps({'imports': 'ok', 'packages': {'heartpy': '1.2.7'}}), '') + with patch.object(probe.subprocess, 'run', return_value=response) as run: + result = probe.probe_modules(self.root, ['piezo-processor']) + self.assertTrue(result['ok']) + self.assertFalse(result['servicesStarted']) + self.assertEqual(run.call_args.args[0][:3], [str(self.root / 'piezo-processor/.venv/bin/python'), '-I', '-c']) + self.assertIn('heartpy', run.call_args.args[0][4:]) + self.assertEqual(run.call_args.kwargs['timeout'], 45) + self.assertEqual(len(result['modules']['piezo-processor']['files']['uv.lock']), 64) + + def test_missing_dependency_is_failure_and_does_not_hide_other_modules(self): + responses = [subprocess.CompletedProcess([], 1, '', 'No module named pkg_resources'), + subprocess.CompletedProcess([], 0, '{"imports":"ok","packages":{}}', '')] + with patch.object(probe.subprocess, 'run', side_effect=responses): + result = probe.probe_modules(self.root, ['piezo-processor', 'cover-buttons']) + self.assertFalse(result['ok']) + self.assertIn('pkg_resources', result['modules']['piezo-processor']['error']) + self.assertTrue(result['modules']['cover-buttons']['ok']) + + def test_malformed_response_timeout_and_absent_interpreter_fail_closed(self): + failures = [subprocess.CompletedProcess([], 0, 'not json', ''), + subprocess.TimeoutExpired('python', 45), FileNotFoundError('missing interpreter')] + for failure in failures: + with self.subTest(failure=failure): + kwargs = {'side_effect': failure} if isinstance(failure, Exception) else {'return_value': failure} + with patch.object(probe.subprocess, 'run', **kwargs): + self.assertFalse(probe.probe_modules(self.root, ['cover-buttons'])['ok']) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_recovery_privacy.py b/scripts/deployment/test_recovery_privacy.py new file mode 100644 index 000000000..c7491356b --- /dev/null +++ b/scripts/deployment/test_recovery_privacy.py @@ -0,0 +1,87 @@ +from pathlib import Path +import sqlite3 +import tempfile +import unittest + +from recovery_privacy import require_legacy_recovery_data, REQUIRED_TABLES + + +class RecoveryPrivacyTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.directory = Path(temporary.name).resolve() + self.path = self.directory/'sleepypod.db' + self.database = sqlite3.connect(self.path) + self.addCleanup(self.database.close) + for name in REQUIRED_TABLES: + self.database.execute('CREATE TABLE ' + name + ' (id INTEGER)') + self.database.commit() + + def test_empty_migrated_schema_and_legacy_browser_session_allow_recovery(self): + self.database.executescript('CREATE TABLE households (id TEXT); CREATE TABLE household_accounts (id TEXT); ' + 'CREATE TABLE household_privacy_boundary (id INTEGER); CREATE TABLE household_browser_scopes (scope TEXT); ' + "INSERT INTO household_browser_scopes VALUES ('owner-cookie-fixture');") + original = self.path.read_bytes() + require_legacy_recovery_data(self.directory) + self.assertEqual(self.path.read_bytes(), original) + + def test_all_personal_state_including_unassigned_or_revoked_preparation_blocks(self): + for table in ('households', 'household_accounts', 'household_personal_settings', 'household_devices', + 'household_attribution_epochs', 'household_person_records', 'household_privacy_boundary', + 'household_arrangement_reviews', 'household_future_feature', 'household_"quoted'): + quoted = '"' + table.replace('"', '""') + '"' + self.database.execute('CREATE TABLE ' + quoted + ' (value TEXT)') + self.database.execute('INSERT INTO ' + quoted + ' VALUES (?)', ('private-fixture-never-log',)) + self.database.commit() + with self.subTest(table=table), self.assertRaisesRegex(ValueError, 'qualified recovery') as failure: + require_legacy_recovery_data(self.directory) + self.assertNotIn('private-fixture', str(failure.exception)) + self.database.execute('DROP TABLE ' + quoted) + self.database.commit() + + def test_reads_committed_wal_without_checkpointing_or_waiting_for_process_exit(self): + self.database.execute('PRAGMA journal_mode=WAL') + self.database.execute('PRAGMA wal_autocheckpoint=0') + self.database.executescript("CREATE TABLE household_accounts (id TEXT); INSERT INTO household_accounts VALUES ('private');") + original = self.path.read_bytes() + with self.assertRaisesRegex(ValueError, 'qualified recovery'): require_legacy_recovery_data(self.directory) + self.assertEqual(self.path.read_bytes(), original) + + def test_missing_corrupt_or_locked_database_never_counts_as_no_accounts(self): + for name in ('missing', 'corrupt'): + directory = self.directory/name + directory.mkdir() + if name == 'corrupt': (directory/'sleepypod.db').write_bytes(b'not sqlite') + with self.assertRaisesRegex(ValueError, 'Cannot verify'): require_legacy_recovery_data(directory) + self.database.execute('BEGIN EXCLUSIVE') + with self.assertRaisesRegex(ValueError, 'Cannot verify'): require_legacy_recovery_data(self.directory) + self.database.rollback() + + def test_an_empty_or_partial_database_cannot_authorize_recovery(self): + empty = self.directory/'empty' + empty.mkdir() + with sqlite3.connect(empty/'sleepypod.db'): pass + with self.assertRaisesRegex(ValueError, 'incomplete or corrupt'): + require_legacy_recovery_data(empty) + self.database.execute('DROP TABLE device_settings') + self.database.commit() + with self.assertRaisesRegex(ValueError, 'incomplete or corrupt'): + require_legacy_recovery_data(self.directory) + + def test_symlinked_database_directory_and_sidecars_are_refused(self): + alias = self.directory/'alias' + alias.symlink_to(self.directory, target_is_directory=True) + with self.assertRaises(ValueError): require_legacy_recovery_data(alias) + for name in ('-wal', '-shm', '-journal'): + link = self.path.with_name(self.path.name + name) + link.symlink_to(self.directory/'missing-private-file') + with self.assertRaises(ValueError): require_legacy_recovery_data(self.directory) + link.unlink() + self.database.close() + self.path.unlink() + self.path.symlink_to(self.directory/'missing-private-file') + with self.assertRaises(ValueError): require_legacy_recovery_data(self.directory) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/deployment/test_replace_plan.py b/scripts/deployment/test_replace_plan.py new file mode 100644 index 000000000..9b75a53d0 --- /dev/null +++ b/scripts/deployment/test_replace_plan.py @@ -0,0 +1,104 @@ +import json +import unittest +from unittest.mock import patch + +import handoff +import replace_plan +import service_plan +import test_handoff + +CONFIG = test_handoff.CONFIG + + +class ReplacementTests(unittest.TestCase): + setUp = test_handoff.HandoffTests.setUp + control = test_handoff.HandoffTests.control + + def prepare_replacement(self): + handoff.install(self.directory) + self.replacement = self.root / 'replacement' + self.new_plan = service_plan.prepare({**CONFIG, 'release': '/persistent/sleepypod-unified/releases/def5678'}, self.replacement) + self.calls.clear() + + def test_replaces_only_reviewed_stopped_units_without_starting_or_enabling(self): + self.prepare_replacement() + result = replace_plan.replace_plan(self.directory, self.replacement) + self.assertEqual(result['phase'], 'installed_not_started') + self.assertEqual(result['unitHashes'], self.new_plan['units']) + self.assertEqual(self.calls, [('daemon-reload',)]) + self.assertFalse(result['bootSelectionChanged']) + handoff.validate_installed(self.new_plan) + self.assertEqual(json.loads((self.directory / 'handoff.json').read_text())['unitHashes'], self.plan['units']) + + def test_rejects_running_or_enabled_core_and_unavailable_legacy(self): + self.prepare_replacement() + for name, field, value in [('sleepypod.service', 'ActiveState', 'active'), + ('sleepypod-calibrator.service', 'UnitFileState', 'enabled'), + ('free-sleep.service', 'MainPID', '0')]: + original = self.states[name][field] + self.states[name][field] = value + with self.assertRaises(ValueError): + replace_plan.replace_plan(self.directory, self.replacement) + self.states[name][field] = original + self.assertEqual(self.calls, []) + self.assertFalse((self.replacement / 'handoff.json').exists()) + + def test_rejects_data_changes_and_unrelated_unit_changes(self): + self.prepare_replacement() + changed = self.root / 'changed-data' + service_plan.prepare({**CONFIG, 'data': '/persistent/sleepypod-unified/different-data'}, changed) + with self.assertRaisesRegex(ValueError, 'cannot change data'): + replace_plan.replace_plan(self.directory, changed) + (self.unit_directory / 'sleepypod.service').write_text('unrelated') + with self.assertRaisesRegex(ValueError, 'changed'): + replace_plan.replace_plan(self.directory, self.replacement) + self.assertEqual(self.calls, []) + + def test_partial_failure_restores_original_bytes_and_keeps_legacy_running(self): + self.prepare_replacement() + original = replace_plan.replace_unit + failed = False + + def fail_once(path, content): + nonlocal failed + if path.name == 'sleepypod-piezo-processor.service' and not failed: + failed = True + raise OSError('simulated disk failure') + original(path, content) + + with patch.object(replace_plan, 'replace_unit', side_effect=fail_once): + with self.assertRaisesRegex(OSError, 'simulated'): + replace_plan.replace_plan(self.directory, self.replacement) + handoff.validate_installed(self.plan) + self.assertEqual(self.calls, [('daemon-reload',)]) + self.assertEqual(json.loads((self.replacement / 'handoff.json').read_text())['phase'], 'replacement_failed_previous_restored') + + def test_preserves_a_unit_modified_during_replacement(self): + self.prepare_replacement() + original = replace_plan.replace_unit + + def replace_then_change(path, content): + original(path, content) + path.write_text('external edit') + raise OSError('interrupted') + + with patch.object(replace_plan, 'replace_unit', side_effect=replace_then_change): + with self.assertRaisesRegex(RuntimeError, 'needs review'): + replace_plan.replace_plan(self.directory, self.replacement) + self.assertEqual((self.unit_directory / 'sleepypod.service').read_text(), 'external edit') + self.assertEqual(self.calls, []) + + def test_bound_maintenance_helper_must_be_removed_before_new_controller_plan(self): + self.prepare_replacement() + target = self.root/'with-maintenance' + service_plan.prepare({**CONFIG, 'release': '/persistent/sleepypod-unified/releases/def5678', + 'maintenanceSocket': service_plan.MAINTENANCE_SOCKET}, target) + names = (replace_plan.MAINTENANCE_SOCKET, replace_plan.MAINTENANCE_SERVICE) + for name in names: self.states[name] = {'LoadState': 'not-found'} + self.states[names[0]]['LoadState'] = 'loaded' + with self.assertRaisesRegex(ValueError, 'bound maintenance'): + replace_plan.replace_plan(self.directory, target) + self.assertFalse((target/'handoff.json').exists()) + self.assertEqual(self.calls, []) + self.states[names[0]]['LoadState'] = 'not-found' + self.assertEqual(replace_plan.replace_plan(self.directory, target)['phase'], 'installed_not_started') diff --git a/scripts/deployment/test_stage_sensors.py b/scripts/deployment/test_stage_sensors.py new file mode 100644 index 000000000..5171a59c9 --- /dev/null +++ b/scripts/deployment/test_stage_sensors.py @@ -0,0 +1,140 @@ +import hashlib +import io +import json +import os +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +import maintenance_install +import service_plan +import stage_sensors + + +class SensorStagingTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.old = self.root/'sensors/old/modules' + self.new = self.root/'sensors/new/modules' + self.release = self.root/'release' + for path in (self.old, self.new.parent, self.release): + path.mkdir(parents=True) + self.files = {'common/calibration_identity.py': b'new shared source'} + for name in service_plan.MODULES: + folder = self.old/name + (folder/'.venv/bin').mkdir(parents=True) + (folder/'.venv/bin/python').write_bytes(b'interpreter fixture, never executed') + (folder/'main.py').write_bytes(b'old source') + for filename in ('pyproject.toml', 'uv.lock'): + (folder/filename).write_bytes(b'locked fixture') + self.files[name+'/'+filename] = b'locked fixture' + self.files[name+'/main.py'] = b'new source' + self.write_archive() + for module, name, value in ( + (maintenance_install, 'ROOT', self.root), + (maintenance_install, 'OWNER_UID', os.getuid()), + (maintenance_install, 'secure_path', lambda *_args, **_kwargs: None), + (maintenance_install, 'verify_release', lambda *_: 'a'*40), + ): + context = patch.object(module, name, value) + context.start() + self.addCleanup(context.stop) + + def write_archive(self, extra=None): + archive = self.release/'source.tar.gz' + with tarfile.open(archive, 'w:gz') as target: + for name, contents in self.files.items(): + member = tarfile.TarInfo('modules/'+name) + member.size = len(contents) + target.addfile(member, io.BytesIO(contents)) + if extra: + target.addfile(extra) + (self.release/'bundle-manifest.json').write_text(json.dumps({ + 'sourceSha256': hashlib.sha256(archive.read_bytes()).hexdigest(), + })) + + def prepare(self): + return stage_sensors.prepare(self.old, self.new, self.release) + + def test_staging_preserves_old_source_and_reuses_exact_environments(self): + result = self.prepare() + self.assertFalse(result['servicesStarted']) + self.assertEqual(result['verifiedSourceFiles'], len(self.files)) + for name in service_plan.MODULES: + self.assertEqual((self.old/name/'main.py').read_bytes(), b'old source') + self.assertEqual((self.new/name/'main.py').read_bytes(), b'new source') + self.assertEqual(os.readlink(self.new/name/'.venv'), str(self.old/name/'.venv')) + self.assertEqual(stage_sensors.verify(self.old, self.new, self.release), result) + + def test_chained_source_releases_keep_the_original_runtime(self): + self.prepare() + candidate = self.root/'sensors/third/modules' + candidate.parent.mkdir() + stage_sensors.prepare(self.new, candidate, self.release) + self.assertEqual(os.readlink(candidate/'sleep-detector/.venv'), str(self.old/'sleep-detector/.venv')) + + def test_dependency_change_rejects_before_creating_destination(self): + (self.old/'sleep-detector/uv.lock').write_bytes(b'other dependencies') + with self.assertRaisesRegex(ValueError, 'dependencies changed'): + self.prepare() + self.assertFalse(self.new.exists()) + + def test_changed_source_or_extra_entries_fail_verification(self): + self.prepare() + path = self.new/'sleep-detector/main.py' + path.write_bytes(b'tampered') + with self.assertRaisesRegex(ValueError, 'differs from release'): + stage_sensors.verify(self.old, self.new, self.release) + path.write_bytes(b'new source') + (self.new/'injected.py').write_bytes(b'extra') + with self.assertRaisesRegex(ValueError, 'membership changed'): + stage_sensors.verify(self.old, self.new, self.release) + + def test_runtime_link_cannot_be_redirected(self): + self.prepare() + link = self.new/'sleep-detector/.venv' + link.unlink() + link.symlink_to(self.old/'calibrator/.venv') + with self.assertRaisesRegex(ValueError, 'link changed'): + stage_sensors.verify(self.old, self.new, self.release) + + def test_archive_checksum_is_required(self): + (self.release/'source.tar.gz').write_bytes(b'changed') + with self.assertRaisesRegex(ValueError, 'checksum mismatch'): + self.prepare() + self.assertFalse(self.new.exists()) + + def test_archive_traversal_and_links_are_refused(self): + for name, kind in [('modules/../escape', tarfile.REGTYPE), ('modules/common/link', tarfile.SYMTYPE)]: + with self.subTest(name=name): + member = tarfile.TarInfo(name) + member.type = kind + member.linkname = '/etc/passwd' if kind == tarfile.SYMTYPE else '' + self.write_archive(member) + with self.assertRaises(ValueError): + self.prepare() + self.assertFalse(self.new.exists()) + + def test_no_overwrite_and_no_unmanaged_destination(self): + self.prepare() + with self.assertRaisesRegex(ValueError, 'must be new'): + self.prepare() + with self.assertRaisesRegex(ValueError, 'overwrite active'): + stage_sensors.prepare(self.old, self.old, self.release) + with self.assertRaisesRegex(ValueError, 'managed sensor'): + stage_sensors.prepare(self.old, self.root/'arbitrary/modules', self.release) + + def test_failed_final_verification_removes_only_candidate_source(self): + with patch.object(stage_sensors, 'verify', side_effect=ValueError('probe failed')): + with self.assertRaisesRegex(ValueError, 'probe failed'): + self.prepare() + self.assertFalse(self.new.exists()) + self.assertTrue((self.old/'sleep-detector/.venv/bin/python').is_file()) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/deployment/test_upgrade.py b/scripts/deployment/test_upgrade.py new file mode 100644 index 000000000..99c81c1e0 --- /dev/null +++ b/scripts/deployment/test_upgrade.py @@ -0,0 +1,309 @@ +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +import handoff +import maintenance_install +import maintenance_plan +import service_plan +import upgrade + + +class UpgradeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.data = self.root/'data' + self.data.mkdir() + with sqlite3.connect(self.data/'sleepypod.db') as database: + database.execute('CREATE TABLE alarm_schedules (time TEXT, duration INTEGER)') + database.execute("INSERT INTO alarm_schedules VALUES ('07:00', 20)") + for name in ('device_settings', 'side_settings', 'power_schedules'): + database.execute('CREATE TABLE ' + name + ' (id INTEGER)') + (self.data/'homekit').mkdir() + (self.data/'homekit'/'pairing.json').write_text('private pairing fixture') + self.units = self.root/'units' + self.units.mkdir() + self.old = self.root/'old' + self.new = self.root/'new' + self.old_helpers = self.root/'old-helpers' + self.new_helpers = self.root/'new-helpers' + self.operation = self.root/'operation' + self.operation.mkdir() + for directory in (self.old, self.new, self.old_helpers, self.new_helpers): + directory.mkdir() + self.states = {} + self.calls = [] + self.failed_start = False + plans = [] + helpers = [] + for index, directory in enumerate((self.old, self.new)): + config = {'release': str(self.root/('release-' + str(index))), + 'data': str(self.data), 'environment': str(self.root/'environment')} + units = {name: 'core version ' + str(index) + name for name in service_plan.CORE_UNITS} + manifest = {'config': config, 'units': {name: hashlib.sha256(value.encode()).hexdigest() for name, value in units.items()}} + for name, value in units.items(): (directory/name).write_text(value) + plans.append(manifest) + for index, directory in enumerate((self.old_helpers, self.new_helpers)): + units = {name: 'helper version ' + str(index) + name for name in upgrade.HELPERS} + manifest = {'controllerPlan': str((self.old, self.new)[index]), + 'release': plans[index]['config']['release'], + 'units': {name: hashlib.sha256(value.encode()).hexdigest() for name, value in units.items()}} + for name, value in units.items(): (directory/name).write_text(value) + helpers.append(manifest) + self.plans, self.helpers = plans, helpers + for name in (*service_plan.CORE_UNITS, *upgrade.HELPERS): + parent = self.old if name in service_plan.CORE_UNITS else self.old_helpers + (self.units/name).write_bytes((parent/name).read_bytes()) + self.states[name] = {'ActiveState': 'active', 'MainPID': '123', 'DropInPaths': '', + 'FragmentPath': str(self.units/name), + 'UnitFileState': 'enabled' if name in (service_plan.CORE_UNIT, maintenance_plan.SOCKET) else 'disabled'} + self.saved = {'legacy': {name: {} for name in service_plan.LEGACY_UNITS}} + self.active = {'phase': 'active', 'activeOwner': 'sleepypod-core'} + (self.old/'activation.json').write_text(json.dumps(self.active)) + replacements = [ + (service_plan, 'read_plan', lambda path: plans[0 if path == self.old else 1]), + (maintenance_plan, 'read_plan', lambda path: helpers[0 if path == self.old_helpers else 1]), + (maintenance_install, 'secure_path', lambda *_args, **_kwargs: None), + (maintenance_install, 'owned_bytes', lambda path, **_kwargs: path.read_bytes()), + (maintenance_install, 'verify_release', lambda path: ('a' if path.name.endswith('0') else 'b') * 40), + (handoff, 'UNIT_DIRECTORY', self.units), + (handoff, 'unit_state', lambda name: self.states[name]), + (handoff, 'systemctl', self.control), + (handoff, 'read_owner_key', lambda _: 'secret-never-journaled'), + (handoff, 'local_json', lambda *_: {'isPriming': False, 'leftSide': {'heatingDuration': 0}, 'rightSide': {'heatingDuration': 0}}), + (handoff, 'core_observation', lambda *_: {'controller': {'revision': 'a'*40}}), + (upgrade.activate, 'read_handoff', lambda *_: self.saved), + (upgrade.activate, 'read_activation', lambda *_: self.active), + (upgrade.activate, 'validate_legacy_definitions', lambda *_: None), + (upgrade.activate, 'assert_ownership', lambda **_: None), + (upgrade.activate, 'assert_boot_selection', lambda **_: None), + (upgrade, 'start_and_verify', self.start), + ] + for module, name, value in replacements: + context = patch.object(module, name, value) + context.start() + self.addCleanup(context.stop) + + def control(self, *arguments): + self.calls.append(arguments) + if arguments[0] == 'stop': + for name in arguments[1:]: + self.states[name].update(ActiveState='inactive', MainPID='0') + if name == maintenance_plan.SOCKET: + self.states[name].pop('MainPID', None) + + def start(self, plan, _token, _revision, _seconds): + self.calls.append(('start', service_plan.CORE_UNIT)) + if plan == self.plans[1] and self.failed_start: + raise RuntimeError('candidate failed') + return {'device': {'freshness': 'fresh'}} + + def apply(self): + return upgrade.upgrade(self.operation, self.old, self.new, self.old_helpers, self.new_helpers) + + def assert_old_units(self): + for name in service_plan.CORE_UNITS: + self.assertEqual((self.units/name).read_bytes(), (self.old/name).read_bytes()) + + def test_success_preserves_routines_pairings_and_never_starts_legacy(self): + before = upgrade.inventory(self.data) + record = self.apply() + self.assertEqual(record['phase'], 'active') + self.assertEqual(upgrade.inventory(self.data), before) + self.assertEqual(upgrade.inventory(self.operation/'restore-rehearsal'), before) + self.assertFalse(record['physicalOperationVerified']) + self.assertNotIn('secret-never-journaled', (self.operation/'upgrade.json').read_text()) + self.assertTrue(all(not set(call) & set(service_plan.LEGACY_UNITS) for call in self.calls)) + self.assertEqual(json.loads((self.old/'activation.json').read_text())['phase'], 'superseded_by_upgrade') + + def test_failed_candidate_with_readable_schema_restores_database_and_units(self): + original = self.start + def start(plan, *arguments): + if plan == self.plans[1]: + with sqlite3.connect(self.data/'sleepypod.db') as database: + database.execute('ALTER TABLE alarm_schedules ADD COLUMN candidate_only TEXT') + database.execute("UPDATE alarm_schedules SET time='09:00'") + (self.data/'homekit'/'pairing.json').write_text('changed candidate state') + raise RuntimeError('failed after migration') + return original(plan, *arguments) + before = upgrade.inventory(self.data) + with patch.object(upgrade, 'start_and_verify', start), self.assertRaisesRegex(RuntimeError, 'after migration'): + self.apply() + self.assertEqual(upgrade.inventory(self.data), before) + self.assert_old_units() + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'rolled_back') + self.assertEqual(len(list(self.operation.glob('replaced-data-*'))), 1) + + def test_incomplete_candidate_schema_requires_recovery_without_erasing_current_data(self): + def start(plan, *_): + if plan == self.plans[1]: + with sqlite3.connect(self.data/'sleepypod.db') as database: + database.execute('DROP TABLE alarm_schedules') + raise RuntimeError('candidate left incomplete schema') + self.fail('Must not restart an old runtime against an unknown household boundary') + with patch.object(upgrade, 'start_and_verify', start), self.assertRaisesRegex(ValueError, 'incomplete or corrupt'): + self.apply() + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'recovery_required') + self.assertFalse(list(self.operation.glob('replaced-data-*'))) + self.assertTrue((self.operation/'data-backup'/'sleepypod.db').is_file()) + + def test_changed_routines_reject_candidate(self): + def start(plan, *_): + if plan == self.plans[1]: + with sqlite3.connect(self.data/'sleepypod.db') as database: + database.execute("UPDATE alarm_schedules SET time='08:00'") + return {} + with patch.object(upgrade, 'start_and_verify', start), self.assertRaisesRegex(RuntimeError, 'saved routine'): + self.apply() + self.assert_old_units() + + def test_live_heat_rejects_before_stopping_anything(self): + with patch.object(handoff, 'require_thermal_idle', side_effect=RuntimeError('not idle')): + with self.assertRaisesRegex(RuntimeError, 'not idle'): self.apply() + self.assertEqual(self.calls, []) + + def test_modified_unit_rejects_before_stopping_anything(self): + (self.units/service_plan.CORE_UNIT).write_text('foreign service') + with self.assertRaisesRegex(ValueError, 'Unrecognized'): self.apply() + self.assertEqual(self.calls, []) + + def test_changed_data_path_rejected(self): + self.plans[1]['config']['data'] = str(self.root/'other') + with self.assertRaisesRegex(ValueError, 'only the release'): self.apply() + self.assertEqual(self.calls, []) + + def test_sensor_source_update_is_verified_and_rolls_back_with_units(self): + self.plans[0]['config']['modules'] = str(self.root/'old-sensors') + self.plans[1]['config']['modules'] = str(self.root/'new-sensors') + self.failed_start = True + with patch.object(upgrade.stage_sensors, 'verify') as verify: + with self.assertRaisesRegex(RuntimeError, 'candidate failed'): + self.apply() + verify.assert_called_once_with(self.root/'old-sensors', self.root/'new-sensors', self.root/'release-1') + self.assert_old_units() + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'rolled_back') + + def test_unverified_sensor_source_rejects_before_stopping_anything(self): + self.plans[0]['config']['modules'] = str(self.root/'old-sensors') + self.plans[1]['config']['modules'] = str(self.root/'new-sensors') + with patch.object(upgrade.stage_sensors, 'verify', side_effect=ValueError('sensor mismatch')): + with self.assertRaisesRegex(ValueError, 'sensor mismatch'): + self.apply() + self.assertEqual(self.calls, []) + + def test_existing_candidate_record_rejected(self): + (self.new/'activation.json').write_text('{}') + with self.assertRaisesRegex(ValueError, 'installation record'): self.apply() + self.assertEqual(self.calls, []) + + def test_backup_failure_restarts_previous_release_without_replacing_data(self): + with patch.object(upgrade, 'copy_data', side_effect=OSError('disk failure')): + with self.assertRaisesRegex(OSError, 'disk failure'): self.apply() + self.assert_old_units() + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'rolled_back') + + def test_corrupt_rollback_copy_stops_and_requires_recovery(self): + def start(plan, *_): + if plan == self.plans[1]: + (self.operation/'data-backup'/'homekit'/'pairing.json').write_text('tampered') + raise RuntimeError('failed candidate') + return {} + with patch.object(upgrade, 'start_and_verify', start), self.assertRaisesRegex(ValueError, 'backup changed'): + self.apply() + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'recovery_required') + + def test_backup_rejects_symlinks(self): + (self.data/'link').symlink_to('/etc/passwd') + with self.assertRaisesRegex(ValueError, 'Unexpected backup entry'): + upgrade.copy_data(self.data, self.root/'unsafe-backup') + self.assertFalse((self.root/'unsafe-backup').exists()) + + def test_rollback_after_success_retains_previous_core_identity(self): + record = self.apply() + details = upgrade.context(self.old, self.new, self.old_helpers, self.new_helpers) + upgrade.restore(self.operation, details, record) + self.assert_old_units() + self.assertEqual(json.loads((self.old/'activation.json').read_text())['phase'], 'active') + self.assertEqual(json.loads((self.new/'activation.json').read_text())['phase'], 'rolled_back_to_previous_core') + + def add_personal_draft(self, directory=None): + with sqlite3.connect((directory or self.data)/'sleepypod.db') as database: + database.execute('CREATE TABLE household_personal_settings (draft TEXT)') + database.execute("INSERT INTO household_personal_settings VALUES ('private-fixture')") + + def test_personal_preparation_blocks_update_before_any_service_or_file_change(self): + self.add_personal_draft() + original = upgrade.inventory(self.data) + with self.assertRaisesRegex(ValueError, 'qualified recovery'): self.apply() + self.assertEqual(self.calls, []) + self.assertEqual(upgrade.inventory(self.data), original) + self.assertFalse((self.operation/'upgrade.json').exists()) + + def test_join_during_quiescence_is_preserved_and_old_runtime_is_not_restarted(self): + original_stop = handoff.stop_units + def stop(names): + original_stop(names) + self.add_personal_draft() + with patch.object(handoff, 'stop_units', side_effect=stop), self.assertRaisesRegex(ValueError, 'qualified recovery'): + self.apply() + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'recovery_required') + self.assertFalse((self.operation/'data-backup').exists()) + self.assertFalse(any(call[0] == 'start' for call in self.calls)) + self.assert_old_units() + with sqlite3.connect(self.data/'sleepypod.db') as database: + self.assertEqual(database.execute('SELECT draft FROM household_personal_settings').fetchone(), ('private-fixture',)) + + def test_candidate_personal_write_is_not_erased_by_automatic_rollback(self): + def start(plan, *_): + if plan == self.plans[1]: + self.add_personal_draft() + raise RuntimeError('candidate failed after personal write') + self.fail('Must not restart a legacy runtime with personal data') + with patch.object(upgrade, 'start_and_verify', start), self.assertRaisesRegex(ValueError, 'qualified recovery'): + self.apply() + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'recovery_required') + self.assertFalse(list(self.operation.glob('replaced-data-*'))) + with sqlite3.connect(self.data/'sleepypod.db') as database: + self.assertEqual(database.execute('SELECT count(*) FROM household_personal_settings').fetchone(), (1,)) + + def test_manual_rollback_preserves_later_personal_data_without_stopping_current_services(self): + record = self.apply() + self.add_personal_draft() + original = upgrade.inventory(self.data) + self.calls.clear() + with self.assertRaisesRegex(ValueError, 'qualified recovery'): + upgrade.restore(self.operation, upgrade.context(self.old, self.new, self.old_helpers, self.new_helpers), record) + self.assertEqual(self.calls, []) + self.assertEqual(upgrade.inventory(self.data), original) + self.assertFalse(list(self.operation.glob('replaced-data-*'))) + + def test_personal_backup_is_not_restored_over_an_empty_current_household(self): + record = self.apply() + self.add_personal_draft(self.operation/'data-backup') + # A valid checksum is not evidence that restoring private records is safe. + record['backupInventory'] = upgrade.inventory(self.operation/'data-backup') + self.calls.clear() + with self.assertRaisesRegex(ValueError, 'qualified recovery'): + upgrade.restore(self.operation, upgrade.context(self.old, self.new, self.old_helpers, self.new_helpers), record) + self.assertEqual(self.calls, []) + + def test_manual_rollback_rechecks_after_stopping_writers(self): + record = self.apply() + original_stop = handoff.stop_units + def stop(names): + original_stop(names) + self.add_personal_draft() + with patch.object(handoff, 'stop_units', side_effect=stop), self.assertRaisesRegex(ValueError, 'qualified recovery'): + upgrade.restore(self.operation, upgrade.context(self.old, self.new, self.old_helpers, self.new_helpers), record) + self.assertEqual(json.loads((self.operation/'upgrade.json').read_text())['phase'], 'recovery_required') + self.assertFalse(list(self.operation.glob('replaced-data-*'))) + + +if __name__ == '__main__': unittest.main() diff --git a/scripts/deployment/upgrade.py b/scripts/deployment/upgrade.py new file mode 100644 index 000000000..2f8abf6b1 --- /dev/null +++ b/scripts/deployment/upgrade.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Update an active isolated Core release, retaining an exact quiesced rollback copy.""" +import argparse +from contextlib import ExitStack +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import sqlite3 +import stat +import tempfile +import time + +import activate +import handoff +import maintenance_broker +import maintenance_install +import maintenance_plan +import service_plan +import stage_sensors +from recovery_privacy import require_legacy_recovery_data + +FORMAT = 'sleepypod-upgrade/1' +HELPERS = (maintenance_plan.SOCKET, maintenance_plan.SERVICE) +PRESERVED_TABLES = ('bedtime_routines', 'temperature_schedules', 'power_schedules', + 'alarm_schedules', 'base_schedules', 'side_settings', 'tap_gestures', + 'comfort_feedback', 'journal_entries', 'room_context_settings') + + +def inventory(directory): + """Hash regular files and metadata; refuse links and special files in a backup.""" + result = {} + for path in [directory, *sorted(directory.rglob('*'))]: + info = path.lstat() + if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)): + raise ValueError('Unexpected backup entry: ' + str(path)) + result[str(path.relative_to(directory))] = { + 'mode': stat.S_IMODE(info.st_mode), 'uid': info.st_uid, 'gid': info.st_gid, + 'sha256': hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None, + } + return result + + +def copy_data(source, destination): + """Preserve owner and modes as well as bytes, including HomeKit pairing state.""" + expected = inventory(source) + shutil.copytree(source, destination) + for relative, entry in expected.items(): + path = destination / relative + if os.geteuid() == 0: + os.chown(path, entry['uid'], entry['gid']) + path.chmod(entry['mode']) + if inventory(destination) != expected: + raise RuntimeError('Backup/restore byte and metadata verification failed') + return expected + + +def check_databases(directory): + for path in directory.glob('*.db'): + with sqlite3.connect(path.as_uri() + '?mode=ro', uri=True) as database: + if database.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: + raise ValueError('Database integrity check failed: ' + path.name) + + +def configuration(directory): + """Compare user routines and history independently of changing runtime rows.""" + result = {} + with sqlite3.connect((directory/'sleepypod.db').as_uri() + '?mode=ro', uri=True) as database: + tables = {row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")} + for name in PRESERVED_TABLES: + if name in tables: + rows = sorted(database.execute('SELECT * FROM "' + name + '"').fetchall(), key=repr) + result[name] = hashlib.sha256(repr(rows).encode()).hexdigest() + return result + + +def checkpoint(directory, record, phase): + record.update(phase=phase, updatedAt=time.time()) + handoff.private_json(directory/'upgrade.json', record) + + +def validate_units(old_units, new_units=None): + for name, digest in old_units.items(): + path = handoff.UNIT_DIRECTORY/name + contents = maintenance_install.owned_bytes(path) + permitted = {digest, (new_units or {}).get(name)} + state = handoff.unit_state(name) + if hashlib.sha256(contents).hexdigest() not in permitted or state.get('DropInPaths'): + raise ValueError('Unrecognized installed unit: ' + name) + if state.get('FragmentPath') != str(path): + raise ValueError('Unexpected service location: ' + name) + + +def context(old_directory, new_directory, old_helpers, new_helpers): + for directory in (old_directory, new_directory, old_helpers, new_helpers): + maintenance_install.secure_path(directory, private=True) + old = service_plan.read_plan(old_directory) + new = service_plan.read_plan(new_directory) + if old['config']['release'] == new['config']['release']: + raise ValueError('Select a different staged release') + if {key: value for key, value in old['config'].items() if key not in ('release', 'modules')} != { + key: value for key, value in new['config'].items() if key not in ('release', 'modules')}: + raise ValueError('This updater changes only the release and verified sensor source; data and credentials must remain fixed') + if old['config'].get('modules') != new['config'].get('modules'): + stage_sensors.verify(Path(old['config']['modules']), Path(new['config']['modules']), Path(new['config']['release'])) + helpers = [maintenance_plan.read_plan(directory) for directory in (old_helpers, new_helpers)] + for manifest, directory, plan in zip(helpers, (old_directory, new_directory), (old, new)): + if manifest['controllerPlan'] != str(directory) or manifest['release'] != plan['config']['release']: + raise ValueError('Maintenance plan and controller identity differ') + return {'old': old, 'new': new, 'oldHelpers': helpers[0], 'newHelpers': helpers[1], + 'oldUnits': {**old['units'], **helpers[0]['units']}, + 'newUnits': {**new['units'], **helpers[1]['units']}} + + +def install_units(directory, helpers): + for parent, names in ((directory, service_plan.CORE_UNITS), (helpers, HELPERS)): + for name in names: + contents = maintenance_install.owned_bytes(parent/name, private=True) + descriptor, filename = tempfile.mkstemp(prefix='.sleepypod-upgrade-', dir=handoff.UNIT_DIRECTORY) + temporary = Path(filename) + try: + with os.fdopen(descriptor, 'wb') as output: + output.write(contents) + output.flush() + os.fsync(output.fileno()) + temporary.chmod(0o644) + os.replace(temporary, handoff.UNIT_DIRECTORY/name) + finally: + temporary.unlink(missing_ok=True) + descriptor = os.open(handoff.UNIT_DIRECTORY, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + handoff.systemctl('daemon-reload') + + +def stop_helpers(): + """A socket has no process ID; verify services and sockets by their own rules.""" + handoff.systemctl('stop', *HELPERS) + if not all(maintenance_install.helper_stopped(name, handoff.unit_state(name)) for name in HELPERS): + raise RuntimeError('Maintenance services did not stop') + + +def start_and_verify(plan, token, revision, seconds): + handoff.systemctl('start', service_plan.CORE_UNIT) + observation = activate.settle(token, revision, seconds, handoff.core_observation, time.sleep, time.monotonic) + handoff.validate_installed(plan) + activate.assert_boot_selection(core=True) + handoff.systemctl('start', maintenance_plan.SOCKET) + if handoff.unit_state(maintenance_plan.SOCKET).get('ActiveState') != 'active': + raise RuntimeError('Maintenance socket did not recover') + maintenance_install.verify_socket() + return observation + + +def restore(directory, details, record, seconds=120): + """Restore the previous Core, never starting the retained Free Sleep controller.""" + validate_units(details['oldUnits'], details['newUnits']) + data = Path(details['old']['config']['data']) + backup = directory/'data-backup' + require_legacy_recovery_data(data) + if record.get('backupComplete'): + require_legacy_recovery_data(backup) + checkpoint(directory, record, 'restoring') + stop_helpers() + handoff.stop_units(service_plan.CORE_UNITS) + # Recheck after writers have stopped. A concurrent account creation must + # not disappear into a pre-account backup or an older runtime. + try: + require_legacy_recovery_data(data) + if record.get('backupComplete'): + require_legacy_recovery_data(backup) + except ValueError: + checkpoint(directory, record, 'recovery_required') + raise + if record.get('backupComplete'): + if inventory(backup) != record['backupInventory']: + raise ValueError('Rollback backup changed; refusing to overwrite live data') + restored = directory/'restored-data' + if restored.exists(): + raise ValueError('A previous restore needs inspection') + copy_data(backup, restored) + check_databases(restored) + failed = directory/('replaced-data-' + str(time.time_ns())) + data.rename(failed) + restored.rename(data) + install_units(Path(record['oldPlan']), Path(record['oldMaintenance'])) + token = handoff.read_owner_key(Path(details['old']['config']['environment'])) + record['restoredObservation'] = start_and_verify(details['old'], token, record['oldRevision'], seconds) + if configuration(data) != record['configurationBefore']: + raise RuntimeError('Restored configuration differs from the pre-update snapshot') + previous = Path(record['oldPlan'])/'activation.json' + active = json.loads(previous.read_text()) + active.update(phase='active', updatedAt=time.time()) + handoff.private_json(previous, active) + candidate = Path(record['newPlan'])/'activation.json' + if candidate.exists(): + current = json.loads(candidate.read_text()) + current.update(phase='rolled_back_to_previous_core', updatedAt=time.time()) + handoff.private_json(candidate, current) + checkpoint(directory, record, 'rolled_back') + return record + + +def upgrade(directory, old_directory, new_directory, old_helpers, new_helpers, seconds=180): + details = context(old_directory, new_directory, old_helpers, new_helpers) + old, new = details['old'], details['new'] + saved = activate.read_handoff(old_directory, old) + active = activate.read_activation(old_directory, old) + if active.get('phase') != 'active' or active.get('activeOwner') != 'sleepypod-core': + raise ValueError('The outgoing Core must have a verified active record') + for name in ('activation.json', 'handoff.json'): + if (new_directory/name).exists() or (new_directory/name).is_symlink(): + raise ValueError('Candidate already has an installation record') + validate_units(details['oldUnits']) + activate.validate_legacy_definitions(saved) + activate.assert_ownership(core=True) + activate.assert_boot_selection(core=True) + old_revision = maintenance_install.verify_release(Path(old['config']['release'])) + new_revision = maintenance_install.verify_release(Path(new['config']['release'])) + data = Path(old['config']['data']) + require_legacy_recovery_data(data) + token = handoff.read_owner_key(Path(old['config']['environment'])) + observation = handoff.core_observation(token, old_revision) + handoff.require_core_thermal_idle(token, observation) + # Reserve space for backup, restore rehearsal and failed-candidate data. + needed = sum(path.stat().st_size for path in data.rglob('*') if path.is_file()) * 4 + 128*1024*1024 + if shutil.disk_usage(data).free < needed: + raise ValueError('Insufficient space for verified backup and rollback') + record = {'format': FORMAT, 'oldPlan': str(old_directory), 'newPlan': str(new_directory), + 'oldMaintenance': str(old_helpers), 'newMaintenance': str(new_helpers), + 'oldRevision': old_revision, 'newRevision': new_revision, + 'createdAt': time.time(), 'backupComplete': False, 'physicalOperationVerified': False, + 'configurationBefore': configuration(data)} + checkpoint(directory, record, 'prepared') + try: + stop_helpers() + handoff.stop_units(service_plan.CORE_UNITS) + checkpoint(directory, record, 'quiesced') + require_legacy_recovery_data(data) + record['configurationBefore'] = configuration(data) + record['backupInventory'] = copy_data(data, directory/'data-backup') + check_databases(directory/'data-backup') + record['backupInventory'] = inventory(directory/'data-backup') + record['backupComplete'] = True + checkpoint(directory, record, 'backed_up') + copy_data(directory/'data-backup', directory/'restore-rehearsal') + check_databases(directory/'restore-rehearsal') + checkpoint(directory, record, 'restore_rehearsed') + validate_units(details['oldUnits']) + install_units(new_directory, new_helpers) + # This records installation only, not an invented initial migration trial. + handoff.private_json(new_directory/'handoff.json', { + 'format': 'sleepypod-handoff/1', 'phase': 'installed_not_started', + 'legacy': saved['legacy'], 'unitHashes': new['units'], + 'upgradedFrom': str(old_directory), 'upgradeJournal': str(directory/'upgrade.json'), + 'bootSelectionChanged': False, 'trialSucceeded': False}) + checkpoint(directory, record, 'starting_candidate') + record['observation'] = start_and_verify(new, token, new_revision, seconds) + if configuration(data) != record['configurationBefore']: + raise RuntimeError('Update changed a saved routine or user history') + handoff.private_json(new_directory/'activation.json', { + **active, 'sourceRevision': new_revision, 'unitHashes': new['units'], + 'phase': 'active', 'createdAt': time.time(), 'updatedAt': time.time(), + 'observation': record['observation'], 'physicalOperationVerified': False, + 'upgradedFrom': str(old_directory), 'upgradeJournal': str(directory/'upgrade.json')}) + handoff.private_json(new_helpers/'maintenance-install.json', { + 'format': maintenance_install.JOURNAL_FORMAT, 'phase': 'enabled', + 'unitHashes': details['newHelpers']['units'], 'controllerPlan': str(new_directory), + 'sourceCommit': new_revision, 'coreEnvironmentChanged': False, + 'controllerStarted': False, 'upgradeJournal': str(directory/'upgrade.json')}) + handoff.private_json(old_directory/'activation.json', { + **active, 'phase': 'superseded_by_upgrade', 'updatedAt': time.time(), + 'upgradeJournal': str(directory/'upgrade.json')}) + checkpoint(directory, record, 'active') + return record + except BaseException: + record['phaseBeforeRestore'] = record['phase'] + try: + restore(directory, details, record, seconds) + except BaseException: + checkpoint(directory, record, 'recovery_required') + raise + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('apply', 'rollback')) + parser.add_argument('--operation', required=True, type=Path) + parser.add_argument('--old-plan', type=Path) + parser.add_argument('--new-plan', type=Path) + parser.add_argument('--old-maintenance', type=Path) + parser.add_argument('--new-maintenance', type=Path) + parser.add_argument('--seconds', type=int, default=180) + args = parser.parse_args() + if os.geteuid() != 0 or not 30 <= args.seconds <= 300: + raise ValueError('Run as root with a 30–300 second readiness deadline') + os.umask(0o077) + for event in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(event, handoff.interrupt_trial) + if args.action == 'apply': + if not all((args.old_plan, args.new_plan, args.old_maintenance, args.new_maintenance)): + parser.error('Apply requires both controller and maintenance plans') + maintenance_install.secure_path(args.operation.parent, private=True) + args.operation.mkdir(mode=0o700) + else: + maintenance_install.secure_path(args.operation, private=True) + record = json.loads(maintenance_install.owned_bytes(args.operation/'upgrade.json', private=True, limit=16*1024*1024)) + if record.get('format') != FORMAT or record.get('phase') == 'rolled_back': + raise ValueError('No recoverable upgrade operation') + args.old_plan, args.new_plan = Path(record['oldPlan']), Path(record['newPlan']) + args.old_maintenance, args.new_maintenance = Path(record['oldMaintenance']), Path(record['newMaintenance']) + with ExitStack() as locks: + for directory in sorted({args.old_plan, args.new_plan}): + locks.enter_context(maintenance_broker.operation_lock(directory, 'handoff.lock')) + if args.action == 'apply': + result = upgrade(args.operation, args.old_plan, args.new_plan, + args.old_maintenance, args.new_maintenance, args.seconds) + else: + details = context(args.old_plan, args.new_plan, args.old_maintenance, args.new_maintenance) + result = restore(args.operation, details, record, args.seconds) + print(json.dumps({key: result.get(key) for key in + ('phase', 'oldRevision', 'newRevision', 'physicalOperationVerified')})) + + +if __name__ == '__main__': + main() diff --git a/scripts/home-assistant/pyproject.toml b/scripts/home-assistant/pyproject.toml new file mode 100644 index 000000000..9fb9b8271 --- /dev/null +++ b/scripts/home-assistant/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "sleepypod-home-assistant-validation" +version = "1.0.0" +requires-python = ">=3.14.2,<3.15" +dependencies = ["homeassistant==2026.10.4"] + +[tool.uv] +package = false diff --git a/scripts/home-assistant/test_blueprints.py b/scripts/home-assistant/test_blueprints.py new file mode 100644 index 000000000..b901a142c --- /dev/null +++ b/scripts/home-assistant/test_blueprints.py @@ -0,0 +1,217 @@ +"""Validate real HA blueprints and execute actions against isolated stub services.""" +from datetime import datetime, timedelta, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +from homeassistant import loader +from homeassistant.components.automation.config import AUTOMATION_BLUEPRINT_SCHEMA, PLATFORM_SCHEMA +from homeassistant.components.blueprint.models import Blueprint, BlueprintInputs +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import trigger as trigger_helper +from homeassistant.helpers.script import Script +from homeassistant.helpers.trigger import async_validate_trigger_config +from homeassistant.util import dt as dt_util, yaml as yaml_util + +BLUEPRINTS = Path(__file__).resolve().parents[2] / 'integrations/home-assistant/blueprints' +NOW = datetime(2026, 10, 26, 6, 30, tzinfo=timezone.utc) + + +def configuration(filename, supplied=None): + """Use HA's own loader/substitution/schema; no parallel YAML interpreter.""" + blueprint = Blueprint(yaml_util.load_yaml(str(BLUEPRINTS / filename)), + expected_domain='automation', schema=AUTOMATION_BLUEPRINT_SCHEMA) + if blueprint.validate(): + raise AssertionError(blueprint.validate()) + examples = { + 'scene': 'scene.test_bedroom', 'occupancy': 'binary_sensor.test_left', + 'left_occupancy': 'binary_sensor.test_left', 'right_occupancy': 'binary_sensor.test_right', + 'sensor': 'sensor.test_room', 'timestamp_attribute': 'sampled_at', + } + inputs = {key: value for key, value in examples.items() if key in blueprint.inputs} + inputs.update(supplied or {}) + config = BlueprintInputs(blueprint, {'id': filename, 'alias': filename, + 'use_blueprint': {'path': filename, 'input': inputs}}) + config.validate() + return PLATFORM_SCHEMA(config.async_substitute()) + + +class Blueprints(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.directory = TemporaryDirectory() + self.hass = HomeAssistant(self.directory.name) + loader.async_setup(self.hass) + await trigger_helper.async_setup(self.hass) + await self.hass.config.async_set_time_zone('America/Los_Angeles') + self.calls = [] + async def capture(call): + self.calls.append((call.domain, call.service, dict(call.data))) + for domain, service in [('scene', 'turn_on'), ('persistent_notification', 'create'), + ('persistent_notification', 'dismiss')]: + self.hass.services.async_register(domain, service, capture) + self.hass.states.async_set('scene.test_bedroom', 'unknown') + self.hass.states.async_set('binary_sensor.test_left', 'off') + self.hass.states.async_set('binary_sensor.test_right', 'off') + self.clock = patch.object(dt_util, 'now', return_value=NOW.astimezone(dt_util.get_time_zone('America/Los_Angeles'))) + self.clock.start() + + async def asyncTearDown(self): + await self.hass.async_stop() + self.clock.stop() + self.directory.cleanup() + + async def run_actions(self, filename, inputs=None, trigger=None): + """Run actions directly as HA's Run actions does, including internal guards.""" + self.calls.clear() + config = configuration(filename, inputs) + script = Script(self.hass, config['actions'], filename, 'automation', + variables=config.get('variables')) + variables = {'this': State('automation.test_room', 'on')} + if trigger is not None: + variables['trigger'] = trigger + await script.async_run(variables, Context()) + await self.hass.async_block_till_done() + return self.calls + + def exit_trigger(self, before='on', after='off'): + return {'platform': 'state', 'entity_id': 'binary_sensor.test_left', + 'from_state': State('binary_sensor.test_left', before), + 'to_state': State('binary_sensor.test_left', after)} + + def room(self, value='20', unit='%', sampled_at=None): + self.hass.states.async_set('sensor.test_room', value, { + 'unit_of_measurement': unit, + 'sampled_at': sampled_at if sampled_at is not None else (NOW - timedelta(seconds=5)).isoformat(), + }) + + async def test_every_blueprint_has_valid_ha_schema_and_starts_disabled(self): + for filename in BLUEPRINTS.glob('*.yaml'): + with self.subTest(filename=filename.name): + config = configuration(filename.name) + self.assertIs(config['initial_state'], False) + self.assertEqual(config['mode'], 'single') + self.assertEqual(config['variables'].variables['allow_actions'], False) + await async_validate_trigger_config(self.hass, config['triggers']) + + async def test_all_actions_are_inert_with_default_permission_even_manual_run(self): + self.room() + for filename in BLUEPRINTS.glob('*.yaml'): + with self.subTest(filename=filename.name): + calls = await self.run_actions(filename.name, trigger=self.exit_trigger()) + self.assertEqual(calls, []) + + async def test_wind_down_requires_real_time_trigger(self): + self.assertEqual(await self.run_actions('wind_down.yaml', {'allow_actions': True}), []) + calls = await self.run_actions('wind_down.yaml', {'allow_actions': True}, {'platform': 'time'}) + self.assertEqual(calls, [('scene', 'turn_on', {'entity_id': ['scene.test_bedroom']})]) + + async def test_scene_manual_runs_and_non_boolean_permissions_cannot_bypass_guards(self): + for filename in ['wind_down.yaml', 'night_path.yaml', 'morning_scene.yaml', 'empty_bed.yaml']: + with self.subTest(filename=filename): + self.assertEqual(await self.run_actions(filename, {'allow_actions': True}), []) + for permission in [1, 'true', 'yes']: + self.assertEqual(await self.run_actions('wind_down.yaml', {'allow_actions': permission}, + {'platform': 'time'}), []) + + async def test_night_path_known_exit_sends_only_selected_scene(self): + calls = await self.run_actions('night_path.yaml', {'allow_actions': True}, self.exit_trigger()) + self.assertEqual(calls, [('scene', 'turn_on', {'entity_id': ['scene.test_bedroom']})]) + + async def test_unknown_unavailable_and_restored_state_do_not_trigger_path(self): + for previous in ['unknown', 'unavailable', 'off']: + with self.subTest(previous=previous): + self.assertEqual(await self.run_actions('night_path.yaml', {'allow_actions': True}, + self.exit_trigger(previous)), []) + for current in ['unknown', 'unavailable', 'on']: + self.hass.states.async_set('binary_sensor.test_left', current) + self.assertEqual(await self.run_actions('night_path.yaml', {'allow_actions': True}, self.exit_trigger()), []) + + async def test_morning_scene_does_not_run_in_night_window(self): + self.assertEqual(await self.run_actions('morning_scene.yaml', {'allow_actions': True}, self.exit_trigger()), []) + with patch.object(dt_util, 'now', return_value=NOW.astimezone(dt_util.DEFAULT_TIME_ZONE).replace(hour=8)): + calls = await self.run_actions('morning_scene.yaml', {'allow_actions': True}, self.exit_trigger()) + self.assertEqual(calls[0][:2], ('scene', 'turn_on')) + + async def test_empty_bed_requires_both_known_empty_and_distinct_entities(self): + for state in ['on', 'unknown', 'unavailable']: + self.hass.states.async_set('binary_sensor.test_right', state) + self.assertEqual(await self.run_actions('empty_bed.yaml', {'allow_actions': True}, self.exit_trigger()), []) + self.hass.states.async_set('binary_sensor.test_right', 'off') + self.assertEqual(await self.run_actions('empty_bed.yaml', { + 'allow_actions': True, 'right_occupancy': 'binary_sensor.test_left'}, self.exit_trigger()), []) + self.assertEqual(await self.run_actions('empty_bed.yaml', {'allow_actions': True}, self.exit_trigger()), []) + # The condition clock advances, not the source data. Both known-off spans must pass. + latest = max(self.hass.states.get(entity).last_changed for entity in + ['binary_sensor.test_left', 'binary_sensor.test_right']) + with patch.object(dt_util, 'utcnow', return_value=latest + timedelta(minutes=6)): + calls = await self.run_actions('empty_bed.yaml', {'allow_actions': True}, self.exit_trigger()) + self.assertEqual(calls[0][:2], ('scene', 'turn_on')) + + async def test_room_notice_uses_original_source_clock_and_stable_id(self): + self.room() + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][:2], ('persistent_notification', 'create')) + self.assertIn('20.0 %', calls[0][2]['message']) + self.assertIn('No appliance or Pod command was sent.', calls[0][2]['message']) + self.assertEqual(calls[0][2]['notification_id'], 'sleepypod_room_automation.test_room') + + async def test_stale_future_and_missing_clocks_withdraw_notification(self): + for timestamp in [(NOW - timedelta(seconds=120)).isoformat(), + (NOW + timedelta(seconds=1)).isoformat(), 'garbage', False, 0]: + with self.subTest(timestamp=timestamp): + self.room(sampled_at=timestamp) + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][:2], ('persistent_notification', 'dismiss')) + + async def test_cached_value_cannot_be_rejuvenated_by_republishing(self): + self.room(sampled_at=(NOW - timedelta(seconds=119)).isoformat()) + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][1], 'create') + with patch.object(dt_util, 'now', return_value=NOW + timedelta(seconds=2)): + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][1], 'dismiss') + + async def test_wrong_units_nonfinite_and_unavailable_values_are_not_zero(self): + for value, unit in [('unknown', '%'), ('unavailable', '%'), ('NaN', '%'), ('inf', '%'), + ('-1', '%'), ('101', '%'), ('20', '°C')]: + with self.subTest(value=value, unit=unit): + self.room(value, unit) + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][1], 'dismiss') + + async def test_ha_receipt_clocks_and_reversed_thresholds_are_rejected(self): + self.room() + for overrides in [{'timestamp_attribute': name} for name in ['last_updated', 'last_reported', 'last_changed']] + [{'lower': 60, 'upper': 30}]: + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True, **overrides}) + self.assertEqual(calls[0][1], 'dismiss') + + async def test_missing_clock_age_limits_and_clock_type_mismatches_withdraw(self): + self.hass.states.async_set('sensor.test_room', '20', {'unit_of_measurement': '%'}) + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][1], 'dismiss') + self.room() + for changed in [{'maximum_age': 29}, {'maximum_age': 901}, {'timestamp_format': 'unix_seconds'}]: + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True, **changed}) + self.assertEqual(calls[0][1], 'dismiss') + self.room(sampled_at=True) + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True, 'timestamp_format': 'unix_seconds'}) + self.assertEqual(calls[0][1], 'dismiss') + + async def test_numeric_source_time_formats(self): + for format_name, factor in [('unix_seconds', 1), ('unix_milliseconds', 1000)]: + self.room(sampled_at=(NOW.timestamp() - 5) * factor) + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True, 'timestamp_format': format_name}) + self.assertEqual(calls[0][1], 'create') + + async def test_recovered_reading_withdraws_and_pm25_never_controls_a_fan(self): + self.room('45') + calls = await self.run_actions('humidity_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][1], 'dismiss') + self.room('25', 'µg/m³') + calls = await self.run_actions('air_quality_review.yaml', {'allow_actions': True}) + self.assertEqual(calls[0][:2], ('persistent_notification', 'create')) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/home-assistant/test_sensor_export.py b/scripts/home-assistant/test_sensor_export.py new file mode 100644 index 000000000..a155d3983 --- /dev/null +++ b/scripts/home-assistant/test_sensor_export.py @@ -0,0 +1,147 @@ +"""Exercise the exporter with Home Assistant's real schema and action engine.""" +from datetime import datetime, timezone +import json +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +from homeassistant import loader +from homeassistant.components.automation.config import AUTOMATION_BLUEPRINT_SCHEMA, PLATFORM_SCHEMA +from homeassistant.components.blueprint.models import Blueprint, BlueprintInputs +from homeassistant.core import Context, HomeAssistant, State +from homeassistant.helpers import trigger as trigger_helper +from homeassistant.helpers.script import Script +from homeassistant.helpers.trigger import async_validate_trigger_config +from homeassistant.util import dt as dt_util, yaml as yaml_util + +ROOT = Path(__file__).resolve().parents[2] / 'integrations/home-assistant/blueprints/sensor-export' +NOW = datetime(2026, 10, 27, 4, 0, tzinfo=timezone.utc) +TOPIC = 'sleepypod/fixture/bedroom/report' + + +def configuration(filename, supplied=None): + blueprint = Blueprint(yaml_util.load_yaml(str(ROOT / filename)), + expected_domain='automation', schema=AUTOMATION_BLUEPRINT_SCHEMA) + if blueprint.validate(): + raise AssertionError(blueprint.validate()) + examples = {'webhook_id': 'synthetic-test-only-webhook-1234567890', 'report_topic': TOPIC, + 'configuration_revision': 2, 'temperature_source': 'fixture-temperature', + 'humidity_source': 'fixture-humidity', 'request_helper': 'input_boolean.fixture_sample'} + inputs = {key: value for key, value in examples.items() if key in blueprint.inputs} + inputs.update(supplied or {}) + result = BlueprintInputs(blueprint, {'id': filename, 'alias': filename, + 'use_blueprint': {'path': filename, 'input': inputs}}) + result.validate() + return PLATFORM_SCHEMA(result.async_substitute()) + + +class SensorExport(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.directory = TemporaryDirectory() + self.hass = HomeAssistant(self.directory.name) + loader.async_setup(self.hass) + await trigger_helper.async_setup(self.hass) + self.calls = [] + async def capture(call): + self.calls.append((call.domain, call.service, dict(call.data))) + for domain, service in [('mqtt', 'publish'), ('input_boolean', 'turn_on'), ('input_boolean', 'turn_off')]: + self.hass.services.async_register(domain, service, capture) + self.hass.states.async_set('input_boolean.fixture_sample', 'off') + self.clock = patch.object(dt_util, 'now', return_value=NOW) + self.clock.start() + + async def asyncTearDown(self): + await self.hass.async_stop() + self.clock.stop() + self.directory.cleanup() + + async def run_actions(self, inputs=None, payload=None, trigger=None, filename='apple_home_cache.yaml'): + self.calls.clear() + config = configuration(filename, inputs) + script = Script(self.hass, config['actions'], filename, 'automation', variables=config['variables']) + variables = {'this': State('automation.fixture_export', 'on')} + if payload is not None: + variables['trigger'] = {'platform': 'webhook', 'json': payload} + elif trigger is not None: + variables['trigger'] = trigger + await script.async_run(variables, Context()) + await self.hass.async_block_till_done() + return self.calls + + def payload(self, **updates): + return {'schemaVersion': 1, 'temperature': 68, 'temperatureUnit': '°F', 'humidity': 50, **updates} + + async def test_real_schemas_and_trigger_configuration(self): + for filename in ROOT.glob('*.yaml'): + config = configuration(filename.name) + self.assertFalse(config['variables'].variables['allow_actions']) + self.assertNotIn('initial_state', config) # HA preserves the owner's enabled state across restarts. + self.assertEqual(config['mode'], 'single') + await async_validate_trigger_config(self.hass, config['triggers']) + receiver = configuration('apple_home_cache.yaml') + self.assertEqual(receiver['triggers'][0]['allowed_methods'], ['POST']) + self.assertIs(receiver['triggers'][0]['local_only'], True) + self.assertEqual(receiver['trace']['stored_traces'], 0) + + async def test_disabled_manual_and_wrong_trigger_requests_are_inert(self): + self.assertEqual(await self.run_actions(payload=self.payload()), []) + self.assertEqual(await self.run_actions({'allow_actions': True}), []) + self.assertEqual(await self.run_actions({'allow_actions': True}, trigger={'platform': 'time'}), []) + for permission in [1, 'true']: + self.assertEqual(await self.run_actions({'allow_actions': permission}, self.payload()), []) + + async def test_real_publication_converts_units_and_preserves_zero(self): + calls = await self.run_actions({'allow_actions': True}, self.payload(humidity=0)) + self.assertEqual(len(calls), 2) + for domain, service, data in calls: + self.assertEqual((domain, service), ('mqtt', 'publish')) + self.assertEqual(data['topic'], TOPIC) + self.assertEqual(data['qos'], 0) + self.assertIs(data['retain'], False) + reports = [json.loads(call[2]['payload']) for call in calls] + self.assertEqual([report['sourceId'] for report in reports], ['fixture-temperature', 'fixture-humidity']) + self.assertEqual([report['value'] for report in reports], [20, 0]) + for report in reports: + self.assertEqual(report, {'schemaVersion': 1, 'revision': 2, 'sourceId': report['sourceId'], + 'available': True, 'value': report['value'], 'observedAt': int(NOW.timestamp() * 1000), + 'measuredAt': None, 'timeBasis': 'cached'}) + + async def test_configured_fahrenheit_source_is_converted_explicitly(self): + calls = await self.run_actions({'allow_actions': True, 'temperature_unit': '°F'}, + self.payload(temperature=20, temperatureUnit='°C')) + self.assertEqual(json.loads(calls[0][2]['payload'])['value'], 68) + + async def test_invalid_values_withdraw_instead_of_becoming_zero(self): + for temperature, humidity in [(None, None), (True, False), ('20', '50'), (float('nan'), float('inf')), (201, -1)]: + with self.subTest(temperature=temperature, humidity=humidity): + calls = await self.run_actions({'allow_actions': True}, self.payload(temperature=temperature, humidity=humidity)) + self.assertEqual(len(calls), 2) + for call in calls: + report = json.loads(call[2]['payload']) + self.assertIsNone(report['value']) + self.assertIs(report['available'], False) + + async def test_webhook_cannot_supply_a_clock_revision_source_or_action(self): + for extra in [{'observedAt': 9999999999999}, {'revision': 7}, {'sourceId': 'other'}, {'action': 'heat'}]: + self.assertEqual(await self.run_actions({'allow_actions': True}, self.payload(**extra)), []) + for changed in [{'schemaVersion': True}, {'schemaVersion': 2}, {'temperatureUnit': 'unknown'}]: + self.assertEqual(await self.run_actions({'allow_actions': True}, self.payload(**changed)), []) + self.assertEqual(await self.run_actions({'allow_actions': True}, {'schemaVersion': 1}), []) + + async def test_invalid_configuration_does_not_publish(self): + for changed in [{'report_topic': 'sleepypod/fixture/command'}, {'report_topic': 'sleepypod/+/bedroom/report'}, + {'webhook_id': 'short'}, {'configuration_revision': 2.5}, + {'humidity_source': 'fixture-temperature'}, {'temperature_source': '../other'}]: + self.assertEqual(await self.run_actions({'allow_actions': True, **changed}, self.payload()), []) + + async def test_polling_touches_only_the_virtual_helper_and_requires_a_timer(self): + self.assertEqual(await self.run_actions(filename='apple_home_poll.yaml', trigger={'platform': 'time_pattern'}), []) + self.assertEqual(await self.run_actions({'allow_actions': True}, filename='apple_home_poll.yaml'), []) + calls = await self.run_actions({'allow_actions': True}, filename='apple_home_poll.yaml', trigger={'platform': 'time_pattern'}) + self.assertEqual([call[:2] for call in calls], [('input_boolean', 'turn_off'), ('input_boolean', 'turn_on'), ('input_boolean', 'turn_off')]) + self.assertTrue(all(call[2]['entity_id'] == ['input_boolean.fixture_sample'] for call in calls)) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/home-assistant/uv.lock b/scripts/home-assistant/uv.lock new file mode 100644 index 000000000..44a567607 --- /dev/null +++ b/scripts/home-assistant/uv.lock @@ -0,0 +1,2125 @@ +version = 1 +revision = 5 +requires-python = ">=3.14.2, <3.15" + +[[package]] +name = "acme" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "josepy" }, + { name = "pyopenssl" }, + { name = "pyrfc3339" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/53/2e79e7d1c5384414d4670f02729601cf23e61fe13b7b99471d02c620ddbe/acme-5.7.0.tar.gz", hash = "sha256:4be4fe6cf3809c3988d75fa1213ce8e784d9d5d8380cac63c2dca555e105653d", size = 90640, upload-time = "2026-07-15T17:40:26.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/af/b5f590040cd2efae675ff5c9da533abcf3011c6d0b7b67e118aa86c14ceb/acme-5.7.0-py3-none-any.whl", hash = "sha256:a736f88e975f0ebe8c6fcabd3db164d1071b4f9e1a56d1319a9f6997e0a64a0a", size = 95023, upload-time = "2026-07-15T17:40:07.404Z" }, +] + +[[package]] +name = "aiodns" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycares" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/22/a2d928e0e42baad0471d12ec44c71152ac870486e8298dddb2893b888c29/aiodns-4.0.4.tar.gz", hash = "sha256:cb10e0c0d2591636716ad2fe402e977c16d71bdaf76bb8cb49e8a6633596f736", size = 29918, upload-time = "2026-05-20T01:54:15.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/70/72e4ab117425ccdc4d10bd523a94c1baa051a15586057d64a4c6888f9e3f/aiodns-4.0.4-py3-none-any.whl", hash = "sha256:c24dd605bac70a1676ce503f967a98483ff163507198557d8e9db16267e6cfd2", size = 12696, upload-time = "2026-05-20T01:54:14.134Z" }, +] + +[[package]] +name = "aiogithubapi" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "backoff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/4c/1319dc5f7772f2ad960bd84d47d972a64aa596b6cdd966956e5e85501333/aiogithubapi-26.0.0.tar.gz", hash = "sha256:71ee97ebb242378535551ede80605384d1d3536b83e68dae938ce201d06dac33", size = 37561, upload-time = "2026-02-28T09:56:52.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a0/3102dbe2457eceb2e71f295e5d08f0f07c4982f82366cce34b2a332e348e/aiogithubapi-26.0.0-py3-none-any.whl", hash = "sha256:156b5f9217d23cb0eb65e233b19c10f499f1ba3bcf1d7d65e3a463c034e3813a", size = 72608, upload-time = "2026-02-28T09:56:51.23Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" }, + { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" }, + { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" }, + { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" }, + { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, +] + +[[package]] +name = "aiohttp-asyncmdnsresolver" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiodns" }, + { name = "aiohttp" }, + { name = "zeroconf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/86/1b94f5669df188f5267e26d48c4cc734b704310e53d695af5f39ee1af67d/aiohttp_asyncmdnsresolver-0.2.0.tar.gz", hash = "sha256:aab2cef7ef8ae45b94abf1fc92aa9f4d70cbef7ac0a3d495a86fb96fce357262", size = 37744, upload-time = "2026-05-20T20:18:27.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d5/c08b4e242d50d2aded314a16105ee708946fa3dfd37b837b1ca625d26397/aiohttp_asyncmdnsresolver-0.2.0-py3-none-any.whl", hash = "sha256:1e6dc01719dc563346ae051122fa7dc3a757317e3e6bf4ef8b23cfd034089c22", size = 10806, upload-time = "2026-05-20T20:18:25.58Z" }, +] + +[[package]] +name = "aiohttp-cors" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/d89e846a5444b3d5eb8985a6ddb0daef3774928e1bfbce8e84ec97b0ffa7/aiohttp_cors-0.8.1.tar.gz", hash = "sha256:ccacf9cb84b64939ea15f859a146af1f662a6b1d68175754a07315e305fb1403", size = 38626, upload-time = "2025-03-31T14:16:20.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/3b/40a68de458904bcc143622015fff2352b6461cd92fd66d3527bf1c6f5716/aiohttp_cors-0.8.1-py3-none-any.whl", hash = "sha256:3180cf304c5c712d626b9162b195b1db7ddf976a2a25172b35bb2448b890a80d", size = 25231, upload-time = "2025-03-31T14:16:18.478Z" }, +] + +[[package]] +name = "aiohttp-fast-zlib" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/a6/982f3a013b42e914a2420631afcaecb729c49525cc6cc58e15d27ee4cb4b/aiohttp_fast_zlib-0.3.0.tar.gz", hash = "sha256:963a09de571b67fa0ef9cb44c5a32ede5cb1a51bc79fc21181b1cddd56b58b28", size = 8770, upload-time = "2025-06-07T12:41:49.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/11/ea9ecbcd6cf68c5de690fd39b66341405ab091aa0c3598277e687aa65901/aiohttp_fast_zlib-0.3.0-py3-none-any.whl", hash = "sha256:d4cb20760a3e1137c93cb42c13871cbc9cd1fdc069352f2712cd650d6c0e537e", size = 8615, upload-time = "2025-06-07T12:41:47.454Z" }, +] + +[[package]] +name = "aiooui" +version = "0.1.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/e4/9ac5dc50921ebaffb625c55d4a057d337cca95fe2de3257649fea946e5e1/aiooui-0.1.11.tar.gz", hash = "sha256:737e1c605339a4ca3a860f0c60f1a35af0e256b44c93e8d0487ad62f3addeda8", size = 346204, upload-time = "2026-09-25T18:36:03.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a8/5dd74c2dad63e66ea9b0caf67abaa6ce2ae84207346141b3952e6c703290/aiooui-0.1.11-py3-none-any.whl", hash = "sha256:531e808bba926d80213413202243754a125a100b3e7912dadb3cd3f921e1665d", size = 343372, upload-time = "2026-09-25T18:36:01.841Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiozoneinfo" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/00/e437a179ab78ed24780ded10bbb5d7e10832c07f62eab1d44ee2f335c95c/aiozoneinfo-0.2.3.tar.gz", hash = "sha256:987ce2a7d5141f3f4c2e9d50606310d0bf60d688ad9f087aa7267433ba85fff3", size = 8381, upload-time = "2025-02-04T19:32:06.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/a4/99e13bb4006999de2a4d63cee7497c3eb7f616b0aefc660c4c316179af3a/aiozoneinfo-0.2.3-py3-none-any.whl", hash = "sha256:5423f0354c9eed982e3f1c35edeeef1458d4cc6a10f106616891a089a8455661", size = 8009, upload-time = "2025-02-04T19:32:04.74Z" }, +] + +[[package]] +name = "annotatedyaml" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "propcache" }, + { name = "pyyaml" }, + { name = "voluptuous" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/4b/973067092ee348e331d125acd60c45245f11663373c219650814b43d0025/annotatedyaml-1.0.2.tar.gz", hash = "sha256:f9a49952994ef1952ca17d27bb6478342eb1189d2c28e4c0ddbbb32065471fb0", size = 15366, upload-time = "2025-10-04T14:36:26.655Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/91/0acf5b74926c6964812d9ed752af77531ab4daa06fba1cb668d9006e9e1f/annotatedyaml-1.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c42f385c3f04f425d5948c16afbb94a876da867be276dbf2c2e7436b9a80792d", size = 58962, upload-time = "2025-10-04T14:42:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/71/f6/5dac1ce125984db4cb99d883f234e6a8c0e49358a9136047a490bc2ba51a/annotatedyaml-1.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b5d9d24ba907fd2e905eac69c88e651310c480980a17aa57faf0599ff21f586f", size = 60252, upload-time = "2025-10-04T14:42:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/81dea3e4272927518abb9c96ab299b8c4346c40267740bfb8d6b0cdb317f/annotatedyaml-1.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2572b7c3c630dae1dd163d6c6ba847493a7f987437941b32d0ad8354615f358a", size = 71219, upload-time = "2025-10-04T14:42:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/42/fb/5aa3d7767cb92e8ba34cba582e5b088f42746e6d075f7d387fcdc4e5dd62/annotatedyaml-1.0.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b28fe13eb0014a0dd06c9a292466feed0cd298ab10525ef9a37089df01c7d333", size = 64459, upload-time = "2025-10-04T14:42:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/a0/eb/b29b84eec6d3a1fc3278ff2959388f347e7853a3f82fc5275c591a523835/annotatedyaml-1.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:987f73a13f121c775bcdb082214c17f114447fee7dad37db2f86b035893ad58d", size = 71175, upload-time = "2025-10-04T14:42:05.22Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7c/4f4bf854f4b62cade7485a9572773d4440ee535e905f166b441b2d3f19a7/annotatedyaml-1.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cb4ee79c08da2b8f4f24b1775732ca6c497682f3c9b3fd65dee4ea084fc925c", size = 71824, upload-time = "2025-10-04T14:42:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/68/ac/1f903eeccde636723fcf664b372a6ab253b7f13c3de446ff5bce6852d696/annotatedyaml-1.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:da065a8c29556219fce1aa81b406e84f73bc2181067658e57428a8b2e662fc1b", size = 65343, upload-time = "2025-10-04T14:42:07.727Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/43e83b50a42ad5c51abf1a335cfc249e182f66542d7c7306ee07397b1956/annotatedyaml-1.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ba9937418c1b189b267540b47fa0dc24c148292739d06a6ca31c2ca8482f16", size = 72328, upload-time = "2025-10-04T14:42:08.656Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6a/f5d9c29633c499973f10330af31a8b135a564a4a2e056c32a6ff2c901559/annotatedyaml-1.0.2-cp314-cp314-win32.whl", hash = "sha256:003e16e91b40176dd8fe77d56c6c936106b408b62953e88ce3506e8ba10bf4e1", size = 57286, upload-time = "2025-10-04T14:42:09.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/d8c7464676094658f1caaee6762536ab43867d7153f7c637207c63fc4c97/annotatedyaml-1.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:17e64a7dde47a678db8aa4e934c3ed8da9a52ab1bc6946d12be86f323e6bd8c7", size = 61363, upload-time = "2025-10-04T14:42:10.968Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5d/7e384f4115a7bc113162f7b6eb5d561031e303f840f304b68e3f1b0541a1/annotatedyaml-1.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8698bbbd1d38f8c9ba95a107d7597f5af3f2ba295d1d14227f85b62377998ffc", size = 104776, upload-time = "2025-10-04T14:42:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/77bebdd30118c1e85f11d5a83a3bb5955409bba74d81cfb0f7b551273513/annotatedyaml-1.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cbc661dbc7c5f7ddf69fbf879da6a96745b8cd39ae1338dab3a0aa8eb208367", size = 107716, upload-time = "2025-10-04T14:42:14.151Z" }, + { url = "https://files.pythonhosted.org/packages/dc/19/bfc798abb154e398d5210304ba3beff9ad9c7b6ec4574ffb705493b8e2d5/annotatedyaml-1.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db1c3ca021bbd390354037ede5c255657afb2a7544b7cfa0e091b62b888aa462", size = 130361, upload-time = "2025-10-04T14:42:15.494Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "astral" +version = "2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/c3/76dfe55a68c48a1a6f3d2eeab2793ebffa9db8adfba82774a7e0f5f43980/astral-2.2.tar.gz", hash = "sha256:e41d9967d5c48be421346552f0f4dedad43ff39a83574f5ff2ad32b6627b6fbe", size = 578223, upload-time = "2020-05-20T14:23:17.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/60/7cc241b9c3710ebadddcb323e77dd422c693183aec92449a1cf1fb59e1ba/astral-2.2-py2.py3-none-any.whl", hash = "sha256:b9ef70faf32e81a8ba174d21e8f29dc0b53b409ef035f27e0749ddc13cb5982a", size = 30775, upload-time = "2020-05-20T14:23:14.866Z" }, +] + +[[package]] +name = "async-interrupt" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/79/732a581e3ceb09f938d33ad8ab3419856181d95bb621aa2441a10f281e10/async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7", size = 8484, upload-time = "2025-02-22T17:15:04.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/77/060b972fa7819fa9eea9a70acf8c7c0c58341a1e300ee5ccb063e757a4a7/async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c", size = 8907, upload-time = "2025-02-22T17:15:01.971Z" }, +] + +[[package]] +name = "atomicwrites-homeassistant" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/5a/10ff0fd9aa04f78a0b31bb617c8d29796a12bea33f1e48aa54687d635e44/atomicwrites-homeassistant-1.4.1.tar.gz", hash = "sha256:256a672106f16745445228d966240b77b55f46a096d20305901a57aa5d1f4c2f", size = 12223, upload-time = "2022-07-08T20:56:46.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/1b/872dd3b11939edb4c0a27d2569a9b7e77d3b88995a45a331f376e13528c0/atomicwrites_homeassistant-1.4.1-py2.py3-none-any.whl", hash = "sha256:01457de800961db7d5b575f3c92e7fb56e435d88512c366afb0873f4f092bb0d", size = 7128, upload-time = "2022-07-08T20:56:44.186Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + +[[package]] +name = "awesomeversion" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/3a/c97ef69b8209aa9d7209b143345fe49c1e20126f62a775038ab6dcd78fd5/awesomeversion-25.8.0.tar.gz", hash = "sha256:e6cd08c90292a11f30b8de401863dcde7bc66a671d8173f9066ebd15d9310453", size = 70873, upload-time = "2025-08-03T08:54:07.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/b3/c6be343010721bfdd3058b708eb4868fa1a207534a3b6c80de74d35fb568/awesomeversion-25.8.0-py3-none-any.whl", hash = "sha256:1c314683abfcc3e26c62af9e609b585bbcbf2ec19568df2f60ff1034fb1dae28", size = 15919, upload-time = "2025-08-03T08:54:06.265Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + +[[package]] +name = "bleak" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-radios", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/df/05a3f80ca8e3f7f5b0dba68a9e618147c909ccdba1468f07487dc8d72a9d/bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f", size = 125293, upload-time = "2026-05-02T23:01:04.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/54/05aceb9cd80073805b3ed8522e3196e8cb22f70e741873fa51406c31f4e7/bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d", size = 146490, upload-time = "2026-05-02T23:01:02.592Z" }, +] + +[[package]] +name = "bleak-retry-connector" +version = "4.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bleak" }, + { name = "bluetooth-adapters", marker = "sys_platform == 'linux'" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/8b/eb4a6668665f4e028a27b6036ad4816683c442158520f4ea2aad9ff775c3/bleak_retry_connector-4.7.1.tar.gz", hash = "sha256:9a5b07d2d7c5a9809b1769492d956604a7403870693be92754259d9eed5c177e", size = 19865, upload-time = "2026-09-14T18:37:54.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ec/3b503e27e65872ac5ae213ee913b5509e461bb96b1d495ae2f72800c4a85/bleak_retry_connector-4.7.1-py3-none-any.whl", hash = "sha256:75b4cb3f69955123121d860348d2c43da638a0f3211f2cb6197d717edbd6f47f", size = 19830, upload-time = "2026-09-14T18:37:53.358Z" }, +] + +[[package]] +name = "bluetooth-adapters" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiooui" }, + { name = "bleak" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "uart-devices" }, + { name = "usb-devices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/e0/6f7d10ffd8e5b62fc0998c285991925aaa9b727521bede0e29a0b0084a94/bluetooth_adapters-2.4.0.tar.gz", hash = "sha256:e8a61935a867deb8af981e986bdf504c4398895ff254eac626790184a4fdd1e3", size = 17501, upload-time = "2026-05-26T14:28:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/ba/f11151a72f782bf24908c2b57163a2ff854024594c4ff3d04f1c47f40ed0/bluetooth_adapters-2.4.0-py3-none-any.whl", hash = "sha256:f2841c49e8750658ecb8c1d46d2c05a9de4740362eb83e3e7e501d3092c0377e", size = 20604, upload-time = "2026-05-26T14:28:57.107Z" }, +] + +[[package]] +name = "bluetooth-auto-recovery" +version = "1.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bluetooth-adapters" }, + { name = "btsocket" }, + { name = "pyric" }, + { name = "usb-devices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/4e/14070e4e938069ef96fd1bfb949ea257ce7d807920f43b945bebde46c458/bluetooth_auto_recovery-1.6.4.tar.gz", hash = "sha256:c69a9f3b5e00239ab005d808aa5e7afa3c36a82f86e9531b6c7682bde1bc3ecc", size = 14827, upload-time = "2026-05-25T02:37:03.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/89/0dc9270c3c8640ea74fb35eef20e00c5fbdf929b4aeb6797cfa3e2f29750/bluetooth_auto_recovery-1.6.4-py3-none-any.whl", hash = "sha256:39485c41e17a2d4887c1fbf04b4e2fd37f0f3c7898db388753e026ca3addf055", size = 13471, upload-time = "2026-05-25T02:37:01.788Z" }, +] + +[[package]] +name = "bluetooth-data-tools" +version = "1.29.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/b5/bc29fcc1a3e8fbb065be4c117f6ed3b21b5e0c493f4c8011dac323270b9c/bluetooth_data_tools-1.29.24.tar.gz", hash = "sha256:0d9e7b1e26cd4ebe733490cffeed18340bb4ee8c1a2c0a0e8119b656f49700e7", size = 22240, upload-time = "2026-08-30T23:01:54.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/58/05d470f746996fa79d25b385b1ff86f449a76e92c76aa0842f626592ce7b/bluetooth_data_tools-1.29.24-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ff738867f0f5849d20d160493a52ed407852b53231208d107549f663b851d03", size = 464608, upload-time = "2026-08-30T23:11:51.411Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/6f26f0e8c49f21439e43e1fc26751d5cae26bed1b275df253bf1f70a2401/bluetooth_data_tools-1.29.24-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:707694da88fb67d063decaa93866e231632d7b1187fb5cf3558f0233fd1a8862", size = 463582, upload-time = "2026-08-30T23:11:52.899Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/c7c81b9d8c9ad402c769ec57d1dd76238db0b2c19cdffd36818c6586cef9/bluetooth_data_tools-1.29.24-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ae45d678a6c0b06d5be25bf17a3238806d8fda1fc62a4708b45c3816d2ffaa", size = 491467, upload-time = "2026-08-30T23:11:54.479Z" }, + { url = "https://files.pythonhosted.org/packages/f6/75/e93f534f6cb51633e75976653169e625b79dbb3bdaeaf1ca5257b44e54a3/bluetooth_data_tools-1.29.24-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ccfc8bdf98d21069f1ddf55ee4a2e67a911bab98c27909a631876097d3fd398d", size = 157445, upload-time = "2026-08-30T23:11:56.044Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b5/2f15189705c115e8cb42836da902221bd9fc511ce52352d15fe8d692f56b/bluetooth_data_tools-1.29.24-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:324b1225bcb3ae125cb60218581577c11f20a0463dec163157947c8c54b5b800", size = 494185, upload-time = "2026-08-30T23:11:57.48Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f4/f82d360119f486e29baf03432977b065ac424d583175752b794c24a2e2a8/bluetooth_data_tools-1.29.24-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:594afe1dac691f570de1f73737d959d294877f802ef1b6ec7b1d0eaf94ac12b5", size = 171330, upload-time = "2026-08-30T23:01:52.731Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/435d4e0152a17ef01ecfd4a08cb0bd2e6f07a6838302348e561943936674/bluetooth_data_tools-1.29.24-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f54ef148ad751eaf1686380b48847fec7f95c7a12c1593b8b062d92f38619e3d", size = 492846, upload-time = "2026-08-30T23:11:59.053Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d2/400a98eec8f159e990e70fd0b89939dbf43ed46697d4d33b7d4f15742276/bluetooth_data_tools-1.29.24-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:31e52b2c448cdbd31ebb12a333cbbe08f32a59aa05290f48a08c2bd8f494de77", size = 158846, upload-time = "2026-08-30T23:12:00.422Z" }, + { url = "https://files.pythonhosted.org/packages/49/12/94c8e7f25bfdc03a53ebd04b2368efe2483928d77d494344ccbb5016b8bb/bluetooth_data_tools-1.29.24-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c353d121733754f283a2a2666c852ecb01542040c4eb1060e2ded234cd7657f", size = 496431, upload-time = "2026-08-30T23:12:01.956Z" }, + { url = "https://files.pythonhosted.org/packages/c9/33/10074ef4e8c3156efe4ab88574ecc90eb8cade942be58d0f9b7be6aae671/bluetooth_data_tools-1.29.24-cp314-cp314-win32.whl", hash = "sha256:c89a98a8b49b0fa1a06b3fae86ad3b7788ae59a31c0993107b336564e3d1d62b", size = 384033, upload-time = "2026-08-30T23:12:03.434Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8d/834f38ab37ff98e4a250e19cbca7b19e06260c63aaf2b83b036e656becf7/bluetooth_data_tools-1.29.24-cp314-cp314-win_amd64.whl", hash = "sha256:9b338174292b1b91521349d99b6d8821f3df16de595152b396c3cf9faa229b7e", size = 396378, upload-time = "2026-08-30T23:12:04.843Z" }, + { url = "https://files.pythonhosted.org/packages/91/13/4ee1067e6914ae863099d1dec0ed1692bbf1a90d5941a2c559bd2b3db99b/bluetooth_data_tools-1.29.24-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ce11b88742863273b4972b556cbb04a9285fbafdb2afebe8f4467709715f74f0", size = 595222, upload-time = "2026-08-30T23:12:06.597Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fd/6375d4805fb35d0e1c5bbd9816daec7647dc5d39ec852b66f53f53adbe8e/bluetooth_data_tools-1.29.24-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ed6fa9db443f95c30c1f8b11f8cae9afadc8bf722d252477250f8b5749fc3757", size = 595150, upload-time = "2026-08-30T23:12:08.241Z" }, + { url = "https://files.pythonhosted.org/packages/7b/aa/bbb4dbf53accc5ece4d1f94d18a81d67015a490b89364ea1f8ee91f96af7/bluetooth_data_tools-1.29.24-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:712a61b0398c49ee6ac7d34e23553ea0685624b6bd760aaea2e77252609aa5d0", size = 646437, upload-time = "2026-08-30T23:12:09.703Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d1/5ff4a1fc85f5a04e3310cf584c61b467a317d13006111fba5d3905a691dc/bluetooth_data_tools-1.29.24-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66b03ee8e6e17efa9283e3b0657b585f21104bf605ec00533b016538095865bd", size = 615861, upload-time = "2026-08-30T23:12:11.356Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4f/37dc994558d9c154db84079d569f90c7d479e3a55ef5cae8ea47563c8906/bluetooth_data_tools-1.29.24-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90e8ef38a8de370395e7526c7a79da20782180b7a220d5c2eea591faa8b55d3e", size = 648983, upload-time = "2026-08-30T23:12:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/1d/35/8403dc708771e0122e314cf8cdfff1895c7a8b2f30a06d49bc6f8be6b38a/bluetooth_data_tools-1.29.24-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:58e9cc8dbef9562cb5c3cd3a3b41530c1f3c3745a08d65a3d12dd743144ca75a", size = 648666, upload-time = "2026-08-30T23:12:14.696Z" }, + { url = "https://files.pythonhosted.org/packages/f1/65/7b9c2cd0b0ad3de07f1c040b74c07555d81b0cd6833805eb7d7f82ec3b8d/bluetooth_data_tools-1.29.24-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2f957d1338d8a9389ea6f098d716298febee49b0d77f2e313c82de30ae950eae", size = 621689, upload-time = "2026-08-30T23:12:16.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/930ec04de8b195e02e19ab519301dd580b1667f776f85d5e30d507f42566/bluetooth_data_tools-1.29.24-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:820a8fad8dec8fcd955cf1e9d2f2b7c0d4b675f1b65fb52caa4481d0a2f30369", size = 653610, upload-time = "2026-08-30T23:12:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/05/8c/ee74315138010d1941ef8ab0dcb0e9ee5db29b1c5a0df690e9cfa2a5ec49/bluetooth_data_tools-1.29.24-cp314-cp314t-win32.whl", hash = "sha256:701205fffbc3390d5a3a945836d566de97255210e0d485904c4a8d4be4c467c9", size = 484887, upload-time = "2026-08-30T23:12:19.756Z" }, + { url = "https://files.pythonhosted.org/packages/8b/15/6357220c04b736bad611fd1e621ed2973597e9f7546d4d9b9ea5a942dcff/bluetooth_data_tools-1.29.24-cp314-cp314t-win_amd64.whl", hash = "sha256:12e5ac9d5a5e669466bd21cdf1809c9c3a7364dd07d7dd10ff96ab0b00ad0355", size = 510102, upload-time = "2026-08-30T23:12:21.189Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.118" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/8b/5694c8433b5202f34a82def4edee274f7df1eba2919c66d9d389d2202ec8/boto3-1.43.118.tar.gz", hash = "sha256:c0620f799e0f0c222ba42c337b65cdbcd3da38a3121187f634d8926cbbb28a9c", size = 112649, upload-time = "2026-10-22T19:22:09.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/a3/65bb3d64928f3c2da17640b92044084150317f02adf22442cb0c52af2a8b/boto3-1.43.118-py3-none-any.whl", hash = "sha256:ab8f3d2c622ef039338b4f228d97ea55f1abd573d84bb9b9a5ee8cb6780c0ccd", size = 140045, upload-time = "2026-10-22T19:22:08.353Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.118" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/b8/3e55154ddaf1a6d1d4ddda85c0e5983205a3b7657edacd8ebba8c6fd774c/botocore-1.43.118.tar.gz", hash = "sha256:7f3f9d88cc9e847587690f82a42e3c3c292c3026c92c1e60d68a9310018e003f", size = 16420126, upload-time = "2026-10-22T19:22:04.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/61b39a8e7a69af5ea43c1c3e1b0065a4866b42ccddf107e16686a3143614/botocore-1.43.118-py3-none-any.whl", hash = "sha256:a45d7ae570674161e4a3542d8c68633bf7503f92d1de781ef7361b79488eda6f", size = 16122016, upload-time = "2026-10-22T19:22:00.414Z" }, +] + +[[package]] +name = "btsocket" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/b1/0ae262ecf936f5d2472ff7387087ca674e3b88d8c76b3e0e55fbc0c6e956/btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d", size = 19563, upload-time = "2024-06-10T07:05:27.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/2b/9bf3481131a24cb29350d69469448349362f6102bed9ae4a0a5bb228d731/btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c", size = 14807, upload-time = "2024-06-10T07:05:26.381Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "ciso8601" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/8a/075724aea06c98626109bfd670c27c248c87b9ba33e637f069bf46e8c4c3/ciso8601-2.3.3.tar.gz", hash = "sha256:db5d78d9fb0de8686fbad1c1c2d168ed52efb6e8bf8774ae26226e5034a46dae", size = 31909, upload-time = "2025-08-20T16:31:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3a/54ad0ae2257870076b4990545a8f16221470fecea0aa7a4e1f39506db8c5/ciso8601-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82db4047d74d8b1d129e7a8da578518729912c3bd19cb71541b147e41f426381", size = 16115, upload-time = "2025-08-20T16:30:54.971Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/9fe767d44520691e2b706769466852fbdeb44a82dc294c2766bce1049d22/ciso8601-2.3.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a553f3fc03a2ed5ca6f5716de0b314fa166461df01b45d8b36043ccac3a5e79f", size = 24214, upload-time = "2025-08-20T16:30:56.359Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ac/984fd3948f372c46c436a2b48da43f4fb7bc6f156a6f4bc858adaab79d42/ciso8601-2.3.3-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:ff59c26083b7bef6df4f0d96e4b649b484806d3d7bcc2de14ad43147c3aafb04", size = 15929, upload-time = "2025-08-20T16:30:58.352Z" }, + { url = "https://files.pythonhosted.org/packages/de/3a/5572917d4e0bec2c1ef0eda8652f9dc8d1850d29d3eef9e5e82ffe5d6791/ciso8601-2.3.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99a1fa5a730790431d0bfcd1f3a6387f60cddc6853d8dcc5c2e140cd4d67a928", size = 41578, upload-time = "2025-08-20T16:30:59.351Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cf/07321ce5cf099b98de0c02cd4bab4818610da69743003e94c8fb6e8a59cb/ciso8601-2.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c35265c1b0bd2ac30ed29b49818dd38b0d1dfda43086af605d8b91722727dec0", size = 42085, upload-time = "2025-08-20T16:31:00.338Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c7/3c521d6779ee433d9596eb3fcded79549bbe371843f25e62006c04f74dc9/ciso8601-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aa9df2f84ab25454f14df92b2dd4f9aae03dbfa581565a716b3e89b8e2110c03", size = 41313, upload-time = "2025-08-20T16:31:01.313Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/efd40db0d6b512be1cbe4e7e750882c2e88f580e17f35b3e9cc9c23004b5/ciso8601-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32e06a35eb251cfc4bbe01a858c598da0a160e4ad7f42ff52477157ceaf48061", size = 41443, upload-time = "2025-08-20T16:31:02.357Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/515f9404faa39af8df5e2b899cafbca5dbe7cd2ffe5cc124ef393ffdaf1c/ciso8601-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:7657ba9730dc1340d73b9e61eca14f341c41dd308128c808b8b084d2b85bc03e", size = 17977, upload-time = "2025-08-20T16:31:03.429Z" }, +] + +[[package]] +name = "cronsim" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "dbus-fast" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/ce64b8788c10a8bd313c8638b28be5dccdd5c2daf14839f23aff37e0b39d/dbus_fast-5.2.0.tar.gz", hash = "sha256:a4a5dddc04b1ade5eb7650d791e2f6fb7c1334595593473914e78a2526ecddda", size = 86442, upload-time = "2026-10-02T13:18:54.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/cdb494b0aadaf99c970f6baca4a3156506b6ffe9a6061ea2c725b214fea5/dbus_fast-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a772708d25c11e980642781f603882e3dc51b5767be19075ffc5a484c4d3411", size = 857487, upload-time = "2026-10-02T13:40:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a7/ec412544064624f12681113debf1a991293e9632bd0125a03a8e652d00e8/dbus_fast-5.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c66b094e96c221b877ccd6627bc3b9d808ac8317a8f6adc1cb2a0223e7d64e2", size = 900696, upload-time = "2026-10-02T13:40:47.255Z" }, + { url = "https://files.pythonhosted.org/packages/26/8e/d2e7791016d88ce8b28afdd5a6d0381937c376e8eed3b761c585cc1ef117/dbus_fast-5.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:79b842eb42f439fabd47db9deb7846d849933eb864fc53373d355c63f850eaa6", size = 877727, upload-time = "2026-10-02T13:40:48.88Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3f/edc14f91f77030bffc891319a2b7939b737972e1b7a17490dc5df3cc7a78/dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:788861134ac1794d44a03970fc817896b4bb35247353eeb13c363e238f7d4474", size = 867199, upload-time = "2026-10-02T13:40:50.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/96/cfc6f0c7a6e3634239bc98de1f5e701ed7330c5c2f9f1f8115a637efe1a9/dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:352e4cc8dbc608e297a73784857a8f9841d3a221b10e4b0f6a1b4b5168456e51", size = 880795, upload-time = "2026-10-02T13:40:52.128Z" }, + { url = "https://files.pythonhosted.org/packages/74/5b/07ec1855d708d396c8847414508f126d792b69ae0767e6c6305fd07d92a2/dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc04ca465f9d9847aa4273efe85da8fed82988004f1002b833df788f48fc0ecd", size = 910365, upload-time = "2026-10-02T13:40:53.799Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/6c97339dcdce2c1aed42eaeaf4bff309c097ae92ee2395b1d3e6844171b2/dbus_fast-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa260884e2df72d584ffec2d5d2f90ea0d624db8326ff0bea33b59f8998a09f2", size = 1625010, upload-time = "2026-10-02T13:40:57.105Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/ee9b144dd0960960c39300aee480df9da7597fd9158e10992c6f8198c67a/dbus_fast-5.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc5845602cd734e01bcee84fc2ff08642987d95d42edb434d048103905d3173f", size = 1706829, upload-time = "2026-10-02T13:40:58.836Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cf/46b9fb29b1cc51bbca6ba6da078739fde78c6f2b80da1e903a5ab7adf4e8/dbus_fast-5.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:288111b8d920b5ab445c2d9e4f13cd8521fe5233efe191c4749dd8fd07c5beb9", size = 868530, upload-time = "2026-10-02T13:41:00.639Z" }, + { url = "https://files.pythonhosted.org/packages/61/3d/fd53daea0cfa5d7d1e2abfb02253003d4c82cb566575047c69b295d26508/dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d828828f879c0536981c1eaf2d4c6fa65fd30354cecb1e16a158a9cda36a827c", size = 1646233, upload-time = "2026-10-02T13:41:02.318Z" }, + { url = "https://files.pythonhosted.org/packages/95/d4/f245a10be37bd2b3ca285a4ba43796421d018e52c9b59f8e92f92d2ca733/dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c4e7f48961e7c85540086458be0c5ca6ae6272e327c15907bd7d1e777ab2ace", size = 872057, upload-time = "2026-10-02T13:41:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/13/6e/08d7cce0bdb8b930e19aa7fa1e6cd89b9984ce2039c23f29b2b85e6df171/dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a0d506adfcbd5451e23ec2b645437ccf419e9ed7ad1f6f82b622d2a292fd23e5", size = 1727355, upload-time = "2026-10-02T13:41:05.805Z" }, +] + +[[package]] +name = "envs" +version = "1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/7f/2098df91ff1499860935b4276ea0c27d3234170b03f803a8b9c97e42f0e9/envs-1.4.tar.gz", hash = "sha256:9d8435c6985d1cdd68299e04c58e2bdb8ae6cf66b2596a8079e6f9a93f2a0398", size = 9230, upload-time = "2021-12-09T22:16:52.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/bc/f8c625a084b6074c2295f7eab967f868d424bb8ca30c7a656024b26fe04e/envs-1.4-py3-none-any.whl", hash = "sha256:4a1fcf85e4d4443e77c348ff7cdd3bfc4c0178b181d447057de342e4172e5ed1", size = 10988, upload-time = "2021-12-09T22:16:51.127Z" }, +] + +[[package]] +name = "fnv-hash-fast" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fnvhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ef/7d94271729042f4905800f9034adb149f2a716af26f2cfe9c46343637322/fnv_hash_fast-2.0.3.tar.gz", hash = "sha256:57b499a80ea8f7daf901aff047377264ef21577b40575183807dba37bcc00d6f", size = 5765, upload-time = "2026-05-16T23:52:11.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/35/7c68c9f8d51e31200a1371b396b772da4d32f269e02cdff7cada22594656/fnv_hash_fast-2.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:381df4fd4891fb20483673d7ff1973395a5a81c4fa736a685ad05e74653c4dbf", size = 7457, upload-time = "2026-05-16T23:58:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/bc/14/5b5cea95c30a51bb76b84661be9de3cc7625fb0edc3252b7509864d36f4d/fnv_hash_fast-2.0.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f50c18835bdc7c92a62801778dd5fdc6593c603b4cbadd7adae9d5f17305a52", size = 8374, upload-time = "2026-05-16T23:58:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/00/c9/1d0a2299ddcbcc9168c3bdc99dcae08de1dae276705baddab323b912468a/fnv_hash_fast-2.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45838d0383438696d815b6de92c841215d4f9f39b8dbaf58071a1af84ea35fc", size = 9207, upload-time = "2026-05-16T23:58:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/18/8b/356df1dc62dd05cfdc3fec24e8e66101be1544f03a388a8043f7eb6d9577/fnv_hash_fast-2.0.3-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:cabf3cc36b0f5eb26eb19716842edf709b032a54e1d18a72981c21458c52c632", size = 7745, upload-time = "2026-05-16T23:52:09.972Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3c/c740792c230608204a4dac1ba2aaaec1615115778ba33836143a937c7996/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe4da9446b554365a24d026c0f08b6ee96e30f31b8db7eb683ea167c923755e6", size = 9117, upload-time = "2026-05-16T23:58:45.351Z" }, + { url = "https://files.pythonhosted.org/packages/f0/09/f859b56caab1e3b564d9ec05771f380f7965e39a1b8c2e3fdaa341655a26/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c89aa84df2bfe2cc280694a8cf9fca3955640ce5f5c6f30f6b1bb020b30f2175", size = 8609, upload-time = "2026-05-16T23:58:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/1e/fa/3f7aeae26885762d7ad20c77a643c8671f7ae63069dc70cddc77483eb5ee/fnv_hash_fast-2.0.3-cp314-cp314-win32.whl", hash = "sha256:f25e37526b2898677f0b6a99a441bf0a86eab9e354d5d9222c4960f49e3795d7", size = 9813, upload-time = "2026-05-16T23:58:47.436Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/c9ed5f7e54889103de1754f53d8d294510d7e7f04c1075ec5dcaeee1562a/fnv_hash_fast-2.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:7a45f7050c4e99e2f04122508ec4efb568c93fcbece8482371a177ac89e6db8c", size = 10052, upload-time = "2026-05-16T23:58:48.47Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a3/a54bdc9a843e3d2988c8c35224ae51cdcffd8e55d9031039ccbf51b8e2f6/fnv_hash_fast-2.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:642bc26dbc906f86ee7a888cb66c149455c1a2a841a7ea72f19ffb969d0f0b14", size = 9085, upload-time = "2026-05-16T23:58:49.54Z" }, + { url = "https://files.pythonhosted.org/packages/75/45/bbb6e0741d945569ae13e91d774b613e06fb184841e25bfc3f188b98888f/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d63dcae2fd15c307f8a333f77aa6a5146538e9e9ef606b1883c8b5f06d5f86e8", size = 10910, upload-time = "2026-05-16T23:58:50.572Z" }, + { url = "https://files.pythonhosted.org/packages/37/84/e95767cbba632270b6d7f14108c800bd9883b3596b939c52c457b671b779/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a852d5c3278153378c04dcf5e45514477beeccd4268ceb775a3cf553dc5102b0", size = 12580, upload-time = "2026-05-16T23:58:51.691Z" }, + { url = "https://files.pythonhosted.org/packages/ef/37/51d20e8444eb87a6396762d0bc160c6698963753e3cff6f765f76a58dbec/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a805cc43c45e3452ff4a4b76e77fb40b452c795bf04d0f6f76c06fb7b02f755", size = 12409, upload-time = "2026-05-16T23:58:52.827Z" }, + { url = "https://files.pythonhosted.org/packages/28/37/0d6a033e957cc2976cc453db5271976107168ddf0a49361f4b4187a1fc47/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd413f1b171188cbf8ea6bb10f1e8c3aed3a88d239a682870e3727021b246700", size = 11380, upload-time = "2026-05-16T23:58:53.86Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/962407e8d475f8b502c5df7f8f9bcc4d73386628b76cf836f6a739d9982a/fnv_hash_fast-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:13f4284c5521df53b2fc54e2f595e8534fba03656ef3d92f099503980c09b15b", size = 14180, upload-time = "2026-05-16T23:58:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/84/19/e0b9e51d9f69efb6a6d155c2a5aa489f4366e34211ca2e843954c9053b44/fnv_hash_fast-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b4e44c9764bf0fa8387251bb806538976b0c641b89bfb6de3c8f242eb6bffdda", size = 14645, upload-time = "2026-05-16T23:58:56.033Z" }, +] + +[[package]] +name = "fnvhash" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/43/30d2dd2b14621b2004f658ba5335e5a6f5a9c1338ed37678d7fd247b7a9c/fnvhash-0.2.1.tar.gz", hash = "sha256:0c7e885f44c8f06de07f442befebc590ee9ca0cc88846681f608496284ce9cd5", size = 19057, upload-time = "2025-05-05T16:59:10.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/92/7c8abc21a1de7159013c0b0bd2ecf06530959bb14fd5c3bf0045e788c6d9/fnvhash-0.2.1-py3-none-any.whl", hash = "sha256:00fab14bec841e4cb29b4fd2ed9358f8bf9f4600d9d8149cde27a191193a33e8", size = 18115, upload-time = "2025-05-05T16:59:09.269Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, +] + +[[package]] +name = "grpcio" +version = "1.85.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/34/60907c03ef43039c1e91ed5bdfae41ada5648264f013cabc3f186600e571/grpcio-1.85.0.tar.gz", hash = "sha256:8aa2647afcbe5a87783e9626a2ece6277db33c6604c21690d19b5bd15609e36d", size = 14084753, upload-time = "2026-10-26T06:20:17.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/2f/8d037027d50fe4aecd23da96cea59faf194c86be7c8c7c77b5fff6079aa1/grpcio-1.85.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:86bd1d2e197d3e4cde1c04136db48bf22a6fe873b7f55c83e9747d5b36f0cbb4", size = 6802126, upload-time = "2026-10-26T06:19:32.382Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6e/cd0520477b161d728410c3bb37fcea46eb06aee9f9f3fb410050ba5eb62d/grpcio-1.85.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7a24e53259c2005c77b03c48272eec4b374eba027d077e84d0e99f9e30747439", size = 12639174, upload-time = "2026-10-26T06:19:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e2/95409a6366aa30d9224d1f6231702ff53fa3f6cb652cdca682295981e593/grpcio-1.85.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6248ed9a86c4191a0d8d7fe54eaea0511aadcadc2ec128daa995eaac91e42880", size = 7185955, upload-time = "2026-10-26T06:19:37.174Z" }, + { url = "https://files.pythonhosted.org/packages/38/f4/b55aaa568ce74f9aa01aeff90646dc223fb86085cf10da1d198df2c799c4/grpcio-1.85.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:c5f993fa6b3bf0af33ed7c2bc42f58d22f10964607cf614c2c08b67db80ced60", size = 8186857, upload-time = "2026-10-26T06:19:39.4Z" }, + { url = "https://files.pythonhosted.org/packages/03/45/04eec36d0dabc90a640b72084ca67aad34f24f312477cea803ad47faea52/grpcio-1.85.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d2ba969495b45ff5e0d10451898387427906b82e28c3c1cfae2dda006e25e18d", size = 7366839, upload-time = "2026-10-26T06:19:41.438Z" }, + { url = "https://files.pythonhosted.org/packages/58/b2/53bb7e2285b55f1208ae743102187e6d29168e1e48ab2146dce5f3a57d21/grpcio-1.85.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c91ad19d92ff81600c087db4829c26b47f9dafd04ed0a26c35e21a05d20f671", size = 7941256, upload-time = "2026-10-26T06:19:43.717Z" }, + { url = "https://files.pythonhosted.org/packages/af/00/62b62f26b2d56cb3b2f5a61a2b5d90fe9614bec12e322d127e536d59414a/grpcio-1.85.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:26147a7a3c9d1c865cc16b3f98e5c38e5606932aba236b7627ca7eb4378c5c7e", size = 9176088, upload-time = "2026-10-26T06:19:45.858Z" }, + { url = "https://files.pythonhosted.org/packages/b0/eb/33604ec3b625e0970fcdea07e69c30a9199264b24174e5eff7d79e8598ce/grpcio-1.85.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:beb6ffcf5253f4247efa47837d876e42157969127657fad4a68be181830ddadd", size = 8315128, upload-time = "2026-10-26T06:19:48.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/eb/1a4e1fedc1001ab756de206e37be8d20887607bd71d4a0be45afb461c615/grpcio-1.85.0-cp314-cp314-win32.whl", hash = "sha256:a03fee5e413938ddb71aa2fd7c52520e2fc3aec2e9602d50464272c75b274a54", size = 4853035, upload-time = "2026-10-26T06:19:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/7e/34/eca74515cd7e1c15312d3f77440dc5c48ed3421110188176b5e37226bc9f/grpcio-1.85.0-cp314-cp314-win_amd64.whl", hash = "sha256:a11c8c322a19d6d3cd6fb645127e8f1ca4e8041d05e41e9b24c7678ec7391021", size = 5758315, upload-time = "2026-10-26T06:19:52.659Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "habluetooth" +version = "7.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-interrupt" }, + { name = "bleak" }, + { name = "bleak-retry-connector" }, + { name = "bluetooth-adapters" }, + { name = "bluetooth-auto-recovery" }, + { name = "bluetooth-data-tools" }, + { name = "btsocket" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/19/5ea527f2d5e5f5cfbefa94bd5b2f0859af497e2bfccb19dd6e23deb258f2/habluetooth-7.1.2.tar.gz", hash = "sha256:8f48153ad7df1f423d7aa209f6a9a6940da8d17cc604abc67444517da3db0656", size = 128577, upload-time = "2026-09-22T14:08:28.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/4f/3e0afb33b26050a90d16194ac33abcd59b2d1a203e1a724f74c4028adfec/habluetooth-7.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c38ac7971d6cd762de476766fc7eec3c93de90fd966ead804d444b7ca4c14c47", size = 1045747, upload-time = "2026-09-22T14:25:20.402Z" }, + { url = "https://files.pythonhosted.org/packages/9b/54/3231dcae65513266fcd6acf7c2c3d584a848101eb2ece88af730d5f0063e/habluetooth-7.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:735146739b90a29a8fab86dfd4742899a859c31cdc4edcc0fff5c5d8f96778c8", size = 1178257, upload-time = "2026-09-22T14:25:22.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/e6/8a4d958a8c4ed8e735bb33fe065021904b3fcaecc9873aa4220119e23835/habluetooth-7.1.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c9307ff7c57d6d3b12861d05a52ee24a4bf6469971f0eeb3ec8c31d9131481b", size = 1137952, upload-time = "2026-09-22T14:25:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e9/cec4a67bef36532559116b2fdef4b6bed90eb7f196b75c07e076f305f6fe/habluetooth-7.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60adef8cb6f518a7fce8fb3545879b94f9edcec6fd001755e3d2f3164da7fc7e", size = 1249506, upload-time = "2026-09-22T14:25:26.467Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/bf6e95470a921341eb6e9489766b5f8e7a8c77ccf74319250eb947664d98/habluetooth-7.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e66edd97e180aad4f61247e0d707eef2a97721192a5bc67b0a6b07f9da59343", size = 1199898, upload-time = "2026-09-22T14:25:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/96/98/e556389f159d916561a2f26d63134bc86022698e72d30eba59bdd9381a42/habluetooth-7.1.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4195560de3786810f9579857b96f0d35c3edc06272b2c6a46608e6eb523eca2f", size = 1148454, upload-time = "2026-09-22T14:25:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c4/f5cab920a5df5b04a7088999c57dd694b8c1267bbe9ffca213c6110fd460/habluetooth-7.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c810bf108c26d62f1da5fb44242b574d03d76be9690f320d82474fa7a93bf840", size = 1263169, upload-time = "2026-09-22T14:25:31.836Z" }, + { url = "https://files.pythonhosted.org/packages/53/7b/fbba70f2b1f7ecf657df3804d00cd5e115633469bfd79e3c428d21454a05/habluetooth-7.1.2-cp314-cp314-win32.whl", hash = "sha256:1cd1f071828552945a7dd18824b2dff9fff019b5c3dd7e8f87529f817ae2d877", size = 825819, upload-time = "2026-09-22T14:25:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/99b933ee5efbcc3599ae259eca74b05380559fa2c15159b6364d13d5d0d3/habluetooth-7.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:fdaf26c6f184716eccdf35749bfd11116e54bb8fa2b99f1f1fd45a9c1b90593b", size = 952983, upload-time = "2026-09-22T14:25:36.104Z" }, + { url = "https://files.pythonhosted.org/packages/07/dc/031c573b153fdc51f2462653effe9b5d414412436f2493641d79c3f22e9e/habluetooth-7.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:89f4e9f559b772ee65f8051e7358267be61febdc795280c0242aa3d385961159", size = 2025552, upload-time = "2026-09-22T14:25:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/97/06/15a5db0cdb0b7d8944afac3e99f5f0c4f8a01be0d9b4555a08a101753d00/habluetooth-7.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d8870dbb64acdf8c941e7b6b71d94f398e5662d76535e9ae4951e111bed6fb", size = 2210162, upload-time = "2026-09-22T14:25:40.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/d2ce387dc0ffcdfb8e2671f525ac1ab478e3cf69710f97621df53a3f6715/habluetooth-7.1.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d2186cfdf8612cd4bc0b80bf8afa2b2de23bafe67297440fdc285b6055d4194c", size = 1089335, upload-time = "2026-09-22T14:25:42.015Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6f/731d1a7e6b0582f41e52455c4c43d3be9d4aa3d0e548c0c636e62bed526b/habluetooth-7.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b780a089209a74fbea666cf315098c28d2624fc61b0abe025f960fdde2f9463c", size = 2331598, upload-time = "2026-09-22T14:25:43.857Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cf/486d7ca4bb2ad6bbda1526bab575fea06a150e0d3e8dd34dfd07d3d8b844/habluetooth-7.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bb9a8bb0a4157a7d4000e3f7d100b9e34d08ca9905712c5f7849cebf81a0f03", size = 2256183, upload-time = "2026-09-22T14:25:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/2cd4d28c7ca48f5782ead679ecb9295b366d5e9f47638a50197c4f3f75d0/habluetooth-7.1.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f187f938f81b38fb33abe8b20ab27a6ae911f2a701af78da60ac96a9bf2b6792", size = 1131508, upload-time = "2026-09-22T14:25:48.356Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5e169cd27d284fd1e7fc21296e08c06f6c038f63dcbe5fdf6aee10c943b2/habluetooth-7.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20c674486d1e4f2a87392988081a6bbe13b0d663d26ef5623020e8cdcc733bab", size = 2359538, upload-time = "2026-09-22T14:25:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/67/77/ffdeff54a345dd173163455b2f0d5c308509e871e6d02d65c7e92be90cf6/habluetooth-7.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b34d8ddb120d36ac216d22d224d7b8a3a25e879401d4a1162a1dc6d10e08f26", size = 1584030, upload-time = "2026-09-22T14:25:52.739Z" }, + { url = "https://files.pythonhosted.org/packages/02/7b/e251ce9b14ab88506d97afed3c24af9955868ec39215ad3cd99ee7ef3697/habluetooth-7.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:71c5098dfddd4693e5d1fa7e3f77844844492adfc6da094f23ce355167f1e594", size = 1843124, upload-time = "2026-09-22T14:25:55.595Z" }, +] + +[[package]] +name = "hass-nabucasa" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "acme" }, + { name = "aiohttp" }, + { name = "atomicwrites-homeassistant" }, + { name = "attrs" }, + { name = "ciso8601" }, + { name = "cryptography" }, + { name = "grpcio" }, + { name = "icmplib" }, + { name = "josepy" }, + { name = "pycognito" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "sentence-stream" }, + { name = "snitun" }, + { name = "voluptuous" }, + { name = "webrtc-models" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/e38702be5091a6a6a8bbffcb193e91ad86a3a47382c611ad42f743f32a5f/hass_nabucasa-2.7.0.tar.gz", hash = "sha256:fb741a2b6447250e5fc6ff07c09184e3afaf8b1d0028833e58b6a652b26965e3", size = 133280, upload-time = "2026-08-27T13:19:56.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/04/810a2557a51f4c9563fdf07ab77ab6d5d2a61c3bcc0c12ff0aa4388b8f0b/hass_nabucasa-2.7.0-py3-none-any.whl", hash = "sha256:a07f0dba540e53204363c1ad762967c715ca7bcebc175fa2a50a4d8721c7ce43", size = 98072, upload-time = "2026-08-27T13:19:55.189Z" }, +] + +[[package]] +name = "home-assistant-bluetooth" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "habluetooth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6f/914fd8086f502748907751766e26e3f7fe5204347a679703ee9954e55e4d/home_assistant_bluetooth-2.0.0.tar.gz", hash = "sha256:3febd16b194812b156024d9bc8ce8ec7622cdd8d159047cac504cc77ca034ea1", size = 7604, upload-time = "2025-07-09T18:01:47.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/3e/2ed9e59da8fc26c43cbd9d6abbd27217138a31c608b1d334cc5e680e4069/home_assistant_bluetooth-2.0.0-py3-none-any.whl", hash = "sha256:8d7c2ac48c303adacad73cb6cdfe7710f2fccd2a367d4c3106acf301b0d22b91", size = 7907, upload-time = "2025-07-09T18:01:46.506Z" }, +] + +[[package]] +name = "homeassistant" +version = "2026.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiodns" }, + { name = "aiogithubapi" }, + { name = "aiohttp" }, + { name = "aiohttp-asyncmdnsresolver" }, + { name = "aiohttp-cors" }, + { name = "aiohttp-fast-zlib" }, + { name = "aiozoneinfo" }, + { name = "annotatedyaml" }, + { name = "astral" }, + { name = "async-interrupt" }, + { name = "atomicwrites-homeassistant" }, + { name = "attrs" }, + { name = "audioop-lts" }, + { name = "awesomeversion" }, + { name = "bcrypt" }, + { name = "certifi" }, + { name = "ciso8601" }, + { name = "cronsim" }, + { name = "cryptography" }, + { name = "fnv-hash-fast" }, + { name = "hass-nabucasa" }, + { name = "home-assistant-bluetooth" }, + { name = "httpx" }, + { name = "ifaddr" }, + { name = "jinja2" }, + { name = "lru-dict" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "probatio" }, + { name = "propcache" }, + { name = "psutil-home-assistant" }, + { name = "pyjwt" }, + { name = "pyopenssl" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "securetar" }, + { name = "sqlalchemy" }, + { name = "standard-aifc" }, + { name = "standard-telnetlib" }, + { name = "typing-extensions" }, + { name = "ulid-transform" }, + { name = "urllib3" }, + { name = "uv" }, + { name = "webrtc-models" }, + { name = "yarl" }, + { name = "zeroconf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/b1/d77d6ecc16eaf90f7913516293cdb00f354e305a126ab185da0f0dbf916a/homeassistant-2026.10.4.tar.gz", hash = "sha256:8410fecb434ab94b3f7724cd80584050f0ceeb856c16ee70202c24037d62238a", size = 38675928, upload-time = "2026-10-24T20:32:27.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/5c/5eab85bd8b1bcf059f9fd530ef3244af364b571026cb89919de3a0cccb1b/homeassistant-2026.10.4-py3-none-any.whl", hash = "sha256:71843060980a763187d856a8ff170640f1e287a7b741334cd838c81404f1f452", size = 62697538, upload-time = "2026-10-24T20:32:19.662Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "icmplib" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/78/ca07444be85ec718d4a7617f43fdb5b4eaae40bc15a04a5c888b64f3e35f/icmplib-3.0.4.tar.gz", hash = "sha256:57868f2cdb011418c0e1d5586b16d1fabd206569fe9652654c27b6b2d6a316de", size = 26744, upload-time = "2023-10-10T17:05:12.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ab/a47a2fdcf930e986914c642242ce2823753d7b08fda485f52323132f1240/icmplib-3.0.4-py3-none-any.whl", hash = "sha256:336b75c6c23c5ce99ddec33f718fab09661f6ad698e35b6f1fc7cc0ecf809398", size = 30561, upload-time = "2023-10-10T17:05:10.092Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "josepy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" }, +] + +[[package]] +name = "lru-dict" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/0a/dec86efe38b350314c49a8d39ef01ba7cf8bbbef1d177646320eedea7159/lru_dict-1.4.1.tar.gz", hash = "sha256:cc518ff2d38cc7a8ab56f9a6ae557f91e2e1524b57ed8e598e97f45a2bd708fc", size = 13439, upload-time = "2025-11-02T10:02:13.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/02/8e04a8d744b466d4153502e2d92b453c2e5a549d49bf7fabfdca1621828a/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b770c7db258625e57b6ea8e2e0503ba0fbbdcde374baacf9adb256eb9c5adfa", size = 11119, upload-time = "2025-11-02T10:01:36.239Z" }, + { url = "https://files.pythonhosted.org/packages/50/f8/ee96f30127ff47c29966603f040e0485700fe0ca0e7d7b1ecbc9bf999eea/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:45d4dc338237cedcbacedab1afd9707b8f9867d8b601ec04e0395ec73f57405c", size = 11435, upload-time = "2025-11-02T10:01:36.857Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/897b33ba1974b6487848cafa5de7e93a7c4f5d9d3f43319ee010f6882830/lru_dict-1.4.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:5b31e9b6636f8945ad69c630c1891d810d62a91d99e792ef0b9ca865b6c26745", size = 10988, upload-time = "2025-11-02T10:01:37.531Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/b87d0f2bfcad0169afc00e23e014bad9af252206ec2cbc6079f12bece58e/lru_dict-1.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f9335d46c83882a1b5deffed8098a2dd9ad66d2bd6263f416fc4c73f63e26904", size = 16733, upload-time = "2025-11-02T10:01:38.194Z" }, + { url = "https://files.pythonhosted.org/packages/ab/19/d2384266864b1e5b1cc20527ae468550d3b23a71636371b40e4663276294/lru_dict-1.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:17844b4f8dd996144d53380395d73832e2508159ad49ed4fbcb62f1787a5feaf", size = 11222, upload-time = "2025-11-02T10:01:39.093Z" }, + { url = "https://files.pythonhosted.org/packages/66/8a/94dec42ae6b5c8bdc53a86867924fa22634516434f129dca187ccc0853b8/lru_dict-1.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b569c7813adb753b7b631097c34e6dbc194cb1814f22299c2d2a94894779877", size = 11733, upload-time = "2025-11-02T10:01:39.739Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/0b6de1db804cf094e201c5541d58e6a96359eb5beed048fa64d0589b6520/lru_dict-1.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:33cf1eb368d3989b8f00945937cfbfc2095d8ad2b1d2274ce1bde0af6f6d1e66", size = 32251, upload-time = "2025-11-02T10:01:40.421Z" }, + { url = "https://files.pythonhosted.org/packages/97/38/89d9425dde436b9bd894234171988289b259aeeab5965bd2c21d5104cb41/lru_dict-1.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5879ec5d5955f9dde105997bdf7ec9e0522bf99612a80b55b09f356a08368", size = 33405, upload-time = "2025-11-02T10:01:41.917Z" }, + { url = "https://files.pythonhosted.org/packages/0e/24/f1a189399ee107a64f955c9d6c84d3b0aee9b64b31fc5684b1eaeb3a6fc0/lru_dict-1.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2084363e4488aa5b4f8b26bd3cc148d70a15be92e3d347621a5b830b2b1e0a82", size = 35135, upload-time = "2025-11-02T10:01:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/58e9dcf0853d639e2995e5d9f84649ff8d6792a04a418628672a130137f4/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8198ab8ad7cc81b86340243ddd5cca882ead87daed0c9fa6cce377a10a7f2e47", size = 32620, upload-time = "2025-11-02T10:01:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bb/664922f0cf076b1e3c2e43e8258582d507b07c19bd441a72dd5547a483e9/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1f4ae6967d5873e684ce8b986e2e43985d0a1be735b09584737ad5634ff48f3", size = 34077, upload-time = "2025-11-02T10:01:44.694Z" }, + { url = "https://files.pythonhosted.org/packages/58/38/b7a6fa85b150232cada26a50c89dc4bcf9acd6ada00e987b074c3b4e57f2/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a9bb130b5eaddd6453ca3dc38ce4a75f743512ad135b6f3994999dde0680bd79", size = 31905, upload-time = "2025-11-02T10:01:45.668Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/9c86393946d621f4aec852d543df4023241d85106e9e1e2a0e4057861f71/lru_dict-1.4.1-cp314-cp314-win32.whl", hash = "sha256:5534c69a52add5757714456d08ce3831d36b86c98972394ba900493bb0bd97f8", size = 13435, upload-time = "2025-11-02T10:01:46.397Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/b017cbeea55a8a1d18037840a8a9c9cdae29554e9985b55d4e8694305035/lru_dict-1.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:96fd677b6d912229f2d02ba61a5a1210176963c4770c1bb765b8da937cec3834", size = 14423, upload-time = "2025-11-02T10:01:47.473Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/13132af7a5155edde66979b53eb509465304e6e5a2b00769246448479c73/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6699bfebbf11dd9ff1387be7996fac6d1009fe6a6f48091ef6e069e6f19c7bce", size = 17184, upload-time = "2025-11-02T10:01:48.161Z" }, + { url = "https://files.pythonhosted.org/packages/ef/82/094985beb3e49461bf65a3c40df8de2018b8484e4ef129295090460ca5d9/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a276f8f6f43861c3f05986824741d00e3133a973c3396598375310129535382d", size = 11459, upload-time = "2025-11-02T10:01:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/14/29/836abc49f8c2b6c2efccd2ac2b2c0ad3e55b7d75a05a20cc061f17871e39/lru_dict-1.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:090c7b6a3d54fa7f3d69ba4802abe2f33c9583b16b33f52bcb521c701f7ea46c", size = 11938, upload-time = "2025-11-02T10:01:49.448Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/1bb4e8fbc0b753fea825564d9d96180813a71715d46a9b6bb30a6dea4ce0/lru_dict-1.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b21d06dec64fb1952385262d9fcefaec147921dc0b55210007091a79da440d93", size = 36389, upload-time = "2025-11-02T10:01:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/54/f2/7df3b6d0dbc66f3be9aa6261750967cdc5619c89a563420c52200d2dd547/lru_dict-1.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9613908a38cf8aa47f6c138ba031a8ac4ed38460299e84a2b07dba7b3b45aae", size = 38706, upload-time = "2025-11-02T10:01:51.197Z" }, + { url = "https://files.pythonhosted.org/packages/97/5f/e3ba3eeb9b864a09b92e24fbf179aef4ef48588e763a9d8d2bc10bd2c6f8/lru_dict-1.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7558302ce8bbfcd29f08e695e07bf7a0d799c2979636d6a6a0b4e207f840969f", size = 38892, upload-time = "2025-11-02T10:01:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e3/12e0888aab0bf3ab9ce35e9849f239994a0feff6fe49380859bf57124a17/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3910396142322fb2718546115bb2a56f50ebc9144b5140327053cca084e0d375", size = 37254, upload-time = "2025-11-02T10:01:52.587Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/06cd981718d039eb07a9c03263094aca6269721c470f765a292b24381a20/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f3f4fad5c4a9458954b275de6a6e31c67a26fbef7037c6a7354e22523a77db26", size = 37422, upload-time = "2025-11-02T10:01:53.271Z" }, + { url = "https://files.pythonhosted.org/packages/e8/82/ea88e618f39d78ff3c15b71f01a0b1a6c6ac2034ce5c6428ae41b1c30ea5/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85fc29363e2d3ba0a5f87b5e17f54b1078aea6d24c6dfc792725854b9d0f8d17", size = 35909, upload-time = "2025-11-02T10:01:54.356Z" }, + { url = "https://files.pythonhosted.org/packages/89/36/1dd91c602f623839cec24d6c77fa3fd1a8878bf2d716871197cd3bf084dc/lru_dict-1.4.1-cp314-cp314t-win32.whl", hash = "sha256:b3853518dfa50f28af0d6e2dcf8bb8b0a1687c5f4eb913c0b35b0da5c6d276ce", size = 13816, upload-time = "2025-11-02T10:01:55.417Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/113410f7b2e61e9d6f13f1f17c584dbd08b5796e65d772ecd5b063fab3af/lru_dict-1.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff3af42922205620fdc920dcdf580c4c16b32c84a537a03b04b523e5c641a8a9", size = 15204, upload-time = "2025-11-02T10:01:56.06Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, +] + +[[package]] +name = "mashumaro" +version = "3.23" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/18/6383ce957a0551d8704936ffef1ecf0b0c620543999a44f9adf6c5f30888/mashumaro-3.23.tar.gz", hash = "sha256:ed69d5bc548c76efbb5286cc0e541a6b8796e621385f986e25b750364011abf5", size = 141901, upload-time = "2026-09-30T20:21:40.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/2c5e2b0df54c9c979d45bfa4820687ab24797fe1ad42d4fdfc0c8ece59f1/mashumaro-3.23-py3-none-any.whl", hash = "sha256:a671279ce93119d41c54adcd767753ae68adb32b9473de2f9d5d1720d2376231", size = 78621, upload-time = "2026-09-30T20:21:39.403Z" }, +] + +[[package]] +name = "multidict" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6b/29ef8e752f5854dcc267ded0652c475bec99b86f255957a0d4fcad657b18/multidict-7.2.1.tar.gz", hash = "sha256:e8af3e6f1bdbc31fd762b094133b478b89988eaebc63cbe82b1d9e3bbd15cede", size = 369994, upload-time = "2026-10-24T20:41:35.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/0c/2eff3c4f36f45eddf11fb10b35069d9ebcef751b16483a585527061354ac/multidict-7.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:3d22ebb716ac79bf32c2a1d15250f017faef719114af4376e8dd814d6633cf0a", size = 102695, upload-time = "2026-10-24T20:41:04.31Z" }, + { url = "https://files.pythonhosted.org/packages/e2/46/4b8667914db791922e826a847f80324cb1f0acba9b05343fcb53c5a237f9/multidict-7.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1d369ed297e79b417766327ece16bb7e89306c588df63c364a85d90d4aeac843", size = 101581, upload-time = "2026-10-24T20:40:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/5a/40/80c50aa45b607086d43ff3d467c25450b85c7d9bb7e4bbcd521c3b030be9/multidict-7.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a9cc121b5ee2c8b4bd4b6b526d5a26bb782f4d620463f73707691b2004b8c349", size = 103677, upload-time = "2026-10-24T20:40:28.633Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e0/c822cf8d92f33f0d6fc0f733b6f5b287956133024765f31b7151b68f6198/multidict-7.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:02009b402129a73e1f3da68f48eb999afbacd29f25aef0d5052b84ff5c2786a2", size = 172007, upload-time = "2026-10-24T20:40:29.243Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d8/b36e8bbc49a59f38e6411bdf96e07c29462460d77e59527309d72469b1f8/multidict-7.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cb7fc8820b206932ec6d8f8985aeba6edfd2932e13b6c4bf2434d6bcbbeef3e1", size = 97224, upload-time = "2026-10-24T20:41:05.627Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/c014da364e2f9707945f921864a6b5c615e88a0ec0518c7999b3b2092405/multidict-7.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cadd02ca804d6ebe799a7af19197d9730094fe2cc74b70eb4f374f4b8c1886c1", size = 103824, upload-time = "2026-10-24T20:40:28.61Z" }, + { url = "https://files.pythonhosted.org/packages/c9/73/bcd1fff09ba644499fcd864e6f86a596e51e90f5504c326354bdba3bb8a0/multidict-7.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:846b17193f1c373d6f05ada894fadf56bce2473067caea0aab51828849894b31", size = 486848, upload-time = "2026-10-24T20:40:29.976Z" }, + { url = "https://files.pythonhosted.org/packages/52/12/d6731ea19a185f76a1dc6b76f239fa3567732c8575188b47f7a715742c21/multidict-7.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6efdeed2723431999cf3bcbc84c72dbe0f65d16bb1f84e15a657f5b60ff18ca6", size = 500914, upload-time = "2026-10-24T20:40:30.626Z" }, + { url = "https://files.pythonhosted.org/packages/71/db/332a5b62d4c2c26950aede813a0da97e42cbc16b656ac936eb2c6e01f58f/multidict-7.2.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9c118e866164ce05498d9106a4bd01e745321403cd6f0b77a0b2587768f8c06", size = 462069, upload-time = "2026-10-24T20:41:06.892Z" }, + { url = "https://files.pythonhosted.org/packages/64/8d/c8c6826dc3e9b742bd21f3e84e1d8c46daffb584f45fd93429541e5e7f11/multidict-7.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc6f3dcb08c4b41e64f2d6cf272b5686d9144db585b2e138a8b1664376ab3921", size = 515356, upload-time = "2026-10-24T20:40:29.998Z" }, + { url = "https://files.pythonhosted.org/packages/92/c8/c13ffe0d352c4caaa17da0a74e9203c718606c62a1d62f98439feb1783d5/multidict-7.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:27cc258247b13008742f0e239933ad01dec5ed4e6dfc63849e30e04e1aa5bc82", size = 539793, upload-time = "2026-10-24T20:40:31.55Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/bff861e3bd5a7cc13bd1f040d22aee8aa32b4f2755bba29b0c31a1d2ae25/multidict-7.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:822a7fcd33f6922b650e1b26594f09d53c5d011fe655fea7ded27b2728ce2a1b", size = 506944, upload-time = "2026-10-24T20:40:32.258Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2f/f40fc4a7070798e31630ae0be583e0857fcab71a1bf80c37481ded49c553/multidict-7.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f30d6eaf3cb309b6c21096c6c2b4cf3800f195a78b1babae85f481ce851bdc2", size = 443550, upload-time = "2026-10-24T20:41:08.334Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7e/068b48b20c1df58dddb7885f5e1befd6def91c7b6048f2644ccd81f57fa9/multidict-7.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edffdb3950d3a8b769ddaec3a0962fb7b76b3c96f317f6b88500455a537dded2", size = 486514, upload-time = "2026-10-24T20:40:31.371Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0d/a2f8b1fdd7cc2f497bb2fb0cc59dc7e2ced1fa4b0cd9b2912973e384ea77/multidict-7.2.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8c3ff4f5c376df7126695a52af61e6084b69c9e52dcb9f4713e71ce9819e1bfd", size = 459258, upload-time = "2026-10-24T20:40:32.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/aa/74652a34732f16a951fce9ca118e659f1a9dbe102ee52d9bcea4a8001dad/multidict-7.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:d5738cd929f2af8651ebb284467acb58b1ec0f2f4d42489cd0597172bc82e15b", size = 487418, upload-time = "2026-10-24T20:40:33.649Z" }, + { url = "https://files.pythonhosted.org/packages/dd/91/ae90df6402911d310cee3f44a73914ea555d5219b1976a8f2842dc247eac/multidict-7.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:81a5f84a76401c4330e6db4e170dddd58860b30c4e8a44a181e5c2809c7f9c9d", size = 502308, upload-time = "2026-10-24T20:41:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f3/5a1ab9f594e652bece00f1243bf565063790838250054ef9310999c856f7/multidict-7.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:efe695cc2081633b02fcb95bc1b13070f6647c9ad52d42a8beeb3c2a7ea062dc", size = 439169, upload-time = "2026-10-24T20:40:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/38/a4/8c4fd4ecbea568de3b4605dc7323488f65a204475e1aac98db9d36fdbfd3/multidict-7.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0c52fdd8cb8194fcb6969a417149aa1d09d6e48b69fb1b34ee1b1e4cb51ec183", size = 519617, upload-time = "2026-10-24T20:40:34.488Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b5/4964b2d8f8b4ff03df7f24e8dc7d0de534fad0430e553ff0693aa2bcfb75/multidict-7.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:02d9c04d395a36eeebe89e45f2269614a4675b8e83fddcba15bba2be903971e5", size = 499402, upload-time = "2026-10-24T20:40:35.035Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f2/c7d5c514e066fc6f9b9a2a9583f46a3d8178c49b7e91cadcc59241f22c12/multidict-7.2.1-cp314-cp314-win32.whl", hash = "sha256:b97bffa5772b7e853303de094829fcd3a9bd49fddd70534651ee4933691f6587", size = 83635, upload-time = "2026-10-24T20:41:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/08/8f/2ec03cddf5f283f979813f8c5d38f5df798e21cb07a124dfc311ed28d8e9/multidict-7.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6e22756c6e2d4f57ead0d2e6be179d8a16d661bee424b81de0c6c0c1305ec39f", size = 94544, upload-time = "2026-10-24T20:40:34.202Z" }, + { url = "https://files.pythonhosted.org/packages/2e/75/3e9803944df2233cd43924c04ef057160a75c35e87f4fc083de7c855ab74/multidict-7.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b63acdf4df5a78a15223bbb510bb3e0f8234dfa807610ccb8796d0842db2c801", size = 92767, upload-time = "2026-10-24T20:40:35.787Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/e3a4b4de34cde67f98887ac0c134e2436e1eafdcef62e81f9f8b7ac8e189/multidict-7.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:15da10cf11fd8d8716720c95b93180e94c8317405d89d08524c579ef360128ac", size = 183485, upload-time = "2026-10-24T20:40:36.397Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/c5aa68302159285e544c7f1b687a48d2b000217b9a90aff880052d6cb867/multidict-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c753b820738cddc097892fee95e7cd716fbdfc9640414c5e9ce9463ad125321b", size = 103109, upload-time = "2026-10-24T20:41:12.496Z" }, + { url = "https://files.pythonhosted.org/packages/54/b5/7574cad64ee957cb65a32ac4f842663fbe948c1749f688a93eef0677644c/multidict-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69befda4b1ef57027ea95dc6ed733568642f330fb3203a3e5c5ef107d41e0a1f", size = 109785, upload-time = "2026-10-24T20:40:35.496Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/ced76f7d87cce5ed15ae404213c7f9fdc594d0caaedbbacc6b8cdfac81e6/multidict-7.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8648dd5dc4d5db32b5936dc5e85e9a4d79db1135513d40d1f54ca15503750324", size = 511246, upload-time = "2026-10-24T20:40:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/b598741d494865ed002df52f37f9e92bf9ab5a0524098a3f7667c1bd6ac6/multidict-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5efddcdbcb73f5485dca2b6624edc8ee063604b77bd3a3eb772826063965819f", size = 545199, upload-time = "2026-10-24T20:40:37.773Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/2e3c15a07da380a7845e7d0969fde25cd6fca078a09bf30cf6deb06a3480/multidict-7.2.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9c44bd978adddf39df5834c6a1efd7d075c84769a7a084168d035a2a276d9008", size = 489912, upload-time = "2026-10-24T20:41:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/f6e2668e393430240da34a13503c190493dac8bef352ce7e26e831dc7949/multidict-7.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff7657e79224dea46f2f2e58eb99186656ca63ebe356212210c5c00e9beb814", size = 553445, upload-time = "2026-10-24T20:40:36.916Z" }, + { url = "https://files.pythonhosted.org/packages/05/ca/d7e0605545eebc8053167683fc6aa0d49114df2e75a2c474f2b65f8f083a/multidict-7.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7fb3bc8d58e1b7c1d9fa1d995e30a4ce9dbe49f4c63c755a1ffdecdfbd026ccb", size = 573757, upload-time = "2026-10-24T20:40:38.554Z" }, + { url = "https://files.pythonhosted.org/packages/1b/de/9a74903ffdbe3712ba392166935c91785ccdff82dc4b6a15b28a4df52a14/multidict-7.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66af55b07e4593790157e85f897f06983ded8a8692cb2fb048f3049c1d3fa172", size = 543116, upload-time = "2026-10-24T20:40:39.052Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9d/8eb3ee4465610cd599b08b3320262a3448dcd03299565f4c725ee1504bbc/multidict-7.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed6c107642a9acaa7ede32dcd428a6c87e302c5d555ecd3ec132ffa8c6155b4b", size = 478536, upload-time = "2026-10-24T20:41:15.442Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/346308a741f2c06b23da186022fd72a75b9923f4fe7c88705605e20506d2/multidict-7.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc9decb36abd00ed5d123db9df70eb9a0da45d6376d86d64f4a94bfd5a1b83fe", size = 526429, upload-time = "2026-10-24T20:40:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/eb/10/96986b2404bba1bdb54e364e682a2a9fc97a93c9f6598a2c35c065cbe28f/multidict-7.2.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5a7df927c7999d1d3196176a8d4139d3a0e1765936f5de8c768097275b2d617a", size = 488886, upload-time = "2026-10-24T20:40:39.949Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5b/59b11e1e2f93d39b34c554bfe3beb35777aa976c35af078f23eda41fa809/multidict-7.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4eb2650aa240b62ab0c6d87238bee5d14aa4d411c0d69ad61be53e026e0785ba", size = 510875, upload-time = "2026-10-24T20:40:40.484Z" }, + { url = "https://files.pythonhosted.org/packages/83/af/79e0036cc9ecc160235f3bcbd337524cb44ebd266cfc77e7532d9bd06850/multidict-7.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:83fbd7cfaca614975efdbdcd9e2df45f3e987e819216468fb73e1db6f0365b98", size = 539190, upload-time = "2026-10-24T20:41:17.519Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b5/342db062f9ae8816c1b940360c251dff21afeb88a9c4b1694c2b3348f2db/multidict-7.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a878fef2797a7c082aa47559af68299372edebc7367bd5128dff2d36e6d820e1", size = 474879, upload-time = "2026-10-24T20:40:39.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/1420f7daae59b331f5506e5b32a79f60fa475e636f53bf4e6cacad5782ec/multidict-7.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f332b16911ffc668baec6aaf8f08a09e1af34a8ee87ccac26bd722589e264645", size = 549769, upload-time = "2026-10-24T20:40:41.241Z" }, + { url = "https://files.pythonhosted.org/packages/ce/af/c4b548057bee365137be7b1ed12cb6339b6911ad95d99a83d4b26e9bcb2a/multidict-7.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:81c6fc88ac9bf6920a0cdf22d3c383964167f12d35eb630463fc6d5ac89e460d", size = 535840, upload-time = "2026-10-24T20:40:41.927Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/70710e4bc50b29ced1ed69ec3a99124c56ac34747c6a64b05611f7b5856a/multidict-7.2.1-cp314-cp314t-win32.whl", hash = "sha256:0c7b4d8cc9e6ae7070b8129bdb840215552d4e2689ca765d37eb208a24919209", size = 90136, upload-time = "2026-10-24T20:41:18.854Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cb/a330b1474608eef92e6a0a5861d61b8f2d3152d1a71b65dad8b56edea150/multidict-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b5fb6b048931ea925b484f0c7c01b007981415a00a6c62ee74bbf8d229d39c08", size = 101163, upload-time = "2026-10-24T20:40:41.847Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/1563cd1336111755f79a60d06ffecd11dfbce5eb8c32ece9eae888d5fbef/multidict-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e64b86d16158954825d12799271979c6944c1fd1d03575b2f22e869c3f7d00dc", size = 98630, upload-time = "2026-10-24T20:40:42.671Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/1aaad0e5df33f1f5af21775c411349c0234a6edd581ea403bc81919a6eab/multidict-7.2.1-py3-none-any.whl", hash = "sha256:08ea94663d9e7c25d14d78222f7aee906c472d4837766bec62477c38f6e419dc", size = 28145, upload-time = "2026-10-24T20:40:59.159Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, +] + +[[package]] +name = "probatio" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/0d/866a5d14b2deb3a9e93121ec8caeae0f3883cda575218da1a6b46dd6223f/probatio-0.13.0.tar.gz", hash = "sha256:b5941dfdb68d813fbe49551d8802b241c4774f756fe51dfdedbad33e27797373", size = 405374, upload-time = "2026-10-02T09:57:43.616Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/ab/8d3eb6004bec5ec8938baac7b251eed8954a5cdbfe9e342072634494a631/probatio-0.13.0-py3-none-any.whl", hash = "sha256:b592d11c89b536fa42617c3f23fb8798aeb659da2113cc2ec1ff8759f8a34d8a", size = 238295, upload-time = "2026-10-02T09:57:40.897Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psutil-home-assistant" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/4f/32a51f53d645044740d0513a6a029d782b35bdc51a55ea171ce85034f5b7/psutil-home-assistant-0.0.1.tar.gz", hash = "sha256:ebe4f3a98d76d93a3140da2823e9ef59ca50a59761fdc453b30b4407c4c1bdb8", size = 6045, upload-time = "2022-08-25T14:28:39.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/48/8a0acb683d1fee78b966b15e78143b673154abb921061515254fb573aacd/psutil_home_assistant-0.0.1-py3-none-any.whl", hash = "sha256:35a782e93e23db845fc4a57b05df9c52c2d5c24f5b233bd63b01bae4efae3c41", size = 6300, upload-time = "2022-08-25T14:28:38.083Z" }, +] + +[[package]] +name = "pycares" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/4df974f2f902fdcec81087773f37eaedc7c8308291fb3f15da7ff21a5b88/pycares-5.2.1.tar.gz", hash = "sha256:3e89edc56a3745046676207958bf3dbf2971c5df0d64de9f7a893eba8bf255b4", size = 723791, upload-time = "2026-10-20T09:08:33.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/a3/7786ecbd5c5937e1942c06c6f42b19c4633abd4540bdad0e0eb066423c22/pycares-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:43c912dd17e2f7770a1f51fc7fdc461939fd14d09945736cc4e8ab7586cfc311", size = 140193, upload-time = "2026-10-20T09:07:29.901Z" }, + { url = "https://files.pythonhosted.org/packages/88/6b/776fee25ad29e1a8661fd46c37be1f7c2eebca6708943db99208908f3df8/pycares-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:120520916b98c88c98e6deb870680f96bad7d94ce70bc31fd19db9478b211161", size = 135092, upload-time = "2026-10-20T09:07:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/a8/70/d6f7e0c76200928e5b4f690133c11ad441713eafaf5e9923b42815ee819a/pycares-5.2.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8948802dba6c7823e29d8a73d6ea960d29ecead2da858a4bd5ca478017a476e", size = 226208, upload-time = "2026-10-20T09:07:32.411Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/bcf22b717601ad7ac151dfe99ed775591718d67f05807ddf0693e22617bc/pycares-5.2.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:401dcc74d9b1265b3c53e9847f4d86b5a521b1e3ebdb77c23a228cfb1172c2f9", size = 258445, upload-time = "2026-10-20T09:07:33.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3f/567e1649138bdcacc0f4da4ae7ebf2d04252b1ffec04c2d4fe4703c64b39/pycares-5.2.1-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9cefd860313c2ef7ec8ef9327492f727340de0ad2d14ad4c119a746986e07469", size = 245005, upload-time = "2026-10-20T09:07:34.666Z" }, + { url = "https://files.pythonhosted.org/packages/98/51/70c9a092322a84ef01b30911e7cd5eb00e7e686d43774b51633f439f261c/pycares-5.2.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d511c94e22c92788ede11ea4e5fbb4ec3e40583fcfc59636b4a61524ddcbf84", size = 229309, upload-time = "2026-10-20T09:07:35.714Z" }, + { url = "https://files.pythonhosted.org/packages/06/d0/8b4f6f4050b04552520d472e51c80940e7c356b972d923c94acde29c2bcd/pycares-5.2.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c1d17ad95afb6ed88940dbb1d042db0bb45b70e0c056fdfdb6a10dfd4b98d61", size = 226180, upload-time = "2026-10-20T09:07:36.736Z" }, + { url = "https://files.pythonhosted.org/packages/18/ac/7f83c2d61bf7fe2f866cedf96ae052924f0cd55b6713ad7a87fb22729b31/pycares-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3d6bd9b382093b67cfcdb9a430b58279b7593d958eea851f44a768fbb1cdc4df", size = 229236, upload-time = "2026-10-20T09:07:37.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/102015fc44c91c63d83e54eba9f9eb77e2b0c0ec9b79d37db5919ce923b5/pycares-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:433c46e04e16040a927ac9f2f1787c3fd25c7bc9d71e35b4ed9fa126c4dbbb46", size = 259264, upload-time = "2026-10-20T09:07:39.033Z" }, + { url = "https://files.pythonhosted.org/packages/9b/27/55cd5e309b5420f285a6a98326ee3d05df05906405ac2f361e324af4db1a/pycares-5.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e2b896888722b32ac3ab6625a49c759c4a6cce8c4e2476ef07fcaba153116dcd", size = 226401, upload-time = "2026-10-20T09:07:40.067Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/26f187e2db05d1687c9be1cf08feaa035aba0dd3d39c217404f71fc04ba6/pycares-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:7dcf05eee2585c09d8bf781ab62e453e53fd5f5f86e6b6969a73e7792297893f", size = 243822, upload-time = "2026-10-20T09:07:41.201Z" }, + { url = "https://files.pythonhosted.org/packages/c6/be/eeb8b3f4851a0b74352dec65e2f51ae960e5f7a783adf01e9d9c1df2d90d/pycares-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e57b72e8da27854a8f59f5bf4548c6167d0249f13f93b599bc7df8167f667c8c", size = 229909, upload-time = "2026-10-20T09:07:42.278Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ab/67d56d9681785cbaa49e7676bbb12a373b1382440d7eb3441d6517cb1e94/pycares-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:4a465e7e87bfdeb12cbb3d3a2def6311be728a61929a961162264d308ff8f8fc", size = 123351, upload-time = "2026-10-20T09:07:43.341Z" }, + { url = "https://files.pythonhosted.org/packages/8e/53/e584d4c05f63473e929d6774254e2e7b5529a01aa9625bb8603760da0623/pycares-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3db315688e0042b47c97a0752adebbd2e31799f51c6da034c34a5a1b8e2d52a3", size = 119638, upload-time = "2026-10-20T09:07:44.351Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/ec944fbcd9d130b377753fc4d3721481f617e524ceb588736f597f15599b/pycares-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:696b9162aa9b5bf5910feaa1ca860b27facdbc146ed628caa21b7e75d21e8369", size = 140754, upload-time = "2026-10-20T09:07:45.396Z" }, + { url = "https://files.pythonhosted.org/packages/47/6b/7f167f09b4b41482ee4dde361a0347549c6af7c711cfe6c7b64cb5f15dea/pycares-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:07ad25040fd672d1ec3a9bc946430e174147661ebed954e22b2ab4eef5ec043f", size = 135517, upload-time = "2026-10-20T09:07:46.448Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/2a307d30e47ef21fc83cfc38962b1a962f9e7d9c80f77c12289b8316c264/pycares-5.2.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e52b6e97dec1a5ebeaefa4b51ceec04ab4154789e8ee795ceb5d1375044024b", size = 235793, upload-time = "2026-10-20T09:07:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/28370ed0f3cbc55bd8faa80f8e215dc3cec6e4bf075172f2b3c3f902a5f4/pycares-5.2.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:67792fe8a2927d7d97c7e662dd5d8177dbc85e631eb8577ab66007bfb71ef844", size = 267493, upload-time = "2026-10-20T09:07:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/b0/13/b64b90f28508d5aa7394bcff4c4f3f4f64fb196c0cffa0dc0648d196cb32/pycares-5.2.1-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c70f6c1834fff8e8b78d5d9748343ac8eef64830c58be77aa95efd04c4cf877", size = 253324, upload-time = "2026-10-20T09:07:50.075Z" }, + { url = "https://files.pythonhosted.org/packages/b6/32/fbd2e6cae25a2374d8f110c041ff7dfb3a694aa2e9c5fc7632011109cf1f/pycares-5.2.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4477105f3abaef427fc828d03c61bb426235e53dbee737bbcf56a9d216d4e641", size = 237621, upload-time = "2026-10-20T09:07:51.076Z" }, + { url = "https://files.pythonhosted.org/packages/db/cd/3ba9b7c603b97119fc9424a848a5e445109b9d8d780e457874e19e96af66/pycares-5.2.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b73d3bb9a11f29f3af642ba4f8894910555fa87ae73ebb94b3e6efbcd8fe690", size = 234208, upload-time = "2026-10-20T09:07:52.382Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5b/3123325fde7d9542ebb6126c3289d8d029180f1db5e2a84bc59ab200172d/pycares-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5572b884f1c43c78ffc10f434de66565c84b459215c2f7b7ed67a6f51adf908e", size = 238278, upload-time = "2026-10-20T09:07:53.795Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ab/ca6e27aa1a16f94ba3853b35316ad19b322d7b0f4ac80312e8c92fed1f83/pycares-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fceebb23a86d1e1ee6946ea76143ab9207a4bb0f49d285885b7e25f1b64ec7e1", size = 268082, upload-time = "2026-10-20T09:07:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/604c734682662a25d98de55540dfd647920e3f09c71d12021a41e43929f8/pycares-5.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b39a3743378abb831057e3c41ae91f0cbc5da0555feb6009cce2ca4fdbfe8821", size = 234178, upload-time = "2026-10-20T09:07:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/ab/dd/32f3a7a357e5b9f9e7ace050f92cba115afa5ee24edc648622806cb38644/pycares-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:48270369c219d382299258c86488159cbbf69a6c63a980a29bfbeea270430435", size = 252243, upload-time = "2026-10-20T09:07:57.199Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/deade815663d1be7acd0f3754fa7d8b3a3e246986f2c300524312c200b80/pycares-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f4ee91dac1c6c4cae18e5a478e836ec959d8710752a2fb9b23af03ef57b42c1", size = 237821, upload-time = "2026-10-20T09:07:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5d/698484f6bb0f879cb6447c9f0a1d605eb25d86eeb2c1c48f6a8493d2da70/pycares-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:35a48b5f6b4da473de997aa5dddd2ee0cae12cb32e3451a375a78a570258dacd", size = 123579, upload-time = "2026-10-20T09:07:59.206Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7c/411f700fd7b41c9e2de1a584abc4a0cd215798f8a0159d770e460561e63c/pycares-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ec1abe7bc799d3c00c43c22dcb74a2889c4dd61bca03e9342fae9892a4d6a44f", size = 119969, upload-time = "2026-10-20T09:08:00.473Z" }, +] + +[[package]] +name = "pycognito" +version = "2024.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "envs" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/67/3975cf257fcc04903686ef87d39be386d894a0d8182f43d37e9cbfc9609f/pycognito-2024.5.1.tar.gz", hash = "sha256:e211c66698c2c3dc8680e95107c2b4a922f504c3f7c179c27b8ee1ab0fc23ae4", size = 31182, upload-time = "2024-05-16T10:02:28.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/7a/f38dd351f47596b22ddbde1b8906e7f43d14be391dcdbd0c2daba886f26c/pycognito-2024.5.1-py3-none-any.whl", hash = "sha256:c821895dc62b7aea410fdccae4f96d8be7cab374182339f50a03de0fcb93f9ea", size = 26607, upload-time = "2024-05-16T10:02:27.3Z" }, +] + +[[package]] +name = "pycparser" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/78/abc4ce5920305780aeb36b4067a86253378b36e29ba96673a3deb02eb03a/pyobjc_core-12.2.2.tar.gz", hash = "sha256:3906452339cd06a3bb07df103c2511d4cb0f7a22d8771c0b802eba15d9a642b6", size = 1067701, upload-time = "2026-08-11T19:43:39.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/486d38a173b0b8dce973a3e13c74cf402ed1b8621586b5963bc9efd49a48/pyobjc_core-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2062e8ad30a310441cd022544a897553408bebeaa7820d5edba3c96fd7fd693b", size = 6421184, upload-time = "2026-08-11T19:30:21.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/d138fd9b9a66ea8db56a8138b77d3413b85da3defe13363a19f364f85529/pyobjc_core-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2c7ef3d2f865b4b3ebb14ec3556f7a3e8abb6d130c67275cd9daa08dbd6e4e4e", size = 6671824, upload-time = "2026-08-11T19:30:25.005Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/1a/b99521999b9f54b89aad928ddff0faad507abfe33bc46599454bfa48a4b2/pyobjc_framework_cocoa-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:889d7bbd4ba2d4941078bfbbfb882138e51dbead27df006abfe0f2e0d49b5b2e", size = 388368, upload-time = "2026-08-11T19:32:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/6d/26/0c697dbc73dcc76bc0f68ea5aeed25bf7b05217df5102659e878501b2d5f/pyobjc_framework_cocoa-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:de69c5933750f3a4599ed962eccd92b6a71914c7e4318dacc7895738a8ae60d7", size = 392404, upload-time = "2026-08-11T19:32:47.918Z" }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/f6/424a21392b3290dab0e8a78bddbe4e71754b9f1cf70d0e1584c03d6df987/pyobjc_framework_corebluetooth-12.2.2.tar.gz", hash = "sha256:75aa13f5355be549252d3192864bb3b82eb74c1f667d0527c1388117efffd688", size = 37806, upload-time = "2026-08-11T19:44:08.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/f8/592c2fc205d5d2a80f49960c4576ef52f89bcd43afb481de0a7f0f85d2c7/pyobjc_framework_corebluetooth-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c8d748b3ece787c750cdfc0ab820bd0316a084015453ca48b7885fcf56d3f782", size = 13225, upload-time = "2026-08-11T19:33:29.393Z" }, + { url = "https://files.pythonhosted.org/packages/40/70/48680ab2e817b805db661d17fa172d704541f873be593a6f4e9e56f3f030/pyobjc_framework_corebluetooth-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:289ea13ba2b7746235ef47e2d7e7606752d08384022197ccc0e6943b91d4c62d", size = 13416, upload-time = "2026-08-11T19:33:30.12Z" }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/76/e40db30142b551790e5ab208e74de04cfc70823177a702c8c9e3f5e73034/pyobjc_framework_libdispatch-12.2.2.tar.gz", hash = "sha256:7cb799a7c5766cc1b68b68655a02c950646adfd2a743b7cbe8e4a04a51c44ecb", size = 40625, upload-time = "2026-08-11T19:44:46.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/a2/827dac269b152880889e0ab0e9eeebb0e006790edc1f32fa9470a947c2d1/pyobjc_framework_libdispatch-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2488964343f9282e3521ab2f136948a8eaf7438351dc45c39e34ba23b9f5bc6b", size = 15704, upload-time = "2026-08-11T19:37:30.252Z" }, + { url = "https://files.pythonhosted.org/packages/95/9a/0bf4c9342949cf64a4781be35bf4cf37416e99b45e88c47604e208e11ba9/pyobjc_framework_libdispatch-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:74cea0ec7803147496c8b7b62e7ad1b9eda525aa692d8856464cec489951aa5d", size = 15987, upload-time = "2026-08-11T19:37:31.115Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, +] + +[[package]] +name = "pyrfc3339" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/7a/0e5227aa1c831fdaf579073a0ebb5ca11fc352a43fce8d2639e3ff87ff74/pyrfc3339-2.1.1.tar.gz", hash = "sha256:b144d63819bd4c1ebf9cb57e12f4cba567fcf23d6e8f2f829ba9c7810ea4b951", size = 12921, upload-time = "2026-10-11T01:50:12.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/79/2746c2066389dd88ede14491bea64cd9d7dd12e4bda63c5960017913af0d/pyrfc3339-2.1.1-py3-none-any.whl", hash = "sha256:4973172cb8b29c14caca7f76e6a52890d9e59ec2722dd5eb29b7fdd9fda2232f", size = 6511, upload-time = "2026-10-11T01:50:10.962Z" }, +] + +[[package]] +name = "pyric" +version = "0.1.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/64/a99f27d3b4347486c7bfc0aa516016c46dc4c0f380ffccbd742a61af1eda/PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9", size = 870401, upload-time = "2016-12-04T07:54:48.374Z" } + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-slugify" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/df/32c87abe18f7d0560e2154ffbe23dfca6edad5d0d2d6c4636c99f4fb8b02/python_slugify-9.1.2.tar.gz", hash = "sha256:bd36ca98e5ebb1cd2b9dfa2f27b948ce87402fb083b762cbd9d0fa3aed489a92", size = 64822, upload-time = "2026-09-27T15:56:24.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/e3/90a873d90440e59470a44261b3da3c175710e77677e1b37b2a79ad0cf9cc/python_slugify-9.1.2-py3-none-any.whl", hash = "sha256:a470dafd2ad79672606cc0d1a5fcaf7a2ccf8f358073ff29b5b93c2bd7e288d6", size = 15950, upload-time = "2026-09-27T15:56:23.158Z" }, +] + +[[package]] +name = "pytz" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "regex" +version = "2026.9.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" }, + { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" }, + { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" }, + { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" }, + { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" }, +] + +[[package]] +name = "securetar" +version = "2026.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/54/c7084733ce23f33c5f863734d6fd0c1789ba9ec771257cc54529c7a999ac/securetar-2026.4.1.tar.gz", hash = "sha256:536108c7cec0bdd6fb51abd8d293deba02930124d76ec5dd4599ae5e0c755638", size = 28289, upload-time = "2026-04-07T17:02:07.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/56/4ef84868d1ff2f77ac080929117283ac9d18f7d018ae64fa1e7f957a8d20/securetar-2026.4.1-py3-none-any.whl", hash = "sha256:7cf66c204d678b9d1e2d17814c83048a6468e928ea00edb7e6c1efe72aab0540", size = 19339, upload-time = "2026-04-07T17:02:05.635Z" }, +] + +[[package]] +name = "sentence-stream" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/69/f3d048692aac843f41102507f6257138392ec841c16718f0618d27051caf/sentence_stream-1.3.0.tar.gz", hash = "sha256:b06261d35729de97df9002a1cc708f9a888f662b80d5d6d008ee69c51f36041b", size = 10049, upload-time = "2026-01-08T16:25:06.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/b6/48339c109bab6f54ff608800773b9425464c6cbf7fd3f2ba01294d78be3d/sentence_stream-1.3.0-py3-none-any.whl", hash = "sha256:7448d131315b85eefdf238e5edd9caa62899acf609145d5e0e10c09812eb8a1d", size = 8707, upload-time = "2026-01-08T16:25:05.918Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sleepypod-home-assistant-validation" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "homeassistant" }, +] + +[package.metadata] +requires-dist = [{ name = "homeassistant", specifier = "==2026.10.4" }] + +[[package]] +name = "snitun" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/8c/1ad04f715ee3cb75d4d1236f4f1b8ac63ee245959847220aa484445d7301/snitun-0.47.0.tar.gz", hash = "sha256:264975472011b7a211e44880c614d5534f6fe953c125e0cec365bae3a169c578", size = 64346, upload-time = "2026-06-29T08:12:49.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/69/c7ba5437d10ae7a6446169a1528c50ec38936455fed23480dde36902a6fe/snitun-0.47.0-py3-none-any.whl", hash = "sha256:57eb4710f13a03360c07527c8e2d442b8c90e8ff7b94c71228cde5da8dbb755a", size = 72315, upload-time = "2026-06-29T08:12:48.231Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.54" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/271aa905cf2964f841371a97f3e63ab692bf51b4423d0491e67bc7f64037/sqlalchemy-2.0.54.tar.gz", hash = "sha256:baa8521e8ee9f24e75dfc7aaabc08020e551ef0d48d7c3e3536f5cddf277586b", size = 9969559, upload-time = "2026-09-15T21:06:57.337Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/c0/4a6503c9d22d6d00a5631082ab1484222ecf7d573db791e0f53161bf7745/sqlalchemy-2.0.54-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:abd6b21bc58e91c1932eb5d6d7f1bd44a551dfec7b6a7f517c3638ccd67233a0", size = 2185899, upload-time = "2026-09-15T22:29:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/f4424f618bd1f373761a32a821d53ce2c257350e9894bae9b968cb03d8fd/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5417322b3c025dd82918725d3bf09ec105fac95efc195722b8b06e1d9c381139", size = 3394763, upload-time = "2026-09-15T22:29:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/59/d2/7f0c77f8e042cb5f28275fea29c3080b4ac6fd4b3fdd7f59ff1ef3e28c11/sqlalchemy-2.0.54-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f84099e4b04a5c2d44500a2a8302eee5af4bc6fee63e8c6e9cf6786e747280e", size = 3402800, upload-time = "2026-09-15T22:40:36.509Z" }, + { url = "https://files.pythonhosted.org/packages/af/32/3eaa930bcf71d17a72587081d2706a5fb97ab3f11a7e0fb838f581f7cff1/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0956dc754d3884da7fe60097110ec7a8a105d26afa2f0844468f4b1598c6912", size = 3341469, upload-time = "2026-09-15T22:29:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/eb/cc/cddb6cbd4408e5c55b3bf722be26b9d3b54d42509f901b7ecc15debd3d1f/sqlalchemy-2.0.54-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:87ba8834318b0d8dc94fc6f405d071b5c08be32a6c3fd68107fd6952ee949615", size = 3373232, upload-time = "2026-09-15T22:40:39.181Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/9c2aa5efc642b7f3b985d13565cd1a5e78856e079ef3022796fea5180498/sqlalchemy-2.0.54-cp314-cp314-win32.whl", hash = "sha256:842540e4382472f23c79589995752648d14696a8200d0807ed8c5c59c92ade44", size = 2142018, upload-time = "2026-09-15T22:42:55.118Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0b/3594f1f51769feb3022d686135dc5d8682a12345ed15ae61d0c0ca42cbee/sqlalchemy-2.0.54-cp314-cp314-win_amd64.whl", hash = "sha256:f4e8f955d13af83fb4e35c3472e5377ee22d3445eada1e5e48199588edb69835", size = 2169220, upload-time = "2026-09-15T22:42:56.727Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a5/c211a9a7af83222509519407e16a4db760c6df3d03be69ebc5414d465321/sqlalchemy-2.0.54-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca05f4e7852cf48083b0cf157e4f9504b7068780422a50fa82f45353b8c5e14a", size = 2208458, upload-time = "2026-09-15T22:30:05.718Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2e/490ad7b3731116cb48ba170f7722eaa99a89707193e54389ca84b7ad55af/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18a8b6417cbb7b735cf91c2b59453c2a554cefa0a8d7bd15aa35740739410d77", size = 3660585, upload-time = "2026-09-15T22:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/eb/25/15dfe6814847eeda773bd58ab6cf42a94b0176e5cf25a578fc1165777160/sqlalchemy-2.0.54-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e55a0b96a1577a1e108c91ccdeeb9cd92768f28ce206597311c3bf6d6423abd", size = 3624442, upload-time = "2026-09-15T22:36:38.377Z" }, + { url = "https://files.pythonhosted.org/packages/aa/19/724d0a6a2fb2a86ff2d6008e581c258f722d9b7d8e52adc7b79085febdd4/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:69cab115c40fd02c5a22c68e4ee630fa6ef9a1650f1de944419aab1f7096fc4f", size = 3562972, upload-time = "2026-09-15T22:36:08.581Z" }, + { url = "https://files.pythonhosted.org/packages/49/bb/9df1bd81c2f2d000cf5e7a1b1a9b331468a3aab939ad983355e701fa42b2/sqlalchemy-2.0.54-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e08397c6c42f53b2488acde9108b8bfefd52d7afd1bf2f03d2ffcab7a204aceb", size = 3576479, upload-time = "2026-09-15T22:36:40.272Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/b5775465d3b89061d7c46057c31c56ff8fb6c509550b2b0c6570ffc248b3/sqlalchemy-2.0.54-cp314-cp314t-win32.whl", hash = "sha256:b9086b8ad48280ef6a7ba68262d5e44f7db1c4cb1973e8cdae8a9f467ae66f51", size = 2174794, upload-time = "2026-09-15T22:31:45.925Z" }, + { url = "https://files.pythonhosted.org/packages/77/f8/296c2e46b4ccd3f29b00b954ef2f195dde32f98e352ed21de1d292cedc0d/sqlalchemy-2.0.54-cp314-cp314t-win_amd64.whl", hash = "sha256:b67c1744e453af833667fc1b84de07adb4a64f3536ef52a8ec5ac2b941d43970", size = 2211942, upload-time = "2026-09-15T22:31:47.368Z" }, + { url = "https://files.pythonhosted.org/packages/24/a1/bd5e3e99bc9c8863b51ac5b9b03008a7f2da8c6b59695992f5c654e1265b/sqlalchemy-2.0.54-py3-none-any.whl", hash = "sha256:7e33a631ab1474f8fe6b910bd1a07b7b8009c4c78cdd3fb18001b03e3bc2e1d2", size = 1958015, upload-time = "2026-09-15T22:24:22.95Z" }, +] + +[[package]] +name = "standard-aifc" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts" }, + { name = "standard-chunk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload-time = "2024-10-30T16:01:07.071Z" }, +] + +[[package]] +name = "standard-chunk" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload-time = "2024-10-30T16:18:28.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" }, +] + +[[package]] +name = "standard-telnetlib" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/06/7bf7c0ec16574aeb1f6602d6a7bdb020084362fb4a9b177c5465b0aae0b6/standard_telnetlib-3.13.0.tar.gz", hash = "sha256:243333696bf1659a558eb999c23add82c41ffc2f2d04a56fae13b61b536fb173", size = 12636, upload-time = "2024-10-30T16:01:42.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/85/a1808451ac0b36c61dffe8aea21e45c64ba7da28f6cb0d269171298c6281/standard_telnetlib-3.13.0-py3-none-any.whl", hash = "sha256:b268060a3220c80c7887f2ad9df91cd81e865f0c5052332b81d80ffda8677691", size = 9995, upload-time = "2024-10-30T16:01:29.289Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] + +[[package]] +name = "uart-devices" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/08/a8fd6b3dd2cb92344fb4239d4e81ee121767430d7ce71f3f41282f7334e0/uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34", size = 5167, upload-time = "2025-02-22T16:47:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/64/edf33c2d7fba7d6bf057c9dc4235bfc699517ea4c996240a1a9c2bf51c29/uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123", size = 4827, upload-time = "2025-02-22T16:47:04.286Z" }, +] + +[[package]] +name = "ulid-transform" +version = "2.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/69/80e64aff3bbfd719ef3519774cf489cd972aac89cf8f85cd52009757be71/ulid_transform-2.2.9.tar.gz", hash = "sha256:6a8b573de0158ab4bd0424f26ed4d1cef42f15afaa718c924a8427fe24a3838f", size = 17886, upload-time = "2026-05-25T05:39:04.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/2e/6351361aefb2bfe44036519bb60ef9689511c7351c42e2edb7f86e28cb0a/ulid_transform-2.2.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b64afbdb9143210e7f9f240a91fa8fd54a559c6627e8f4cdb9f40771f8b8a498", size = 25212, upload-time = "2026-05-25T05:45:49.977Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2c/db41412391950083c387c8185641db32e6f67862b52391c992cdd1824b5c/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcbef4c1c537da14fa098529167f64f7d351e7aebeeeafd6779ed2ffe10f4649", size = 27462, upload-time = "2026-05-25T05:45:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/71/aa/8a2b3b3b6fd638f88117e6852f5b1aaaf5f73de25e2d7a8bb483a9adaa58/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:73fef8bdfae646ec4c54f5cc96dc27510fdb55c1aaed3c20d6661e8b51d01bd9", size = 27809, upload-time = "2026-05-25T05:45:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/78/f5/520fc50bfd97a616e5c367cd820eee65ffc3537acc68fdacb09f31fef149/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6db97794ce8638ee2bd11ace5ddc9e23e450e971b264556c2c42ab43fc3d580", size = 26406, upload-time = "2026-05-25T05:45:54.1Z" }, + { url = "https://files.pythonhosted.org/packages/9a/33/27c3f9ed60426b514b1501c8acd47ab05a305fbb1e294dce6a13c52a88be/ulid_transform-2.2.9-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:f62d23bf7ed08e086e27dc5f5c89f9aec4d79316174529070073505bc367d8fa", size = 16808, upload-time = "2026-05-25T05:39:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/5f/43/b246e2c3e4936789f9797c5e61ebde1d1c6fecbc9a1d6cdf6c427518006e/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae8473948da794f27ecf800511ff7e9bc340e1e5d07f28f1f640a860240d1d39", size = 1007530, upload-time = "2026-05-25T05:45:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/c1ab2d3901899962afcbddce47dc796f7eaa6694f96c3f4977ab43883712/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a978e55081259c95a14bd7d48db6540645ca228c2d07da6c176c291cd955e85b", size = 877349, upload-time = "2026-05-25T05:45:57.625Z" }, + { url = "https://files.pythonhosted.org/packages/af/b1/2bd4509651123caa37b447e0d4e5bb321067fd79a1bcaefa2dfc7fe41206/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:364651ca7bf17e56b7570855d185e10ae461d5bfe15943936ee6f79fde71bb02", size = 1058955, upload-time = "2026-05-25T05:45:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d6/e48135a3992562630043f57a85ba36109787b91d27da842ae8e7136fd24f/ulid_transform-2.2.9-cp314-cp314-win32.whl", hash = "sha256:6daaaadfe2d7e465f0850c05dfcd5a785426571361433e84fc3f5a8ed78321ae", size = 25741, upload-time = "2026-05-25T05:46:00.438Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8e/ff96df79000ed277a9fc73e8825f27c81fba2446acb7fe33f53a1489ba70/ulid_transform-2.2.9-cp314-cp314-win_amd64.whl", hash = "sha256:f586c25a2457b88511e1acceae9aeb90f002aee06a4d37e68280ba0ff6af4b9d", size = 25837, upload-time = "2026-05-25T05:46:01.955Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/59879019153dbbd34eff5f3c633f329b5d75519c1a95f3271948ea51715f/ulid_transform-2.2.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a873a51c8ec7f3d51c6d3d6ecdf3720d29637d5619e7c7df16dbd451f19ceb82", size = 25198, upload-time = "2026-05-25T05:46:03.022Z" }, + { url = "https://files.pythonhosted.org/packages/51/c6/293ba411f0e32e17086baed7c955e4287e368275f31f59ef7a005d401eac/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65711af19211c89c115954aab2965abad5287596d24f5754fcd142e80dd7656c", size = 27474, upload-time = "2026-05-25T05:46:04.058Z" }, + { url = "https://files.pythonhosted.org/packages/57/17/2eabe1ba6e1ed341d372c996380a812a5278217582349fc609d61fb57342/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9456b9bd8a572448cbddd9f3c5266d7354c68a3c0dc25209108ea904846fac3", size = 27826, upload-time = "2026-05-25T05:46:05.166Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/1f3f2e6c1243cafd75d9c2736b6b73e5530d96b49f955493e8917b2fe716/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12639c5dda80d6d90437e9427dd87c6bb71bb291dcfe8d6063703394c2e15e3a", size = 26405, upload-time = "2026-05-25T05:46:06.268Z" }, + { url = "https://files.pythonhosted.org/packages/00/6b/200c79dc081b4c9673923dd9d9bc9dc0e47ccdc90eeae8e85acbb1914751/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8cf746c642569dfe3efd94fe45672954f6257ff910f993bca80ab6dcb5a34f92", size = 1007528, upload-time = "2026-05-25T05:46:07.417Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/228a44957e37a3c7541989d47c481c9f51ae045ad07fdacc0deb49d4e120/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5c19d10c7ad9a24c456aba5cbd74aeeca84a4b33cd35077b4bec61d2cf92e30c", size = 877361, upload-time = "2026-05-25T05:46:09.145Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4beaa80ac820b3243e376f79fde4a8bca2b477d8b6529ff5afcfd83f5f74/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35084b9ade29b462472485966789e5edc66dcbc376ffaf89759e7ada0c7d1f6c", size = 1058912, upload-time = "2026-05-25T05:46:10.736Z" }, + { url = "https://files.pythonhosted.org/packages/39/f8/ab3af64aa6ced7c65b09aa4b1c33d2cd4bf48646c9957a17659cea69e1c5/ulid_transform-2.2.9-cp314-cp314t-win32.whl", hash = "sha256:ccf6b173ae1e90a589935fd99176dc9d71d461c9f413b1a754d542ba2d399860", size = 25725, upload-time = "2026-05-25T05:46:12.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c8/cbb9374e8a0e26d18e94ffb3e1f861beab5312b3e0be4fbad1bf60cdb47c/ulid_transform-2.2.9-cp314-cp314t-win_amd64.whl", hash = "sha256:fed352965a8682a156000d29e0efff8b85e7c2b8cb74f2e953a1e6d0157648b9", size = 25827, upload-time = "2026-05-25T05:46:13.164Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "usb-devices" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/93/76be49b956574f75c9b907193732ff2b6b8f7075692adde93a7f151b30e5/usb_devices-0.5.1.tar.gz", hash = "sha256:8ef45c401490391a3651b3ba748eb213a654ac165be10a0786ab963668c49cc3", size = 5365, upload-time = "2026-05-27T18:01:56.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/12/f8dd0bd25e9e2ab747b47ae4efed08de75f595f6136a71bafaf3db0c47ed/usb_devices-0.5.1-py3-none-any.whl", hash = "sha256:e77e7972b5ac6a883b3778b0273d9582c5b8ae847ac78fba3d080dd4a4cc69ad", size = 5423, upload-time = "2026-05-27T18:01:55.012Z" }, +] + +[[package]] +name = "uv" +version = "0.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/f0/7565e80415f841c3500ed69d86516c7676e3b24470a218ab61b1f43071a2/uv-0.13.5.tar.gz", hash = "sha256:979409b8c9a78df140ffb19c53f511b7a73526da8d1d5e36558833055e371c6c", size = 7513309, upload-time = "2026-10-22T01:32:33.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/ff/e30478710f53cf388c892e27e90662aa2acac7a7998bdb18023c595213c1/uv-0.13.5-py3-none-linux_armv6l.whl", hash = "sha256:8d092d07d2389909027c2ed4965ade1bd46bbcecceb26686ff40927684f8c457", size = 18325046, upload-time = "2026-10-22T01:31:37.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2a/dcae794203261e49564bb9ff7c4b64a093f69ac73476cc8ce54c1deed3a1/uv-0.13.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:337a14829bb499e114989df4f908f134ff37f1fb6c33497541cfb05b28080e39", size = 17335724, upload-time = "2026-10-22T01:31:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/d2/26/9ac45211123244ea68be852775093af487a64debc13621a2de239f8ebe73/uv-0.13.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a42c85fa8156e1e5e2cecdbc77bb818ea1dcf5f78b8e2a8f84c7fe4f16f0702e", size = 14470685, upload-time = "2026-10-22T01:31:43.897Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2e/5bf9acf9d11fb790420e258a875e13bba36bc890858350642aca185efe0e/uv-0.13.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2386c2dc9b59fc1d39b1c500e4319d71d3268232eb3b38e29cd6775d6269e8d4", size = 17784583, upload-time = "2026-10-22T01:31:46.95Z" }, + { url = "https://files.pythonhosted.org/packages/f3/70/b02fdcbae77346e3ec1310e7de9dde541252e709f6641db3fbd32cd4da79/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:4f62680fe58c5a83fd539e38f1956c2b577b1f5939c9020aa4c7f0addda2f4ad", size = 17866408, upload-time = "2026-10-22T01:31:50.352Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b9/6663095851e0943e05e47318d7600e567bc172a1e7a26b21b8e6608e8491/uv-0.13.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e03f5717bdaaf5ab4e2df361d2395e36b515946f56ef4bed88801aeb9959fb0", size = 17753574, upload-time = "2026-10-22T01:31:53.502Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/1bb89a94486b813bbeaa1b8b491e69a25fa864626711944127b186f903de/uv-0.13.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f867c5ec24c563afaae730a13e46cdf74a1a0b64c134286ff99762a96e2abf40", size = 18502087, upload-time = "2026-10-22T01:31:56.748Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a1/b0b0c7247f3bb0f307eae8a28679ecd0d4b0e2757fcf6c87dea6f679a099/uv-0.13.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3fd5db55c20feb672ea457361aba29ab68b9ae69a14d98ad6c138b5f86dcca4", size = 19927049, upload-time = "2026-10-22T01:31:59.831Z" }, + { url = "https://files.pythonhosted.org/packages/b4/44/6bf11de78ce18869849a14178534b900dbe4e310270b2f60df6503c03137/uv-0.13.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e01481ae5212e45ee7c85fad5fc67f06b0ee0329a7c3c289dc529da884b03881", size = 19662743, upload-time = "2026-10-22T01:32:03.258Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa8d26e08e34c29c0c4e820cf3181757086d547240b4f4d31ff1d8bd810ca946", size = 17211825, upload-time = "2026-10-22T01:32:06.393Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/d83d019c63c1ccc6ff17df2bfe76270412f9766e7e71aa4d4ccbf018683f/uv-0.13.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:91e58e304b63b6c91bac5561459ab0c3ea24f17bc01ea9359d69fed72142978f", size = 16654136, upload-time = "2026-10-22T01:32:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5a/d42f9d93b322ad36647eb3bf516cc528847c240f630f6df213b6221c5262/uv-0.13.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:309a74d3c488bfaa3ba7bff759da73193d8781729c0a01274f4940fd32bb5712", size = 17921105, upload-time = "2026-10-22T01:32:13.174Z" }, + { url = "https://files.pythonhosted.org/packages/97/79/c5c2c39c78cf7a1dd6e041b3f7aa2cc4258d351d42dea06f6fee0bde5daa/uv-0.13.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:469cca66de1b17c75b9b9de806a64cffb57cba5e42b1e30d0bba6fa72a146f58", size = 18103266, upload-time = "2026-10-22T01:32:16.302Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/e5c70a05013077e5ecadb804418ddbab33f7279f96d052f79f565369b2a7/uv-0.13.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:2386e8dbfe110ff2f8ec8bd344dd18bf39eb9eb54e1edeeb87c3b85c34229cd3", size = 17842061, upload-time = "2026-10-22T01:32:19.208Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b0/7dba2cb636d7f58e0d74e948ad1afc6b7923e84e82de025e9951a68055db/uv-0.13.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d162669e3a7811c584b03690eaa11e796ad1d309aa8229d5dfe3cbcd68a90400", size = 18963145, upload-time = "2026-10-22T01:32:22.362Z" }, + { url = "https://files.pythonhosted.org/packages/00/7c/e38b312abaae1bd59ff5886126f48e9e223fbb9a8edf9e236ede976f67cb/uv-0.13.5-py3-none-win32.whl", hash = "sha256:f4e55a6e2a1089fe74ed50fbdf2df0f6f336ee148ad232b05932db5deee8007e", size = 16240919, upload-time = "2026-10-22T01:32:25.329Z" }, + { url = "https://files.pythonhosted.org/packages/32/ea/771744a784b16aa21d195cb5d5d5a2878d72fa00fc26f11ea3f9646994fe/uv-0.13.5-py3-none-win_amd64.whl", hash = "sha256:1fb29fc46ba12033e2ef95a95ac79d649e71bbb1358419bbbcbd19d86026ef2f", size = 15358650, upload-time = "2026-10-22T01:32:28.22Z" }, + { url = "https://files.pythonhosted.org/packages/52/67/1f31243f75ece410f2db1afdfc9a9f9722fdee2385e4a347c3ca1dee6d13/uv-0.13.5-py3-none-win_arm64.whl", hash = "sha256:0548460d46c8667488b1f089e936f29f0edfb1cc753f9152ffbd8bc84a46aa20", size = 15533668, upload-time = "2026-10-22T01:32:31.335Z" }, +] + +[[package]] +name = "voluptuous" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f4/0738e6849858deae22218be3bbb8207ba83a96e9d0ec7e8e8cd67b30e5ca/voluptuous-0.16.0.tar.gz", hash = "sha256:006535e22fed944aec17bef6e8725472476194743c87bd233e912eb463f8ff05", size = 54238, upload-time = "2025-12-18T23:18:46.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/00/0e0da784245c93cf346150ab67634177bf277f93b7a162bb56c928c39c04/voluptuous-0.16.0-py3-none-any.whl", hash = "sha256:ee342095263e1b5afbd4d418cb5adc92810eebfd07696bb033a261210df33db4", size = 31931, upload-time = "2025-12-18T23:18:44.694Z" }, +] + +[[package]] +name = "webrtc-models" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mashumaro" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e8/050ffe3b71ff44d3885eee2bed763ca937e2a30bc950d866f22ba657776b/webrtc_models-0.3.0.tar.gz", hash = "sha256:559c743e5cc3bcc8133be1b6fb5e8492a9ddb17151129c21cbb2e3f2a1166526", size = 9411, upload-time = "2024-11-18T17:43:45.682Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/e7/62f29980c9e8d75af93b642a0c37aa8e201fd5268ba3a7179c172549bac3/webrtc_models-0.3.0-py3-none-any.whl", hash = "sha256:8fddded3ffd7ca837de878033501927580799a2c1b7829f7ae8a0f43b49004ea", size = 7476, upload-time = "2024-11-18T17:43:44.165Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/4b/69b1aa90c4b601d4699b1463c5de4887e1f886567df502b67e9474f7ead2/winrt_runtime-4.0.3.tar.gz", hash = "sha256:b04877a5b1f2ca67084d62731546f7b512c8291e2b70b95def82a1c4d0bfe993", size = 443382, upload-time = "2026-10-24T23:57:14.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/c8/21d9fa3728dabfbb1d081e6251a1929a83cfab7c57c932b72283b11dd78f/winrt_runtime-4.0.3-cp314-cp314-win32.whl", hash = "sha256:fee35995e7dfb083b5024d7a11d63ad2d2c74e7a9fc6a58dcdede52c1f0a321d", size = 208761, upload-time = "2026-10-24T23:57:01.867Z" }, + { url = "https://files.pythonhosted.org/packages/24/bc/081419f80efd99477d4b6c474169140600b078ca06b60b2e76bd6f775f32/winrt_runtime-4.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:03d76d06172b6f5bb6312aa24f0d25761b1e45fe2b7c3c0b4ad0cd73a06bb293", size = 256844, upload-time = "2026-10-24T23:57:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c7/6c805c9d44097a5c9dab515946c481e96454122f72b51f12596587f6ca1b/winrt_runtime-4.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:9731462174346a2ede57660b9d521e41a16cd7e59890dc292c4250c42d1642a9", size = 239426, upload-time = "2026-10-24T23:57:03.683Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/4721085f38c0ae128698769e07516ffaf942f1d81c14595236e50f7afbf4/winrt_runtime-4.0.3-cp314-cp314t-win32.whl", hash = "sha256:dc545f834a3dd2bcc69512193677f0918788ccce4871ca24937aa6f171ea7112", size = 214175, upload-time = "2026-10-24T23:57:04.687Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/e50b71e188a1988df1b2ca16fc50597d257da87cea91f6a51f973e659693/winrt_runtime-4.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b0b06be21eb17248ce32d43a4a027ba22651065f9af5b42c3454575766879e1e", size = 265020, upload-time = "2026-10-24T23:57:05.744Z" }, + { url = "https://files.pythonhosted.org/packages/88/5e/c3d879cf37b578789f2e56f56a2c712945ce88ef26186f5e87cfb34da9f2/winrt_runtime-4.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2b8d6a55d695e54a25e262a6d1134c510a1e291cf2c3d141990a8c209f87dc40", size = 248744, upload-time = "2026-10-24T23:57:06.982Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, + { name = "winrt-windows-foundation-collections" }, + { name = "winrt-windows-storage-streams" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/a2/02c95c1766180032917f8fd3becf26557f79cc6a526fe45cd2ead1955ced/winrt_windows_devices_bluetooth-4!10.0.28000.2705.tar.gz", hash = "sha256:998449c3c2058cf7a721200de260013df1dc8367d08fed123b8e608bc353f5b2", size = 21158, upload-time = "2026-10-24T01:45:25.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/0c/6ac0f96761a9a0a3d247e59b9e133819c095aea588cd0858f7d4bdaff715/winrt_windows_devices_bluetooth-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:c68bdb970459eab888cf03764d50ee156eedd10fd02aceefb161babad0ba3dc7", size = 25007, upload-time = "2026-10-24T01:30:00.033Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, + { name = "winrt-windows-foundation-collections" }, + { name = "winrt-windows-storage-streams" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/b8/5b35beb300f474536ab5ccd26ac8e55bfd0a0da5a995e355d04d163a4966/winrt_windows_devices_bluetooth_advertisement-4!10.0.28000.2705.tar.gz", hash = "sha256:3962fdd53dcf66b9b7d701d0ad8bcbedcc237a27f2a38cc323504130b2168de9", size = 14803, upload-time = "2026-10-24T01:45:26.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/14/cc100ba8a4f650dd5489f397d4988c58a18d8dbda6b9155691ce830e3f6f/winrt_windows_devices_bluetooth_advertisement-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:810e11e4d6d3ad226b9cadf00b27e4a345b82b0244ec3900ea02fffd9e86d60c", size = 17942, upload-time = "2026-10-24T01:30:00.898Z" }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, + { name = "winrt-windows-foundation-collections" }, + { name = "winrt-windows-storage-streams" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/e0/4728712d704a03c096908e4eabf034d498714d28a0130854999327acf14a/winrt_windows_devices_bluetooth_genericattributeprofile-4!10.0.28000.2705.tar.gz", hash = "sha256:522fa8f9c4fc784deb05ba12242a35b11ddda1f95bdb0de40cc6d0341789ba6c", size = 33787, upload-time = "2026-10-24T01:45:27.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/75/ba8882b335d3abdd0a262fdbfa21b86f7258c80e716c3034a78a99cd23c6/winrt_windows_devices_bluetooth_genericattributeprofile-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:9e8d7fa90148f659bb335b58e96d87b5267cb429bf779f56aec138e0524b3a12", size = 37345, upload-time = "2026-10-24T01:30:02.842Z" }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, + { name = "winrt-windows-foundation-collections" }, + { name = "winrt-windows-storage-streams" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/92/8d628a5e629407275759318c5521e4990ef234def354bcb458d083c9a8d8/winrt_windows_devices_enumeration-4!10.0.28000.2705.tar.gz", hash = "sha256:52607bfcbf8989b819a7d5d96693018084ad64d0aec69ee5df5f7b273e033b35", size = 22773, upload-time = "2026-10-24T01:45:30.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/60/913888bbd1bf5e32c27a704ef6ae568bb623f4a4a74c0e4cdb7129b87186/winrt_windows_devices_enumeration-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:719e7f638db3d324f54ce86ed09689dce11374aa60fbe2b14843651bee266fc8", size = 25084, upload-time = "2026-10-24T01:30:07.097Z" }, +] + +[[package]] +name = "winrt-windows-devices-radios" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, + { name = "winrt-windows-foundation-collections" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/50/e3410ef672be33a6e31dd0d2333db8fe282a36cb48774868f9fafd246122/winrt_windows_devices_radios-4!10.0.28000.2705.tar.gz", hash = "sha256:8705524e424cdeefd9252bedc3267ff4a8715ca48835b077a44748d92807c23f", size = 5703, upload-time = "2026-10-24T01:45:45.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/9b/b5fdefa5cf82226da5b89bf15d6d45119588b0997497fcc2936e5a104da6/winrt_windows_devices_radios-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:1852902c3c89ba9ca291bfa4099d3ead923eca6c38a4c421b99a35cebd717f62", size = 7452, upload-time = "2026-10-24T01:30:29.688Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation-collections" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/4c/15c793795c666aad29f84e4b9d2fb1e67974be33eda6c3166116c5e36a34/winrt_windows_foundation-4!10.0.28000.2705.tar.gz", hash = "sha256:eb33cc2fdf0d8c5358cb7a731563118de0f16ddc75e256b8fd83c8117a6405a0", size = 13970, upload-time = "2026-10-24T01:45:53.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/59/b952e151894cc86954edddc0bc894e3849a0aa09363742accd6bab8f536a/winrt_windows_foundation-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:b409530668b101cb17b3a6ce02061b9839665c92b64b0563c106186d87562571", size = 17674, upload-time = "2026-10-24T01:30:41.741Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/87/6348bbff25a2fda2ab6c53b582f2f7f83fe11e42e338f53ec39ef5a0093e/winrt_windows_foundation_collections-4!10.0.28000.2705.tar.gz", hash = "sha256:8d396ea352d3e57a534223314aa935c2fe3e3edaa6b38439b13d5ddfeb5a12ea", size = 8693, upload-time = "2026-10-24T01:45:54.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f9/ed0f5b9c4dc4ccd5f46dc9c65e4b6c82254eae07df00fa8aad2d9dd91863/winrt_windows_foundation_collections-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:f2169228644b86d752dca6f4ba0bb55ee27c5cc1062f3a3f0fbb375f48894bd7", size = 11341, upload-time = "2026-10-24T01:30:42.586Z" }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "4!10.0.28000.2705" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, + { name = "winrt-windows-foundation" }, + { name = "winrt-windows-foundation-collections" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/1c/c64902c94efd142398198a3aeffa4311cdfffd9b1a40da16771ee5bff3e1/winrt_windows_storage_streams-4!10.0.28000.2705.tar.gz", hash = "sha256:9511b19e9cc588dcc81969b2871e465385f2ab37a62a9019f90dc37e03a5dd16", size = 16617, upload-time = "2026-10-24T01:47:12.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/69/58254ddbc0ef0e19095e98bfce1cf3df98cf5611098db9147cf428bcd1a8/winrt_windows_storage_streams-4!10.0.28000.2705-py3-none-any.whl", hash = "sha256:2824e516e328c9ba76ffc7926431255b124b4a9f52c656f0b1e0056a7f8ca7b8", size = 18790, upload-time = "2026-10-24T01:32:31.342Z" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] + +[[package]] +name = "zeroconf" +version = "0.151.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/20/69744d9de9d375dae2639dda9add7dae85d52d690b68c61b23b8a6468434/zeroconf-0.151.5.tar.gz", hash = "sha256:28c2ec9d772007eedf11b41a9c9fd3d5c684c17b00721ff8f1ee31b20ad286a1", size = 223583, upload-time = "2026-09-28T14:42:06.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/e2/36f043cdf3970bc9f07cbd0af0263e40681d5a4693e9bb250b29aa425fae/zeroconf-0.151.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cba88b0fb77ea1b4dceaebae577c8245852159c525201b2453cd3bd5715cf56", size = 1204720, upload-time = "2026-09-28T14:58:49.354Z" }, + { url = "https://files.pythonhosted.org/packages/90/c3/fc795bfc5450ea339f212162a76548424f8e860bf043ba041634aab53aa1/zeroconf-0.151.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b9f3f49860cb60dd5d1453c681dbcced4cac96960935f1b9f7c24d7f3993ce", size = 1379162, upload-time = "2026-09-28T14:58:50.895Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/54155cd16ba7247ee171be2d95c49273445aa8e3c9517e7a7d861922c65c/zeroconf-0.151.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2304c3fd4717422242cc78315b37d48613109963900e0c9a2b784a91fd6e9c0e", size = 1290560, upload-time = "2026-09-28T14:58:52.542Z" }, + { url = "https://files.pythonhosted.org/packages/16/d3/9b6fa4f94a5be0145730f998bdf44c642db159d7a7f2f9fb5a1ed4adce04/zeroconf-0.151.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5846416ae2bddacb1bbb755233dd13c4bd15d94dfd0feb23a4b1abe1e2312302", size = 1417844, upload-time = "2026-09-28T14:58:54.382Z" }, + { url = "https://files.pythonhosted.org/packages/c3/31/43c707f69d6118766a5950e86f23b7e2130eea119c9d491460d663db4b06/zeroconf-0.151.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c1f45df00d20918fc7a0c2a6229ae1f9b44fbbef6a161df96c7710098c82e019", size = 1398885, upload-time = "2026-09-28T14:58:56.167Z" }, + { url = "https://files.pythonhosted.org/packages/2d/68/f0b29845b18e2436e169e3d2a4889da6802077115a67289abac1b14b4976/zeroconf-0.151.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f8f7ec289a07acb9e272d0f2998759b76ec81dd23b15a7506e05689f630fafd1", size = 1303148, upload-time = "2026-09-28T14:58:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/99/84/566e4a3f104b0a49f119056d9ba60c4ff9e0ed88cbf22214d995aa1751dd/zeroconf-0.151.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dc7d125bd01eca9f63e7c65a3e52a81e385ef6ac25721bc67e15b99efc3ce6b0", size = 1436151, upload-time = "2026-09-28T14:58:59.608Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6e/939e5e9fe9fef9c0b2dc2ad13e2cb843f9d1019de5196e22a0489c8cb01f/zeroconf-0.151.5-cp314-cp314-win32.whl", hash = "sha256:965d9e92ad34896a2537352a37affcff2daeb8c7af2072451220e753f0de990d", size = 965883, upload-time = "2026-09-28T14:59:01.278Z" }, + { url = "https://files.pythonhosted.org/packages/db/0b/4ff510db9f43b08a95907a82602017c5a58b2bf470663a755d1fae38c437/zeroconf-0.151.5-cp314-cp314-win_amd64.whl", hash = "sha256:3d3d2f0eb51e605f2010e83ab49db34c70c51304ee88a980ed1c59c0ea20b82f", size = 1088620, upload-time = "2026-09-28T14:59:02.906Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/c26550e38ecf81cb83fbfbe41c6b7fa3737db0f435f9b3a0a80ae3da499e/zeroconf-0.151.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a4bde05914a6dc6a4de83fb9f85ebcbf7fb1c89d9021122b689ebb6aa9d717c", size = 2380718, upload-time = "2026-09-28T14:59:04.743Z" }, + { url = "https://files.pythonhosted.org/packages/77/e8/fc6f3d0335340ec5ba5e137014f46d6d752a8d60c897950883bab21453c3/zeroconf-0.151.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5daadc4306a6def67187dd8a4ee15978d0c4d425bad4dfef7b2131e909edce02", size = 2662627, upload-time = "2026-09-28T14:59:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/94f1f3664a53a53eee7ff86a39ca507430cddba4c7dc1c7cc40992cd66c9/zeroconf-0.151.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5cfb9d625a12bb6788c142bd238479444ba43b8cbb56d6e0fd2becc17ef8e4a3", size = 2462679, upload-time = "2026-09-28T14:59:08.337Z" }, + { url = "https://files.pythonhosted.org/packages/9c/4f/b9274656c182405cfd4e3c3385ab209a7dda039ac3b4c0c72a47dd90e391/zeroconf-0.151.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd773d22ddd60f696e0be063a925a9d34f8de7bcc40e4b9c9360c9e081f97ab0", size = 2728078, upload-time = "2026-09-28T14:59:10.401Z" }, + { url = "https://files.pythonhosted.org/packages/27/39/2188581d88daebc349eb0519cebd6a951766201de510b57355f3b60f10c3/zeroconf-0.151.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99729ee6109f08ebee6ce7ef7bd4280747848550462d40275b059e3eaca57", size = 2706423, upload-time = "2026-09-28T14:59:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/77/f3/86aaca253b7eee26f3990e547a88590481a02847e7a2d46a320289efd377/zeroconf-0.151.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:faecc5a1f318a920a4f7052a14391ae6def8fd6035313886e88f53705ad9dc8e", size = 2507468, upload-time = "2026-09-28T14:59:14.285Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/26d680ccf78e9af53710069e3303ebd063f194f74ab6a05f384f85b1d57a/zeroconf-0.151.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b124e30e18f29690907f7f7ed8b79ab34fa8f40fe300a6f5a8f21b04148497e5", size = 2767519, upload-time = "2026-09-28T14:59:16.261Z" }, + { url = "https://files.pythonhosted.org/packages/0c/df/0b23bf3e3a27bda2a71e70c0ce7eb15e81f4cf4ee292f2c55b562a937ef7/zeroconf-0.151.5-cp314-cp314t-win32.whl", hash = "sha256:6385a69307ee9ccd71d0d2374f0a822590e2468919acbd4ae6bf3ab5f501a1f3", size = 1901603, upload-time = "2026-09-28T14:59:18.053Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6f/8809f921f46fa1f32115bcaa702752fb028af347f751baf5648c15c43454/zeroconf-0.151.5-cp314-cp314t-win_amd64.whl", hash = "sha256:9583f52f51f08f7b14e9f09d99cc0e5ed29afd02bfef6f9d89f175a3e3582a33", size = 2147378, upload-time = "2026-09-28T14:59:19.809Z" }, +] diff --git a/scripts/import-watch-health.py b/scripts/import-watch-health.py index 3e117d8cb..da5573f97 100755 --- a/scripts/import-watch-health.py +++ b/scripts/import-watch-health.py @@ -1,29 +1,32 @@ #!/usr/bin/env -S uv run --quiet --no-project """ -Import Apple Watch sleep stages and heart rate from a Health export into the pod. +Import Apple Watch sleep stages, heart rate, breathing rate and HRV from a Health export into the pod. scripts/import-watch-health.py ~/Desktop/apple_health_export --pod 192.168.1.88 --side left Reads export.xml (or the unzipped apple_health_export/ directory) with the same parser as sleep-stage-dataset.py, keeps samples whose sourceName matches ---source (default: Watch), and posts them to the pod's REST API: +--source (default: Watch) and was not written by sleepypod itself, and posts +them to the pod's REST API: POST /api/biometrics/reference-stages SleepAnalysis segments (wake/light/deep/rem) POST /api/biometrics/reference-vitals HeartRate samples + POST /api/biometrics/reference-samples RespiratoryRate (breathing_rate, breaths/min) + and HeartRateVariabilitySDNN (hrv_sdnn, ms) -Heart rate is first-write-wins on side + time + source, so re-running on a -newer export only adds what is new. Stages are stricter: a batch is rejected +Heart rate, breathing rate and HRV are first-write-wins on side + time + +source (+ metric), so re-running on a newer export only adds what is new. Stages are stricter: a batch is rejected (HTTP 400) when a segment overlaps a stored one with different boundaries, e.g. a night the iOS app already posted. Such a batch is skipped with a -warning, heart rate is still imported, and the run exits 1 at the end. +warning, the vitals are still imported, and the run exits 1 at the end. Nothing is written to the pod's own `vitals` table: Watch heart rate lives in -`reference_vitals`, and the Biometrics page draws it as a second line over the -pod's heart rate. +`reference_vitals` (the Biometrics page draws it as a second line over the +pod's heart rate), breathing rate and HRV in `reference_samples`. Options: --days N only samples from the last N days (default 30; 0 = everything) --dry-run parse and count, post nothing - --stages-only / --hr-only + --stages-only / --hr-only (heart rate alone, without breathing rate and HRV) """ import argparse import importlib.util @@ -43,6 +46,18 @@ _spec.loader.exec_module(_ds) parse_export = _ds.parse_export +HK_RR = 'HKQuantityTypeIdentifierRespiratoryRate' +HK_HRV = 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN' +# reference_samples metric, HK type, plausible range (the API enforces the same) +SAMPLE_METRICS = (('breathing_rate', HK_RR, 4, 60), ('hrv_sdnn', HK_HRV, 1, 500)) +# Factors into the canonical unit (breaths/min, ms). Health exports use +# count/min and ms; anything unlisted is skipped instead of stored unconverted. +SAMPLE_UNITS = {HK_RR: {'count/min': 1, 'count/s': 60, 'Hz': 60}, HK_HRV: {'ms': 1, 's': 1000}} +# The sleepypod iOS app writes the pod's own HR, breathing, HRV and sleep stages +# back into Health (sourceName "sleepypod", "sleepypod design"). Those are never +# a reference, whatever --source says. +POD_SOURCE = r'(?i)(sleepy\s*pod|free[\s_-]*sleep)' + # The API caps both endpoints at 2000 items per call. BATCH = 2000 # importReferenceStages rejects a batch whose segments overlap each other. @@ -90,7 +105,11 @@ def __init__(self, url, code, body): def post_json(url, payload, timeout=30): data = json.dumps(payload).encode('utf-8') - req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'}, method='POST') + headers = {'Content-Type': 'application/json'} + token = os.environ.get('SLEEPYPOD_AUTH_TOKEN') + if token: + headers['Authorization'] = 'Bearer ' + token + req = urllib.request.Request(url, data=data, headers=headers, method='POST') try: with urllib.request.urlopen(req, timeout=timeout) as resp: return json.loads(resp.read().decode('utf-8') or '{}') @@ -127,14 +146,18 @@ def export_stream(export): def run(export, pod, side, source_pattern='Watch', days=30, dry_run=False, - stages=True, hr=True, post=post_json, now=None, report=False): + stages=True, hr=True, extra=True, post=post_json, now=None, report=False): now = time.time() if now is None else now cutoff = now - days * 86400 if days > 0 else float('-inf') + quantities = {hk: [] for _, hk, _, _ in SAMPLE_METRICS} with export_stream(export) as xml: - segments, hr_samples = parse_export(xml, source_pattern, cutoff=cutoff) + segments, hr_samples = parse_export(xml, source_pattern, cutoff=cutoff, quantities=quantities, + exclude_pattern=POD_SOURCE, units=SAMPLE_UNITS) segs = [s for s in dedupe_segments(segments) if s[1] >= cutoff] if stages else [] samples = [(t, bpm) for t, bpm in hr_samples if t >= cutoff and 20 <= bpm <= 250] if hr else [] + extras = {metric: [(t, v) for t, v in quantities[hk] if t >= cutoff and lo <= v <= hi] if extra else [] + for metric, hk, lo, hi in SAMPLE_METRICS} def span(times): if not times: @@ -144,12 +167,14 @@ def span(times): return f'{fmt(lo)} → {fmt(hi)}' print(f'{len(segs)} stage segments ({span([s[0] for s in segs])}), ' - f'{len(samples)} heart-rate samples ({span([t for t, _ in samples])}) for side {side}', file=sys.stderr) + f'{len(samples)} heart-rate samples ({span([t for t, _ in samples])}), ' + f"{len(extras['breathing_rate'])} breathing-rate and {len(extras['hrv_sdnn'])} HRV samples for side {side}", + file=sys.stderr) if dry_run: - return {'stages': 0, 'vitals': 0} + return {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0} url = base_url(pod) - written = {'stages': 0, 'vitals': 0} + written = {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0} rejected = 0 for batch in chunks(segs): try: @@ -170,12 +195,21 @@ def span(times): 'samples': [{'timestamp': t, 'heartRate': bpm} for t, bpm in batch], }) written['vitals'] += int(out.get('written', 0)) - print(f"wrote {written['stages']} new stage segments and {written['vitals']} new heart-rate samples to {url}", + for metric, key in (('breathing_rate', 'breathing'), ('hrv_sdnn', 'hrv')): + for batch in chunks(extras[metric]): + out = post(f'{url}/api/biometrics/reference-samples', { + 'side': side, 'source': 'apple_watch', 'metric': metric, + 'samples': [{'timestamp': t, 'value': v} for t, v in batch], + }) + written[key] += int(out.get('written', 0)) + print(f"wrote {written['stages']} new stage segments, {written['vitals']} new heart-rate, " + f"{written['breathing']} new breathing-rate and {written['hrv']} new HRV samples to {url}", file=sys.stderr) if report: - return {**written, 'rejectedStageBatches': rejected, 'matchedStages': len(segs), 'matchedVitals': len(samples)} + return {**written, 'rejectedStageBatches': rejected, 'matchedStages': len(segs), 'matchedVitals': len(samples), + 'matchedBreathing': len(extras['breathing_rate']), 'matchedHrv': len(extras['hrv_sdnn'])} if rejected: - raise SystemExit(f'error: {rejected} stage batch(es) rejected by the pod; heart rate was still imported') + raise SystemExit(f'error: {rejected} stage batch(es) rejected by the pod; vitals were still imported') return written @@ -197,7 +231,8 @@ def main(argv=None): if not os.path.exists(export): ap.error(f'{export} not found') result = run(export, args.pod, args.side, args.source, args.days, args.dry_run, - stages=not args.hr_only, hr=not args.stages_only, report=args.json) + stages=not args.hr_only, hr=not args.stages_only, extra=not (args.hr_only or args.stages_only), + report=args.json) if args.json: print(json.dumps(result)) return 0 diff --git a/scripts/install b/scripts/install index 9d93211fe..224be250d 100755 --- a/scripts/install +++ b/scripts/install @@ -650,7 +650,32 @@ for bin in node npm npx pnpm; do done INSTALL_DIR="/home/dac/sleepypod-core" -GITHUB_REPO="${SLEEPYPOD_GITHUB_REPO:-sleepypod/core}" +REPOSITORY_FILE="/etc/sleepypod/github-repo" +GITHUB_REPO="${SLEEPYPOD_GITHUB_REPO:-}" +# The bootstrap script is standalone, so read the persistent pin before fetching +# any source. A local/archive install with unknown provenance stays unpinned. +if [ -z "$GITHUB_REPO" ] && { [ -e "$REPOSITORY_FILE" ] || [ -L "$REPOSITORY_FILE" ]; }; then + if [ ! -f "$REPOSITORY_FILE" ] || [ -L "$REPOSITORY_FILE" ] || [ ! -r "$REPOSITORY_FILE" ]; then + echo "Error: Update repository metadata must be a readable regular file." >&2 + exit 1 + fi + if [ "$(wc -c < "$REPOSITORY_FILE")" -gt 256 ]; then + echo "Error: Update repository metadata is too large." >&2 + exit 1 + fi + GITHUB_REPO="$(cat "$REPOSITORY_FILE")" + if [ -z "$GITHUB_REPO" ]; then + echo "Error: Empty update repository metadata." >&2 + exit 1 + fi +fi +if [ -z "$GITHUB_REPO" ] && [ "$INSTALL_LOCAL" = false ] && [ -z "$ARTIFACT_URL_OVERRIDE" ]; then + GITHUB_REPO="sleepypod/core" +fi +if [ -n "$GITHUB_REPO" ] && { [[ ! "$GITHUB_REPO" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+$ ]] || [[ "${GITHUB_REPO##*/}" = . || "${GITHUB_REPO##*/}" = .. ]] || [ "${#GITHUB_REPO}" -gt 255 ]; }; then + echo "Error: Update repository must be OWNER/REPO." >&2 + exit 1 +fi SCRIPT_DEFAULT_BRANCH="__BRANCH__" # substituted at release time by .github/workflows/{release,dev-release}.yml # Guard for users curling the raw source from main: the substitution only # happens inside the release tarball, so the file at raw.githubusercontent @@ -813,14 +838,16 @@ fi # exactly `systemctl reboot` — both common systemctl paths so the rule matches # whichever the image ships. secure_path (below) lets sudo resolve it. # The restart rules back System → Health's "Restart " fix, scoped to -# exactly the three biometrics module units. +# exactly the three biometrics module units. Core restart is a separate, +# non-blocking command so the server does not await its own termination. SUDOERS_FILE="/etc/sudoers.d/sleepypod-update" SUDOERS_CONTENT='Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" sleepypod ALL=(root) NOPASSWD: /usr/local/bin/sp-update sleepypod ALL=(root) NOPASSWD: /bin/systemctl reboot, /usr/bin/systemctl reboot sleepypod ALL=(root) NOPASSWD: /bin/systemctl restart sleepypod-piezo-processor.service, /usr/bin/systemctl restart sleepypod-piezo-processor.service sleepypod ALL=(root) NOPASSWD: /bin/systemctl restart sleepypod-sleep-detector.service, /usr/bin/systemctl restart sleepypod-sleep-detector.service -sleepypod ALL=(root) NOPASSWD: /bin/systemctl restart sleepypod-environment-monitor.service, /usr/bin/systemctl restart sleepypod-environment-monitor.service' +sleepypod ALL=(root) NOPASSWD: /bin/systemctl restart sleepypod-environment-monitor.service, /usr/bin/systemctl restart sleepypod-environment-monitor.service +sleepypod ALL=(root) NOPASSWD: /bin/systemctl --no-block restart sleepypod.service, /usr/bin/systemctl --no-block restart sleepypod.service' SUDOERS_TMP=$(mktemp) printf '%s\n' "$SUDOERS_CONTENT" > "$SUDOERS_TMP" chmod 0440 "$SUDOERS_TMP" @@ -931,7 +958,7 @@ fi # Install Node.js via binary download (works on any Linux, no apt required) # ============================================================================ NODE_WANTED=24 -NODE_FULL="24.16.0" +NODE_FULL="24.22.1" NODE_DIR="/usr/local/lib/nodejs" # Detect architecture @@ -1096,7 +1123,7 @@ else RELEASE_API="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/dev-latest" fi - if [ -n "${RELEASE_API:-}" ]; then + if [ -n "${RELEASE_API:-}" ] && [ -z "$ARTIFACT_URL_OVERRIDE" ]; then RELEASE_JSON=$(curl -sf "$RELEASE_API" || true) RELEASE_URL=$(printf '%s' "$RELEASE_JSON" \ | grep -om1 '"browser_download_url": *"[^"]*sleepypod-core\.tar\.gz"' \ @@ -1187,6 +1214,10 @@ else # Fall back to source tarball if [ "$HAS_BUILD" = false ]; then + if [ -n "$ARTIFACT_URL_OVERRIDE" ]; then + echo "Error: The explicitly selected artifact failed; refusing a different source." >&2 + exit 1 + fi # "latest" is not a real branch — use main for the source tarball SOURCE_BRANCH="$DOWNLOAD_BRANCH" [ "$SOURCE_BRANCH" = "latest" ] && SOURCE_BRANCH="main" @@ -1290,6 +1321,16 @@ else SP_BRANCH="$EFFECTIVE_BRANCH" pnpm build fi +# Keep the installed source outside the replaceable app tree and sudo environment. +# This is deliberately independent of archive-provided metadata. +if [ -n "$GITHUB_REPO" ]; then + mkdir -p "$(dirname "$REPOSITORY_FILE")" + repository_temporary="$(mktemp "$(dirname "$REPOSITORY_FILE")/.github-repo.XXXXXX")" + printf '%s\n' "$GITHUB_REPO" > "$repository_temporary" + chmod 0644 "$repository_temporary" + mv -f "$repository_temporary" "$REPOSITORY_FILE" +fi + # Create or preserve environment file if [ -f "$INSTALL_DIR/.env" ]; then echo "Existing .env found, backing up..." diff --git a/scripts/migration/free_sleep.py b/scripts/migration/free_sleep.py new file mode 100644 index 000000000..01a8d41bd --- /dev/null +++ b/scripts/migration/free_sleep.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Stage a verified Free Sleep backup offline; refuse to replace existing paths.""" + +import argparse +from contextlib import closing +from datetime import date, datetime, timezone +import hashlib +import json +import math +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import sqlite3 +import tarfile +import tempfile +from uuid import uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +if __package__: + from . import imported_history +else: + import imported_history + +ROOT = Path(__file__).resolve().parents[2] +DAYS = ('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday') +SIDES = ('left', 'right') +MAX_ARCHIVE_BYTES = 8 * 1024**3 +CLOCK = re.compile(r'^([01]\d|2[0-3]):[0-5]\d$') + + +def checksum(filename): + digest = hashlib.sha256() + with Path(filename).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def read_json(filename): + def invalid_constant(_value): + raise ValueError('Non-finite JSON value') + def unique_object(pairs): + result = {} + for name, value in pairs: + require(name not in result, 'Duplicate JSON field') + result[name] = value + return result + return json.loads(Path(filename).read_text(), parse_constant=invalid_constant, object_pairs_hook=unique_object) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def validate_timezone(zone): + """Check the source timezone before creating databases or stopping writers.""" + require(isinstance(zone, str) and bool(zone), 'Missing device timezone') + try: + ZoneInfo(zone) + except (ZoneInfoNotFoundError, ValueError) as error: + raise ValueError('Device timezone is unavailable or invalid; run the importer ' + 'in its locked scripts/migration environment and verify the source timezone') from error + return zone + + +def number(value, minimum, maximum, label, integer=False): + require(type(value) in (int, float) and math.isfinite(value) + and minimum <= value <= maximum and (not integer or int(value) == value), + 'Invalid ' + label) + return int(value) if integer else value + + +def boolean(value, label): + require(type(value) is bool, 'Invalid ' + label) + return value + + +def clock(value): + require(isinstance(value, str) and CLOCK.fullmatch(value), 'Invalid schedule clock') + return value + + +def calendar_date(value, allow_empty=False): + if allow_empty and value == '': + return value + require(isinstance(value, str) and re.fullmatch(r'\d{4}-\d{2}-\d{2}', value), 'Invalid local date') + require(date.fromisoformat(value).isoformat() == value, 'Invalid local date') + return value + + +def json_text(value): + return json.dumps(value, allow_nan=False, separators=(',', ':')) + + +def write_private(filename, value): + Path(filename).write_text(json.dumps(value, indent=2, allow_nan=False) + '\n') + os.chmod(filename, 0o600) + + +def verify_backup(backup, staging): + """Verify exact archive membership/hashes and extract only regular data files.""" + require(not (backup / 'manifest.json').is_symlink(), 'Manifest must be a regular file') + manifest_hash = checksum(backup / 'manifest.json') + manifest = read_json(backup / 'manifest.json') + require(manifest.get('format') == 'free-sleep-migration/1', 'Unsupported backup format') + expected = manifest.get('files') + require(isinstance(expected, dict), 'Missing backup file manifest') + require(set(manifest.get('archives', {})) == {'data.tar.gz', 'private.tar.gz'}, 'Invalid archive manifest') + require(all(isinstance(name, str) and isinstance(digest, str) and re.fullmatch(r'[0-9a-f]{64}', digest) + for name, digest in expected.items()), 'Invalid member checksum manifest') + observed = {} + for category in ('data', 'private'): + filename = category + '.tar.gz' + archive_path = backup / filename + require(not archive_path.is_symlink() and archive_path.is_file(), 'Missing regular backup archive') + require(checksum(archive_path) == manifest.get('archives', {}).get(filename), 'Archive checksum mismatch') + total_size = 0 + names = set() + with tarfile.open(archive_path, 'r:gz') as archive: + for member in archive: + member_path = PurePosixPath(member.name) + require(not member_path.is_absolute() and '..' not in member_path.parts + and member_path.parts and member_path.parts[0] == category + and '\\' not in member.name and str(member_path) == member.name + and member.name not in names, + 'Unsafe or duplicate backup member') + names.add(member.name) + require(len(names) <= 100_000, 'Too many backup members') + require(member.isdir() or member.isfile(), 'Backup links and special files are unsupported') + if member.isdir(): + continue + total_size += member.size + require(0 <= member.size <= MAX_ARCHIVE_BYTES and total_size <= MAX_ARCHIVE_BYTES, + 'Backup exceeds staging size limit') + require(member.name in expected, 'Unmanifested backup member') + digest = hashlib.sha256() + target = staging / 'legacy' / Path(*member_path.parts[1:]) if category == 'data' else None + if target: + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with closing(archive.extractfile(member)) as stream: + output = target.open('xb') if target else None + try: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + if output: + output.write(chunk) + finally: + if output: + output.close() + os.chmod(target, 0o600) + observed[member.name] = digest.hexdigest() + require(observed[member.name] == expected[member.name], 'Backup member checksum mismatch') + require(observed == expected, 'Backup manifest membership mismatch') + # Keep the exact private archive for recovery; do not put credentials in Core settings. + source = staging / 'source' + source.mkdir(mode=0o700) + for name in ('manifest.json', 'data.tar.gz', 'private.tar.gz'): + shutil.copyfile(backup / name, source / name) + os.chmod(source / name, 0o600) + expected_hash = manifest_hash if name == 'manifest.json' else manifest['archives'][name] + require(checksum(source / name) == expected_hash, 'Backup changed during staging') + return manifest + + +def create_database(filename, migrations): + """Use the checked-in Drizzle SQL and ledger so Core can resume normal migrations.""" + connection = sqlite3.connect(filename) + try: + connection.execute('CREATE TABLE __drizzle_migrations (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)') + previous = -1 + for entry in read_json(migrations / 'meta/_journal.json')['entries']: + require(entry['when'] > previous, 'Migration journal is not ordered') + previous = entry['when'] + sql = (migrations / (entry['tag'] + '.sql')).read_text() + connection.executescript(sql) + connection.execute('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)', + (hashlib.sha256(sql.encode()).hexdigest(), entry['when'])) + connection.commit() + os.chmod(filename, 0o600) + return connection + except BaseException: + connection.close() + raise + + +def map_settings(settings, database, report, now): + require(isinstance(settings, dict), 'Invalid settings document') + zone = validate_timezone(settings.get('timeZone')) + unit = {'fahrenheit': 'F', 'celsius': 'C'}.get(settings.get('temperatureFormat')) + require(unit is not None, 'Invalid temperature preference') + prime = settings['primePodDaily'] + prime_time = clock(prime['time']) + prime_enabled = boolean(prime['enabled'], 'prime enabled') + reboot_requested = boolean(settings['rebootDaily'], 'reboot enabled') + # Free Sleep never registers its reboot job when daily priming is disabled. + reboot_enabled = reboot_requested and prime_enabled + if reboot_requested and not prime_enabled: + report['reviewRequired'].append('Daily reboot retained disabled because Free Sleep gated it on daily priming') + # Free Sleep reboots one hour before its priming clock. + reboot_time = f"{(int(prime_time[:2]) - 1) % 24:02d}{prime_time[2:]}" + database.execute('''INSERT INTO device_settings(id,timezone,temperature_unit,prime_pod_daily, + prime_pod_time,reboot_daily,reboot_time,autopilot_enabled,homekit_enabled,mqtt_enabled) + VALUES (1,?,?,?,?,?,?,0,0,0)''', (zone, unit, prime_enabled, prime_time, reboot_enabled, reboot_time)) + for side in SIDES: + preference = settings[side] + require(isinstance(preference['name'], str) and 1 <= len(preference['name']) <= 20, 'Invalid side name') + away = boolean(preference['awayMode'], 'away mode') + database.execute('INSERT INTO side_settings(side,name,away_mode) VALUES (?,?,?)', (side, preference['name'], away)) + database.execute('INSERT INTO device_state(side,is_powered,is_alarm_vibrating) VALUES (?,0,0)', (side,)) + for gesture in ('doubleTap', 'tripleTap', 'quadTap'): + tap = preference['taps'][gesture] + require(tap['type'] in ('temperature', 'alarm'), 'Unsupported gesture action') + if tap['type'] == 'temperature': + require(tap['change'] in ('increment', 'decrement'), 'Invalid gesture change') + amount = number(tap['amount'], 0, 10, 'gesture amount', integer=True) + values = (side, gesture, 'temperature', tap['change'], amount, None, None, None) + else: + require(tap['behavior'] in ('snooze', 'dismiss') and tap['inactiveAlarmBehavior'] in ('power', 'none'), 'Invalid alarm gesture') + duration = number(tap['snoozeDuration'], 60, 600, 'snooze duration', integer=True) + values = (side, gesture, 'alarm', None, None, tap['behavior'], duration, tap['inactiveAlarmBehavior']) + database.execute('''INSERT INTO tap_gestures(side,tap_type,action_type,temperature_change, + temperature_amount,alarm_behavior,alarm_snooze_duration,alarm_inactive_behavior) + VALUES (?,?,?,?,?,?,?,?)''', values) + for name, override in preference.get('scheduleOverrides', {}).items(): + expires = override.get('expiresAt') + if expires: + expiry = datetime.fromisoformat(expires.replace('Z', '+00:00')) + require(expiry.tzinfo is not None, 'Override expiry lacks timezone') + if expiry.timestamp() * 1000 > now: + report['reviewRequired'].append(f'{side}: active {name} override retained in legacy settings; no Core override was invented') + return zone + + +def event_day(day, at, start): + return DAYS[(DAYS.index(day) + int(at < start)) % 7] + + +def map_schedules(schedules, database, report): + """Translate routine-relative weekdays into the actual Core calendar events.""" + require(set(schedules) == set(SIDES), 'Incomplete schedule sides') + for side in SIDES: + snooze_rows = database.execute("""SELECT alarm_snooze_duration FROM tap_gestures + WHERE side=? AND action_type='alarm' AND alarm_behavior='snooze' AND alarm_snooze_duration IS NOT NULL + ORDER BY CASE tap_type WHEN 'doubleTap' THEN 0 WHEN 'tripleTap' THEN 1 ELSE 2 END""", (side,)).fetchall() + snooze_duration = snooze_rows[0][0] if snooze_rows else 300 + if len({row[0] for row in snooze_rows}) > 1: + report['reviewRequired'].append(f'{side}: alarms use the first snoozing tap duration; differing legacy tap durations require review') + require(set(schedules[side]) == set(DAYS), 'Incomplete schedule week') + for day in DAYS: + routine = schedules[side][day] + require(isinstance(routine['temperatures'], dict) and len(routine['temperatures']) <= 96, + 'A bedtime routine supports at most 96 temperature phases; review the original before migration') + power = routine['power'] + start, end = clock(power['on']), clock(power['off']) + enabled = boolean(power['enabled'], 'power enabled') + require(start != end or not enabled, 'Enabled routine has equal start and end times') + temperature = number(power['onTemperature'], 55, 110, 'power temperature', integer=True) + power_id = database.execute('''INSERT INTO power_schedules(side,day_of_week,on_time,off_time,off_day_offset, + end_action,on_temperature,enabled) VALUES (?,?,?,?,?,'turn_off',?,?)''', + (side, day, start, end, int(end < start), temperature, enabled)).lastrowid + duration = (minutes(end) - minutes(start)) % 1440 + phase_ids, phases = [], [] + for at, target in sorted(routine['temperatures'].items(), key=lambda item: (minutes(clock(item[0])) - minutes(start)) % 1440): + clock(at) + target = number(target, 55, 110, 'routine target', integer=True) + offset = (minutes(at) - minutes(start)) % 1440 + active = enabled and 0 < offset < duration + phase_id = database.execute('''INSERT INTO temperature_schedules(side,day_of_week,time,temperature,enabled) + VALUES (?,?,?,?,?)''', (side, event_day(day, at, start), at, target, active)).lastrowid + phase_ids.append(phase_id) + phases.append({'time': at, 'temperature': target, 'enabled': 0 < offset < duration}) + if not 0 < offset < duration: + report['reviewRequired'].append(f'{side}/{day}: inactive out-of-window target retained disabled') + alarm = routine['alarm'] + at = clock(alarm['time']) + alarm_enabled = boolean(alarm['enabled'], 'alarm enabled') and enabled + intensity = number(alarm['vibrationIntensity'], 1, 100, 'alarm intensity', integer=True) + length = number(alarm['duration'], 0, 180, 'alarm duration', integer=True) + require(alarm['vibrationPattern'] in ('rise', 'double'), 'Invalid alarm pattern') + alarm_temperature = number(alarm['alarmTemperature'], 55, 110, 'alarm temperature', integer=True) + alarm_id = database.execute('''INSERT INTO alarm_schedules(side,day_of_week,time,vibration_intensity, + vibration_pattern,duration,alarm_temperature,wake_window,snooze_duration,snooze_taper,enabled) VALUES (?,?,?,?,?,?,?,0,?,0,?)''', + (side, event_day(day, at, start), at, intensity, alarm['vibrationPattern'], max(10, length), alarm_temperature, snooze_duration, alarm_enabled)).lastrowid + draft = { + 'power': {'onTime': start, 'offTime': end, 'onTemperature': temperature, 'enabled': enabled, 'endAction': 'turn_off'}, + 'temperatures': phases, + 'alarm': {'time': at, 'vibrationIntensity': intensity, 'vibrationPattern': alarm['vibrationPattern'], + 'duration': max(10, length), 'alarmTemperature': alarm_temperature, 'wakeWindow': 0, 'snoozeDuration': snooze_duration, 'snoozeTaper': 0, 'enabled': alarm['enabled']}, + 'notes': '', + } + database.execute('INSERT INTO bedtime_routines(side,day_of_week,configuration,schedule_ids) VALUES (?,?,?,?)', + (side, day, json.dumps(draft), json.dumps({'power': [power_id], 'temperature': phase_ids, 'alarm': [alarm_id]}))) + if alarm_enabled: + report['reviewRequired'].append(f'{side}/{day}: Core alarm warm-up and post-alarm hold require review before enabling this candidate') + report['scheduleSemantics'] = 'Calendar events with explicit bedtime ownership and next-day power-off; original routines retained under legacy/lowdb' + + +def minutes(value): + return int(value[:2]) * 60 + int(value[3:]) + + +def map_comfort(legacy, database, report, now, zone): + filename = legacy / 'analytics/comfort.json' + document = read_json(filename) if filename.exists() else {} + require(isinstance(document, dict) and (not document or set(document) == set(SIDES)), 'Invalid comfort document') + for side in SIDES: + original = document.get(side, {}) + config = original.get('config', {'mode': 'off', 'minimumF': 70, 'maximumF': 95}) + require(config['mode'] in ('off', 'preview', 'active'), 'Invalid comfort mode') + lower = number(config['minimumF'], 55, 110, 'comfort minimum', integer=True) + upper = number(config['maximumF'], lower, 110, 'comfort maximum', integer=True) + for option in ('sensorAware', 'followBedtime'): + if option in config: + boolean(config[option], option) + state = { + 'config': {'mode': 'off', 'minimumF': lower, 'maximumF': upper, 'sensorAware': False, 'followBedtime': False}, + 'offsetF': number(original.get('offsetF', 0), -3, 3, 'learned offset', integer=True), + 'feedbackThrough': calendar_date(original.get('feedbackThrough', ''), allow_empty=True), + 'learnedSession': original.get('learnedSession', ''), + 'pausedUntil': number(original.get('pausedUntil', 0), 0, 8_640_000_000_000_000, 'manual pause', integer=True), + 'lastAppliedAt': number(original.get('lastAppliedAt', 0), 0, now, 'last application', integer=True), + 'lastTargetF': original.get('lastTargetF'), + 'lastSegment': original.get('lastSegment', ''), + } + require(isinstance(state['learnedSession'], str) and len(state['learnedSession']) <= 500 + and isinstance(state['lastSegment'], str) and len(state['lastSegment']) <= 1000, 'Invalid learning cursor') + if state['lastTargetF'] is not None: + number(state['lastTargetF'], 55, 110, 'last target') + database.execute('INSERT INTO comfort_states(side,state) VALUES (?,?)', (side, json_text(state))) + # Old activity lacks reported-state/ownership evidence; keep it as legacy history. + history = original.get('history', []) + require(isinstance(history, list) and len(history) <= 60, 'Invalid comfort history') + for entry in history: + number(entry['at'], 0, 8_640_000_000_000_000, 'legacy activity time', integer=True) + require(isinstance(entry['message'], str), 'Invalid legacy activity message') + if entry['targetF'] is not None: + number(entry['targetF'], 55, 110, 'legacy activity target') + if 'phaseShiftMinutes' in original: + number(original['phaseShiftMinutes'], -30, 30, 'learned timing offset', integer=True) + if 'timingSession' in original: + require(isinstance(original['timingSession'], str), 'Invalid timing cursor') + if config['mode'] != 'off' or config.get('sensorAware') or config.get('followBedtime'): + report['reviewRequired'].append(f'{side}: previous comfort configuration preserved; Core starts Off with unsupported adapters disabled') + journal_path = legacy / 'analytics/journal.json' + entries = read_json(journal_path) if journal_path.exists() else [] + require(isinstance(entries, list) and len(entries) <= 7300, 'Invalid comfort journal') + seen = set() + for entry in entries: + require(isinstance(entry, dict), 'Invalid journal entry') + side, recorded_date = entry.get('side'), calendar_date(entry.get('date')) + require(side in SIDES and (side, recorded_date) not in seen, 'Duplicate or invalid journal side/date') + require(entry.get('comfort') in ('warm', 'cold', 'comfortable'), 'Invalid comfort check-in') + number(entry['restfulness'], 1, 5, 'restfulness', integer=True) + for field in ('caffeine', 'alcohol', 'exercise'): + boolean(entry[field], field) + require(isinstance(entry.get('notes', ''), str) and len(entry.get('notes', '').encode('utf-16-le')) // 2 <= 500, 'Invalid journal notes') + seen.add((side, recorded_date)) + fields = {key: entry[key] for key in ('restfulness', 'comfort', 'caffeine', 'alcohol', 'exercise')} + fields['notes'] = entry.get('notes', '').strip() + record = {'side': side, 'date': recorded_date, 'fields': fields, 'timeZone': zone, + 'origin': 'free-sleep', 'createdAt': None, 'updatedAt': None, 'importedAt': now} + database.execute('INSERT INTO journal_entries(side,local_date,revision,record) VALUES (?,?,?,?)', + (side, recorded_date, str(uuid4()), json_text(record))) + database.execute('INSERT INTO comfort_feedback(side,local_date,comfort,recorded_at) VALUES (?,?,?,?)', + (side, recorded_date, entry['comfort'], now)) + report['journalEntries'] = len(entries) + report['legacyComfortHistory'] = {side: len(document.get(side, {}).get('history', [])) for side in SIDES} + + +def preserve_biometrics(legacy, biometrics, report, now): + """Keep legacy metric definitions intact; do not relabel five-minute HRV as Core HRV.""" + source = legacy / 'free-sleep.db' + require(source.is_file(), 'Missing legacy biometric database') + with closing(sqlite3.connect(source.as_uri() + '?mode=ro', uri=True)) as database: + require(database.execute('PRAGMA integrity_check').fetchone()[0] == 'ok', 'Legacy SQLite integrity failure') + report['legacyMetricRows'] = {} + columns = { + 'vitals': {'id', 'side', 'timestamp', 'heart_rate', 'hrv', 'breathing_rate'}, + 'movement': {'id', 'side', 'timestamp', 'total_movement'}, + 'sleep_records': {'id', 'side', 'entered_bed_at', 'left_bed_at', 'sleep_period_seconds', + 'times_exited_bed', 'present_intervals', 'not_present_intervals'}, + } + for table, required_columns in columns.items(): + require(database.execute("SELECT type FROM sqlite_master WHERE name=?", (table,)).fetchone() == ('table',), 'Missing legacy metric table') + actual_columns = {row[1] for row in database.execute(f'PRAGMA table_info({table})')} + require(required_columns <= actual_columns, 'Unsupported legacy metric schema') + report['legacyMetricRows'][table] = database.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] + for side in SIDES: + filename = legacy / f'{side}_cap_baseline.json' + if not filename.exists(): + report['reviewRequired'].append(f'{side}: no legacy capacitance baseline') + continue + baseline = read_json(filename) + require(isinstance(baseline, dict) and 'format' not in baseline, 'Tagged capacitance needs an explicit format adapter') + channels = {} + for channel in ('out', 'cen', 'in'): + values = baseline[f'{side}_{channel}'] + channels[channel] = { + 'mean': number(values['mean'], 0, 1e15, 'calibration mean'), + 'std': number(values['std'], 1e-12, 1e15, 'calibration noise'), + } + # Pending is intentional: Free Sleep used normalized deviations, Core raw units. + parameters = {'format': 'capSense', 'channels': channels, 'migrationSource': 'free-sleep', + 'qualification': 'required', 'legacyAlgorithm': 'summed-z-score'} + biometrics.execute('''INSERT INTO calibration_profiles(side,sensor_type,status,parameters,created_at) + VALUES (?,'capacitance','pending',?,?)''', (side, json_text(parameters), now // 1000)) + report['reviewRequired'].append(f'{side}: baseline preserved pending occupancy qualification; no quality score or raw-unit threshold inferred') + report['legacyMetricsPath'] = 'legacy/free-sleep.db' + report['legacyMetricSemantics'] = 'Original tables/units retained; not mixed with Core live metrics or used as Preview nights' + + +def stage_backup(backup, output, now=None): + backup = Path(backup).resolve(strict=True) + output = Path(output).absolute() + require(not output.exists() and not output.is_symlink(), 'Output must be a new directory') + output = output.parent.resolve(strict=True) / output.name + require(output.parent.is_dir(), 'Output parent must exist') + require(backup != output and backup not in output.parents, 'Output must be outside the backup') + now = int(datetime.now(timezone.utc).timestamp() * 1000) if now is None else now + report = {'format': 'sleepypod-free-sleep-import/1', 'createdAt': now, 'readyForCutover': False, + 'reviewRequired': [], 'controllersChanged': False} + staging = Path(tempfile.mkdtemp(prefix='.free-sleep-import-', dir=output.parent)) + os.chmod(staging, 0o700) + try: + manifest = verify_backup(backup, staging) + report['sourceArchives'] = manifest['archives'] + legacy = staging / 'legacy' + settings = read_json(legacy / 'lowdb/settingsDB.json') + require(isinstance(settings, dict), 'Invalid settings document') + validate_timezone(settings.get('timeZone')) + with closing(create_database(staging / 'sleepypod.db', ROOT / 'src/db/migrations')) as database: + with closing(create_database(staging / 'biometrics.db', ROOT / 'src/db/biometrics-migrations')) as biometrics: + map_settings(settings, database, report, now) + map_schedules(read_json(legacy / 'lowdb/schedulesDB.json'), database, report) + map_comfort(legacy, database, report, now, settings['timeZone']) + preserve_biometrics(legacy, biometrics, report, now) + report['importedHistory'] = imported_history.prepare( + legacy / 'free-sleep.db', staging / imported_history.FILENAME, settings['timeZone'], now) + report['importedRows'] = {table: database.execute(f'SELECT COUNT(*) FROM {table}').fetchone()[0] + for table in ('power_schedules', 'temperature_schedules', 'alarm_schedules', 'tap_gestures', 'comfort_states', 'comfort_feedback', 'journal_entries')} + for connection in (database, biometrics): + connection.commit() + require(connection.execute('PRAGMA integrity_check').fetchone()[0] == 'ok', 'Imported SQLite integrity failure') + require(not connection.execute('PRAGMA foreign_key_check').fetchall(), 'Imported SQLite foreign-key failure') + report['requiredGates'] = ['Complete installation/service/SSH rollback artifact', 'Native compatibility and overnight editor qualification', + 'Attended hardware and sensor qualification', 'Reviewed activation and seven actual Preview nights'] + report['databaseHashes'] = {name: checksum(staging / name) + for name in ('sleepypod.db', 'biometrics.db', imported_history.FILENAME)} + write_private(staging / 'migration-report.json', report) + for path in staging.rglob('*'): + os.chmod(path, 0o700 if path.is_dir() else 0o600) + require(not output.exists() and not output.is_symlink(), 'Output appeared during import') + staging.rename(output) + return report + finally: + if staging.exists(): + shutil.rmtree(staging) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--backup', type=Path) + parser.add_argument('--output', type=Path) + parser.add_argument('--check-timezone', help='Validate the source timezone without reading a backup or writing files') + arguments = parser.parse_args() + if arguments.check_timezone is not None: + if arguments.backup is not None or arguments.output is not None: + parser.error('--check-timezone cannot be combined with --backup or --output') + try: + validate_timezone(arguments.check_timezone) + except ValueError as error: + parser.error(str(error)) + print(json.dumps({'status': 'timezone_available', 'timezone': arguments.check_timezone})) + raise SystemExit(0) + if arguments.backup is None or arguments.output is None: + parser.error('--backup and --output are required for an import') + os.umask(0o077) + result = stage_backup(arguments.backup, arguments.output) + print(json.dumps({'status': 'staged_for_review', 'readyForCutover': False, + 'importedRows': result['importedRows'], 'reviewItems': len(result['reviewRequired'])})) diff --git a/scripts/migration/imported_history.py b/scripts/migration/imported_history.py new file mode 100644 index 000000000..54fec6538 --- /dev/null +++ b/scripts/migration/imported_history.py @@ -0,0 +1,89 @@ +"""Build a separate query snapshot; never change the byte-preserved legacy database.""" +from contextlib import closing +import hashlib +import json +from pathlib import Path +import sqlite3 + +FORMAT = 'sleepypod-imported-history/1' +FILENAME = 'free-sleep-history.db' +MAX_SECONDS = 253402300799 +MAX_ID = 9007199254740991 +TABLES = { + 'vitals': ('id', 'side', 'timestamp', 'heart_rate', 'hrv', 'breathing_rate'), + 'movement': ('id', 'side', 'timestamp', 'total_movement'), + 'sleep_records': ('id', 'side', 'entered_bed_at', 'left_bed_at', 'sleep_period_seconds', + 'times_exited_bed', 'present_intervals', 'not_present_intervals'), +} +KINDS = {'vitals': 'vitals', 'movement': 'movement', 'presence': 'sleep_records'} +SCHEMA = ''' +CREATE TABLE vitals (id INTEGER PRIMARY KEY, side TEXT, timestamp INTEGER, + heart_rate REAL, hrv REAL, breathing_rate REAL); +CREATE TABLE movement (id INTEGER PRIMARY KEY, side TEXT, timestamp INTEGER, total_movement INTEGER); +CREATE TABLE sleep_records (id INTEGER PRIMARY KEY, side TEXT, entered_bed_at INTEGER, left_bed_at INTEGER, + sleep_period_seconds INTEGER, times_exited_bed INTEGER, present_intervals TEXT, not_present_intervals TEXT); +CREATE INDEX vitals_history_order ON vitals(side, timestamp DESC, id DESC); +CREATE INDEX movement_history_order ON movement(side, timestamp DESC, id DESC); +CREATE INDEX presence_history_order ON sleep_records(side, entered_bed_at DESC, id DESC); +CREATE TABLE imported_history_metadata (id INTEGER PRIMARY KEY CHECK(id=1), document TEXT NOT NULL); +''' + + +def checksum(path): + digest = hashlib.sha256() + with Path(path).open('rb') as source: + for chunk in iter(lambda: source.read(1024*1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def readable_rows(kind): + """Match the reader's timestamp/identity rules; retain excluded rows in both archives.""" + timestamp = 'entered_bed_at' if kind == 'presence' else 'timestamp' + clause = (f"typeof(id)='integer' AND id BETWEEN 1 AND {MAX_ID} " + f"AND typeof({timestamp})='integer' AND {timestamp} BETWEEN 0 AND {MAX_SECONDS}") + if kind == 'presence': + clause += (f" AND typeof(left_bed_at)='integer' AND left_bed_at>entered_bed_at" + f" AND left_bed_at<={MAX_SECONDS}") + return clause + + +def prepare(source, destination, timezone, imported_at): + """Copy supported columns in bounded batches into a DELETE-journal read model.""" + if destination.exists() or destination.is_symlink(): + raise ValueError('Imported history destination already exists') + source_hash = checksum(source) + snapshot_id = hashlib.sha256(f'{FORMAT}\n{source_hash}\n{timezone}'.encode()).hexdigest() + document = {'format': FORMAT, 'source': 'free-sleep', 'snapshotId': snapshot_id, + 'sourceSha256': source_hash, 'timeZone': timezone, 'importedAt': imported_at, + 'sides': {}, 'unassignedRecords': 0} + with closing(sqlite3.connect(source.resolve().as_uri()+'?mode=ro&immutable=1', uri=True)) as original: + with closing(sqlite3.connect(destination)) as history: + history.execute('PRAGMA journal_mode=DELETE') + history.executescript(SCHEMA) + for table, columns in TABLES.items(): + rows = original.execute(f'SELECT {",".join(columns)} FROM {table}') + insert = f'INSERT INTO {table} VALUES ({",".join("?" for _ in columns)})' + while batch := rows.fetchmany(500): + history.executemany(insert, batch) + document['unassignedRecords'] += history.execute( + f"SELECT COUNT(*) FROM {table} WHERE side IS NULL OR side NOT IN ('left','right')").fetchone()[0] + for side in ('left', 'right'): + document['sides'][side] = {} + for kind, table in KINDS.items(): + timestamp = 'entered_bed_at' if kind == 'presence' else 'timestamp' + end = 'left_bed_at' if kind == 'presence' else 'timestamp' + total = history.execute(f'SELECT COUNT(*) FROM {table} WHERE side=?', (side,)).fetchone()[0] + count, first, last = history.execute( + f'SELECT COUNT(*),MIN({timestamp}),MAX({end}) FROM {table} WHERE side=? AND {readable_rows(kind)}', + (side,)).fetchone() + document['sides'][side][kind] = {'records': total, 'readableRecords': count, + 'firstAt': first, 'lastAt': last} + history.execute('INSERT INTO imported_history_metadata VALUES (1,?)', + (json.dumps(document, allow_nan=False, separators=(',', ':')),)) + history.commit() + if history.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: + raise ValueError('Imported history integrity failure') + if checksum(source) != source_hash: + raise ValueError('Legacy database changed while building history') + return document diff --git a/scripts/migration/pyproject.toml b/scripts/migration/pyproject.toml new file mode 100644 index 000000000..79ee74970 --- /dev/null +++ b/scripts/migration/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "sleepypod-migration" +version = "1.0.0" +requires-python = ">=3.10" +dependencies = ["tzdata==2026.5"] + +[tool.uv] +package = false diff --git a/scripts/migration/test_free_sleep.py b/scripts/migration/test_free_sleep.py new file mode 100644 index 000000000..1cdbbbc20 --- /dev/null +++ b/scripts/migration/test_free_sleep.py @@ -0,0 +1,404 @@ +"""Offline importer acceptance tests; all data is synthetic and temporary.""" +import copy +from contextlib import closing +import hashlib +import io +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys +import tarfile +import tempfile +import unittest +from uuid import UUID +from unittest.mock import patch + +import free_sleep as migration + +NOW = 1792958400000 + + +def encoded(value): + return json.dumps(value, allow_nan=False).encode() + + +def fixture_documents(): + taps = { + 'doubleTap': {'type': 'temperature', 'change': 'increment', 'amount': 1}, + 'tripleTap': {'type': 'temperature', 'change': 'decrement', 'amount': 2}, + 'quadTap': {'type': 'alarm', 'behavior': 'snooze', 'snoozeDuration': 300, 'inactiveAlarmBehavior': 'none'}, + } + preference = {'name': 'Sleeper', 'awayMode': False, 'taps': taps, 'scheduleOverrides': {}} + settings = {'id': 'synthetic', 'timeZone': 'America/Los_Angeles', 'temperatureFormat': 'celsius', + 'primePodDaily': {'enabled': False, 'time': '00:15'}, 'rebootDaily': True, + 'left': copy.deepcopy(preference), 'right': copy.deepcopy(preference)} + routine = {'power': {'enabled': False, 'on': '22:00', 'off': '07:00', 'onTemperature': 75}, + 'temperatures': {}, 'alarm': {'enabled': False, 'time': '06:30', 'vibrationIntensity': 50, + 'duration': 0, 'vibrationPattern': 'rise', 'alarmTemperature': 80}} + schedules = {side: {day: copy.deepcopy(routine) for day in migration.DAYS} for side in migration.SIDES} + night = schedules['left']['saturday'] + night['power']['enabled'] = True + night['temperatures'] = {'23:00': 74, '01:00': 73, '12:00': 80, '22:00': 90, '07:00': 90} + night['alarm']['enabled'] = True + state = {'config': {'mode': 'active', 'minimumF': 70, 'maximumF': 95, 'sensorAware': True, 'followBedtime': True}, + 'offsetF': -1, 'feedbackThrough': '2026-10-24', 'learnedSession': 'legacy-session', + 'pausedUntil': NOW + 21600000, 'lastAppliedAt': NOW - 3600000, 'lastTargetF': 74, + 'lastSegment': 'legacy-segment', 'timingSession': 'legacy-session', 'phaseShiftMinutes': 15, + 'history': [{'at': NOW - 3600000, 'message': 'Synthetic comfort action', 'targetF': 74}]} + journal = [{'date': '2026-10-24', 'side': 'left', 'restfulness': 4, 'comfort': 'warm', + 'caffeine': False, 'alcohol': False, 'exercise': True, 'notes': 'Synthetic private note'}] + return settings, schedules, {side: copy.deepcopy(state) for side in migration.SIDES}, journal + + +class ImportTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.backup = self.root / 'backup' + self.backup.mkdir() + self.output = self.root / 'candidate' + self.settings, self.schedules, self.comfort, self.journal = fixture_documents() + legacy = self.root / 'legacy.db' + with closing(sqlite3.connect(legacy)) as database: + database.executescript(''' + CREATE TABLE vitals (id INTEGER PRIMARY KEY, side TEXT, timestamp INTEGER, heart_rate REAL, hrv REAL, breathing_rate REAL); + CREATE TABLE movement (id INTEGER PRIMARY KEY, side TEXT, timestamp INTEGER, total_movement INTEGER); + CREATE TABLE sleep_records (id INTEGER PRIMARY KEY, side TEXT, entered_bed_at INTEGER, left_bed_at INTEGER, + sleep_period_seconds INTEGER, times_exited_bed INTEGER, present_intervals TEXT, not_present_intervals TEXT); + INSERT INTO vitals VALUES (1, 'left', 1792890000, 55, 35, 14); + INSERT INTO movement VALUES (1, 'left', 1792890000, 3); + INSERT INTO sleep_records VALUES (1, 'left', 1792861200, 1792890000, 28000, 1, '[]', '[]'); + ''') + self.database_bytes = legacy.read_bytes() + self.extra = [] + + def build_backup(self): + members = { + 'data/lowdb/settingsDB.json': encoded(self.settings), + 'data/lowdb/schedulesDB.json': encoded(self.schedules), + 'data/analytics/comfort.json': encoded(self.comfort), + 'data/analytics/journal.json': encoded(self.journal), + 'data/free-sleep.db': self.database_bytes, + 'private/features.env': b'SYNTHETIC_KEY=fixture-only-not-real', + } + for side in migration.SIDES: + members[f'data/{side}_cap_baseline.json'] = encoded({f'{side}_{channel}': {'mean': 1000, 'std': 10} for channel in ('out', 'cen', 'in')}) + manifest = {'format': 'free-sleep-migration/1', 'files': {}, 'archives': {}} + for category in ('data', 'private'): + archive_path = self.backup / (category + '.tar.gz') + with tarfile.open(archive_path, 'w:gz') as archive: + for name, value in members.items(): + if not name.startswith(category + '/'): + continue + member = tarfile.TarInfo(name) + member.size = len(value) + archive.addfile(member, io.BytesIO(value)) + manifest['files'][name] = hashlib.sha256(value).hexdigest() + for member, value in self.extra: + if member.name.startswith(category + '/'): + archive.addfile(member, io.BytesIO(value)) + manifest['files'][member.name] = hashlib.sha256(value).hexdigest() + manifest['archives'][archive_path.name] = migration.checksum(archive_path) + migration.write_private(self.backup / 'manifest.json', manifest) + return manifest + + def stage(self): + self.build_backup() + return migration.stage_backup(self.backup, self.output, NOW) + + def assert_clean_failure(self): + self.assertFalse(self.output.exists()) + self.assertFalse(list(self.root.glob('.free-sleep-import-*'))) + + def test_event_times_learning_and_history_round_trip(self): + report = self.stage() + self.assertFalse(report['readyForCutover']) + self.assertFalse(report['controllersChanged']) + with closing(sqlite3.connect(self.output / 'sleepypod.db')) as database: + self.assertEqual(database.execute("SELECT day_of_week,on_time,off_time,off_day_offset FROM power_schedules WHERE enabled=1").fetchall(), [('saturday', '22:00', '07:00', 1)]) + self.assertEqual(database.execute('SELECT day_of_week,time,temperature FROM temperature_schedules WHERE enabled=1 ORDER BY time').fetchall(), [('sunday', '01:00', 73), ('saturday', '23:00', 74)]) + self.assertEqual(database.execute('SELECT day_of_week,time,duration,wake_window FROM alarm_schedules WHERE enabled=1').fetchall(), [('sunday', '06:30', 10, 0)]) + self.assertEqual(database.execute('SELECT timezone,temperature_unit,reboot_daily,reboot_time,autopilot_enabled,homekit_enabled,mqtt_enabled FROM device_settings').fetchone(), ('America/Los_Angeles', 'C', 0, '23:15', 0, 0, 0)) + state = json.loads(database.execute("SELECT state FROM comfort_states WHERE side='left'").fetchone()[0]) + self.assertEqual(state['config'], {'mode': 'off', 'minimumF': 70, 'maximumF': 95, 'sensorAware': False, 'followBedtime': False}) + for field in ('feedbackThrough', 'learnedSession', 'pausedUntil', 'offsetF', 'lastAppliedAt', 'lastTargetF', 'lastSegment'): + self.assertEqual(state[field], self.comfort['left'][field]) + self.assertEqual(database.execute('SELECT local_date,comfort FROM comfort_feedback').fetchall(), [('2026-10-24', 'warm')]) + journal = database.execute('SELECT side,local_date,revision,record FROM journal_entries').fetchone() + self.assertEqual(journal[:2], ('left', '2026-10-24')) + self.assertEqual(str(UUID(journal[2])), journal[2]) + record = json.loads(journal[3]) + self.assertEqual(record, {'side': 'left', 'date': '2026-10-24', + 'fields': {key: value for key, value in self.journal[0].items() if key not in ('side', 'date')}, + 'timeZone': 'America/Los_Angeles', 'origin': 'free-sleep', + 'createdAt': None, 'updatedAt': None, 'importedAt': NOW}) + self.assertEqual(report['importedRows']['journal_entries'], 1) + self.assertEqual(database.execute('SELECT COUNT(*) FROM comfort_events').fetchone()[0], 0) + self.assertEqual(database.execute('SELECT SUM(is_powered) FROM device_state').fetchone()[0], 0) + with closing(sqlite3.connect(self.output / 'biometrics.db')) as database: + self.assertEqual(database.execute('SELECT COUNT(*) FROM vitals').fetchone()[0], 0) + self.assertEqual(database.execute('SELECT status,quality_score FROM calibration_profiles').fetchall(), [('pending', None), ('pending', None)]) + self.assertEqual(report['legacyMetricRows'], {'vitals': 1, 'movement': 1, 'sleep_records': 1}) + self.assertEqual(set(report['databaseHashes']), {'sleepypod.db', 'biometrics.db', 'free-sleep-history.db'}) + history_path = self.output / 'free-sleep-history.db' + self.assertEqual(report['databaseHashes']['free-sleep-history.db'], migration.checksum(history_path)) + with closing(sqlite3.connect(history_path)) as database: + self.assertEqual(database.execute('PRAGMA journal_mode').fetchone()[0], 'delete') + self.assertEqual(database.execute('SELECT side,timestamp,heart_rate FROM vitals').fetchall(), [('left', 1792890000, 55)]) + metadata = json.loads(database.execute('SELECT document FROM imported_history_metadata').fetchone()[0]) + self.assertEqual(metadata, report['importedHistory']) + self.assertEqual(metadata['sourceSha256'], hashlib.sha256(self.database_bytes).hexdigest()) + self.assertEqual(metadata['timeZone'], self.settings['timeZone']) + self.assertEqual(metadata['sides']['left']['presence']['readableRecords'], 1) + self.assertEqual((self.output / 'legacy/free-sleep.db').read_bytes(), self.database_bytes) + self.assertEqual(migration.read_json(self.output / 'legacy/analytics/journal.json'), self.journal) + self.assertEqual(migration.read_json(self.output / 'legacy/analytics/comfort.json'), self.comfort) + self.assertIn('warm-up', '\n'.join(report['reviewRequired'])) + + def test_source_is_untouched_and_credentials_are_only_in_private_archive(self): + self.build_backup() + before = {item.name: item.read_bytes() for item in self.backup.iterdir()} + report = migration.stage_backup(self.backup, self.output, NOW) + self.assertEqual(before, {item.name: item.read_bytes() for item in self.backup.iterdir()}) + for name, content in before.items(): + self.assertEqual((self.output / 'source' / name).read_bytes(), content) + self.assertFalse((self.output / 'legacy/features.env').exists()) + self.assertNotIn('fixture-only-not-real', json.dumps(report)) + for item in [self.output, *self.output.rglob('*')]: + self.assertEqual(item.stat().st_mode & 0o777, 0o700 if item.is_dir() else 0o600) + + def test_history_failure_removes_candidate_and_preserves_backup(self): + self.build_backup() + before = {item.name: item.read_bytes() for item in self.backup.iterdir()} + with patch.object(migration.imported_history, 'prepare', side_effect=OSError('Synthetic full disk')): + with self.assertRaisesRegex(OSError, 'Synthetic full disk'): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + self.assertEqual(before, {item.name: item.read_bytes() for item in self.backup.iterdir()}) + + def test_snooze_settings_survive_import_in_rows_and_owned_routines(self): + self.settings['left']['taps']['tripleTap'] = { + 'type': 'alarm', 'behavior': 'snooze', 'snoozeDuration': 420, + 'inactiveAlarmBehavior': 'none', + } + report = self.stage() + with closing(sqlite3.connect(self.output / 'sleepypod.db')) as database: + for side, duration in [('left', 420), ('right', 300)]: + self.assertEqual(database.execute( + 'SELECT DISTINCT snooze_duration,snooze_taper FROM alarm_schedules WHERE side=?', + (side,), + ).fetchall(), [(duration, 0)]) + routines = database.execute( + 'SELECT configuration FROM bedtime_routines WHERE side=?', (side,), + ).fetchall() + self.assertEqual(len(routines), 7) + for (configuration,) in routines: + alarm = json.loads(configuration)['alarm'] + self.assertEqual((alarm['snoozeDuration'], alarm['snoozeTaper']), (duration, 0)) + self.assertIn('differing legacy tap durations require review', '\n'.join(report['reviewRequired'])) + + def test_drizzle_resumes_both_generated_migration_ledgers_without_reapplying(self): + self.stage() + script = '''import Database from 'better-sqlite3'; + import {drizzle} from 'drizzle-orm/better-sqlite3'; + import {migrate} from 'drizzle-orm/better-sqlite3/migrator'; + import {readFileSync} from 'node:fs'; + const [directory, root] = process.argv.slice(1); + for (const [filename, folder] of [['sleepypod.db','migrations'],['biometrics.db','biometrics-migrations']]) { + const sqlite = new Database(`${directory}/${filename}`); + const before = sqlite.prepare('SELECT * FROM __drizzle_migrations').all(); + const expected = JSON.parse(readFileSync(`${root}/src/db/${folder}/meta/_journal.json`)).entries.length; + if (before.length !== expected) throw Error('Missing ledger entries'); + migrate(drizzle(sqlite), {migrationsFolder: `${root}/src/db/${folder}`}); + if (JSON.stringify(before) !== JSON.stringify(sqlite.prepare('SELECT * FROM __drizzle_migrations').all())) throw Error('Migrations reapplied'); + sqlite.close(); + }''' + subprocess.run(['node', '--input-type=module', '-e', script, str(self.output), str(migration.ROOT)], cwd=migration.ROOT, check=True, capture_output=True) + + def test_existing_core_power_rows_keep_calendar_default_after_upgrade(self): + with closing(sqlite3.connect(':memory:')) as database: + entries = migration.read_json(migration.ROOT / 'src/db/migrations/meta/_journal.json')['entries'] + offset_index = next(index for index, entry in enumerate(entries) if entry['tag'].startswith('0030_')) + for entry in entries[:offset_index]: + database.executescript((migration.ROOT / 'src/db/migrations' / (entry['tag'] + '.sql')).read_text()) + database.execute("INSERT INTO power_schedules(side,day_of_week,on_time,off_time,on_temperature) VALUES ('left','saturday','22:00','07:00',75)") + for entry in entries[offset_index:]: + database.executescript((migration.ROOT / 'src/db/migrations' / (entry['tag'] + '.sql')).read_text()) + self.assertEqual(database.execute('SELECT COUNT(*) FROM bedtime_routines').fetchone()[0], 0) + self.assertEqual(database.execute('SELECT off_day_offset FROM power_schedules').fetchone()[0], 0) + + def test_imported_ownership_is_readable_by_the_typescript_routine_contract(self): + self.stage() + script = """import assert from 'node:assert/strict'; + import {getBedtimeRoutines} from './src/server/bedtimeRoutineStore.ts'; + const left = getBedtimeRoutines('left'); + const right = getBedtimeRoutines('right'); + assert.equal(left.canCreate, true); + assert.equal(left.routines.length, 7); + assert.equal(right.routines.length, 7); + assert(left.routines.every(row => /^[a-f0-9]{64}$/.test(row.revision))); + const saturday = left.routines.find(row => row.dayOfWeek === 'saturday'); + assert.deepEqual(saturday.draft.power, {onTime:'22:00',offTime:'07:00',onTemperature:75,enabled:true,endAction:'turn_off'}); + assert.deepEqual(saturday.draft.temperatures.filter(row => row.enabled).map(row => row.time), ['23:00','01:00']); + assert.equal(saturday.draft.alarm.duration, 10); + assert.equal(saturday.draft.alarm.enabled, true); + assert.equal(saturday.draft.alarm.snoozeDuration, 300); + assert.equal(saturday.draft.alarm.snoozeTaper, 0); + assert.equal(right.routines[0].draft.power.enabled, false); + """ + environment = {**os.environ, 'DATABASE_URL': str(self.output / 'sleepypod.db'), + 'BIOMETRICS_DATABASE_URL': str(self.output / 'biometrics.db')} + result = subprocess.run(['node', '--import', 'tsx', '--input-type=module', '-e', script], + cwd=migration.ROOT, env=environment, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_reboot_runs_only_when_prime_and_reboot_are_enabled(self): + self.settings['primePodDaily']['enabled'] = True + self.stage() + with closing(sqlite3.connect(self.output / 'sleepypod.db')) as database: + self.assertEqual(database.execute('SELECT reboot_daily,reboot_time FROM device_settings').fetchone(), (1, '23:15')) + + def test_same_day_power_and_disabled_routine_targets(self): + routine = self.schedules['left']['saturday'] + routine['power'].update(on='06:00', off='08:00', enabled=False) + routine['temperatures'] = {'07:00': 75} + self.stage() + with closing(sqlite3.connect(self.output / 'sleepypod.db')) as database: + self.assertEqual(database.execute("SELECT off_day_offset FROM power_schedules WHERE side='left' AND day_of_week='saturday'").fetchone()[0], 0) + self.assertEqual(database.execute('SELECT SUM(enabled) FROM temperature_schedules').fetchone()[0], 0) + self.assertEqual(database.execute('SELECT SUM(enabled) FROM alarm_schedules').fetchone()[0], 0) + + def test_rejects_hash_mismatch_and_cleans_temporary_output(self): + self.build_backup() + with (self.backup / 'data.tar.gz').open('ab') as stream: + stream.write(b'tampered') + with self.assertRaisesRegex(ValueError, 'checksum'): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + + def test_rejects_manifest_member_mismatch(self): + manifest = self.build_backup() + manifest['files']['data/missing'] = '0' * 64 + migration.write_private(self.backup / 'manifest.json', manifest) + with self.assertRaisesRegex(ValueError, 'membership'): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + + def test_rejects_traversal_symlinks_duplicates_and_noncanonical_members(self): + cases = [('data/../../escape', tarfile.REGTYPE), ('data/link', tarfile.SYMTYPE), + ('data/free-sleep.db', tarfile.REGTYPE), ('data//alias', tarfile.REGTYPE)] + for name, kind in cases: + with self.subTest(name=name): + member = tarfile.TarInfo(name) + member.type = kind + member.linkname = '../../escape' if kind == tarfile.SYMTYPE else '' + self.extra = [(member, b'')] + self.build_backup() + with self.assertRaises(ValueError): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + self.assertFalse((self.root / 'escape').exists()) + + def test_rejects_existing_output_and_output_inside_backup_through_symlink(self): + self.build_backup() + self.output.mkdir() + (self.output / 'keep').write_text('retained') + with self.assertRaisesRegex(ValueError, 'new directory'): + migration.stage_backup(self.backup, self.output, NOW) + self.assertEqual((self.output / 'keep').read_text(), 'retained') + alias = self.root / 'alias' + alias.symlink_to(self.backup, target_is_directory=True) + with self.assertRaisesRegex(ValueError, 'outside'): + migration.stage_backup(self.backup, alias / 'nested', NOW) + self.assertFalse((self.backup / 'nested').exists()) + + def test_rejects_corrupt_settings_without_publishing_candidate(self): + self.settings['timeZone'] = 'not-a-timezone' + self.build_backup() + with self.assertRaises(Exception): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + + def test_missing_timezone_fails_before_database_creation_and_preserves_source(self): + self.build_backup() + before = {item.name: item.read_bytes() for item in self.backup.iterdir()} + with patch.object(migration, 'ZoneInfo', side_effect=migration.ZoneInfoNotFoundError('missing')): + with patch.object(migration, 'create_database') as create_database: + with self.assertRaisesRegex(ValueError, 'locked scripts/migration environment'): + migration.stage_backup(self.backup, self.output, NOW) + create_database.assert_not_called() + self.assertEqual(before, {item.name: item.read_bytes() for item in self.backup.iterdir()}) + self.assert_clean_failure() + + def test_packaged_timezone_handles_dst_without_system_zoneinfo(self): + # Empty PYTHONTZPATH reproduces the Pod's missing operating-system data. + script = '''from datetime import datetime +from zoneinfo import ZoneInfo +zone = ZoneInfo('America/Los_Angeles') +assert datetime(2026, 1, 15, tzinfo=zone).utcoffset().total_seconds() == -28800 +assert datetime(2026, 7, 15, tzinfo=zone).utcoffset().total_seconds() == -25200 +''' + subprocess.run([sys.executable, '-c', script], check=True, capture_output=True, + env={**os.environ, 'PYTHONTZPATH': ''}) + + def test_timezone_preflight_cli_needs_no_backup_and_rejects_invalid_usage(self): + command = [sys.executable, str(migration.ROOT / 'scripts/migration/free_sleep.py')] + result = subprocess.run([*command, '--check-timezone', 'America/Los_Angeles'], + check=True, capture_output=True, text=True, + env={**os.environ, 'PYTHONTZPATH': ''}) + self.assertEqual(json.loads(result.stdout)['status'], 'timezone_available') + for arguments in ([], ['--check-timezone', 'not-a-timezone'], + ['--check-timezone', 'UTC', '--output', str(self.output)]): + with self.subTest(arguments=arguments): + result = subprocess.run([*command, *arguments], capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assert_clean_failure() + + def test_rejects_a_phase_count_the_bedtime_contract_cannot_read(self): + self.schedules['left']['saturday']['temperatures'] = { + f'{minute // 60:02d}:{minute % 60:02d}': 75 for minute in range(97) + } + self.build_backup() + with self.assertRaisesRegex(ValueError, '96 temperature phases'): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + + def test_rejects_duplicate_feedback_and_out_of_range_learning_state(self): + self.journal.append(copy.deepcopy(self.journal[0])) + self.build_backup() + with self.assertRaisesRegex(ValueError, 'Duplicate'): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + self.journal.pop() + self.comfort['left']['offsetF'] = 4 + self.build_backup() + with self.assertRaisesRegex(ValueError, 'offset'): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + + def test_rejects_nonfinite_and_duplicate_json_fields(self): + filename = self.root / 'invalid.json' + for content in ('{"offsetF":NaN}', '{"offsetF":Infinity}', '{"mode":"off","mode":"active"}'): + filename.write_text(content) + with self.assertRaises(ValueError): + migration.read_json(filename) + + def test_rejects_archive_change_between_validation_and_preservation(self): + self.build_backup() + original_copy = migration.shutil.copyfile + def changed_copy(source, destination): + result = original_copy(source, destination) + if Path(source).name == 'private.tar.gz': + Path(destination).write_bytes(b'changed') + return result + with patch.object(migration.shutil, 'copyfile', side_effect=changed_copy): + with self.assertRaisesRegex(ValueError, 'changed during staging'): + migration.stage_backup(self.backup, self.output, NOW) + self.assert_clean_failure() + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/migration/uv.lock b/scripts/migration/uv.lock new file mode 100644 index 000000000..6b41a45f4 --- /dev/null +++ b/scripts/migration/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 5 +requires-python = ">=3.10" + +[[package]] +name = "sleepypod-migration" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "tzdata" }, +] + +[package.metadata] +requires-dist = [{ name = "tzdata", specifier = "==2026.5" }] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" }, +] diff --git a/scripts/sleep-stage-dataset.py b/scripts/sleep-stage-dataset.py index 37321c606..69dc4e77c 100755 --- a/scripts/sleep-stage-dataset.py +++ b/scripts/sleep-stage-dataset.py @@ -82,21 +82,31 @@ def night_of(dt): # ── Health export ────────────────────────────────────────────────────────── -def parse_export(path, source_pattern='Watch', cutoff=None): +def parse_export(path, source_pattern='Watch', cutoff=None, quantities=None, exclude_pattern=None, units=None): """Stream export.xml and return (stage_segments, hr_samples) for sources - matching source_pattern. + matching source_pattern and, when given, not matching exclude_pattern. stage_segments: [(start_dt, end_dt, stage)] in file order (aware datetimes) hr_samples: [(unix_s, bpm)] sorted by time + quantities: optional {HK quantity type: list}; each list receives that + type's (unix_s, value) samples, sorted by time + units: optional {HK quantity type: {unit: factor}}; values of those + types are multiplied into the canonical unit, and records in + any other unit are skipped rather than imported unconverted """ if isinstance(path, (str, os.PathLike)) and os.path.isdir(path): path = os.path.join(path, 'export.xml') source_re = re.compile(source_pattern) + exclude_re = re.compile(exclude_pattern) if exclude_pattern else None segments, hr = [], [] + quantities = quantities or {} + units = units or {} def add(a): kind = a.get('type') - if kind not in (HK_SLEEP, HK_HR) or not source_re.search(a.get('sourceName', '')): + source = a.get('sourceName', '') + if (kind not in (HK_SLEEP, HK_HR) and kind not in quantities) \ + or not source_re.search(source) or (exclude_re and exclude_re.search(source)): return try: start = datetime.strptime(a['startDate'], '%Y-%m-%d %H:%M:%S %z') @@ -111,11 +121,16 @@ def add(a): segments.append((start, end, stage)) return try: - bpm = float(a['value']) + value = float(a['value']) except (KeyError, ValueError): return - if math.isfinite(bpm): - hr.append((int(start.timestamp()), bpm)) + if kind in units: + factor = units[kind].get(a.get('unit', '')) + if factor is None: + return + value *= factor + if math.isfinite(value): + (hr if kind == HK_HR else quantities[kind]).append((int(start.timestamp()), value)) # Incremental parse, so record layout (line breaks, attribute wrapping) # does not matter. Clearing the root after each record keeps memory flat. @@ -126,6 +141,8 @@ def add(a): add(elem.attrib) root.clear() hr.sort() + for samples in quantities.values(): + samples.sort() return segments, hr diff --git a/scripts/tests/test_import_watch_health.py b/scripts/tests/test_import_watch_health.py index 0055c07ca..0532fa8e2 100644 --- a/scripts/tests/test_import_watch_health.py +++ b/scripts/tests/test_import_watch_health.py @@ -39,12 +39,22 @@ def hr_record(at, bpm, source=WATCH): f'startDate="{hk_time(at)}" endDate="{hk_time(at)}" value="{bpm}"/>\n') -def write_export(path, segments, hr): +def quantity_record(kind, at, value, unit, source=WATCH): + return (f' \n') + + +def write_export(path, segments, hr, rr=(), hrv=(), extra=''): parts = ['\n\n'] for a, b, v in segments: parts.append(sleep_record(T0 + timedelta(minutes=a), T0 + timedelta(minutes=b), v)) for m, bpm in hr: parts.append(hr_record(T0 + timedelta(minutes=m), bpm)) + for m, v in rr: + parts.append(quantity_record('RespiratoryRate', T0 + timedelta(minutes=m), v, 'count/min')) + for m, v in hrv: + parts.append(quantity_record('HeartRateVariabilitySDNN', T0 + timedelta(minutes=m), v, 'ms')) + parts.append(extra) parts.append('\n') with open(path, 'w', encoding='utf-8') as f: f.write(''.join(parts)) @@ -105,14 +115,14 @@ def test_posts_stages_and_heart_rate_to_the_pod_endpoints(self): {'timestamp': int((T0 + timedelta(minutes=6)).timestamp()), 'heartRate': 56.5}, {'timestamp': int((T0 + timedelta(minutes=11)).timestamp()), 'heartRate': 61.0}, ]) - self.assertEqual(written, {'stages': 3, 'vitals': 3}) - self.assertIn('wrote 3 new stage segments and 3 new heart-rate samples', err) + self.assertEqual(written, {'stages': 3, 'vitals': 3, 'breathing': 0, 'hrv': 0}) + self.assertIn('wrote 3 new stage segments, 3 new heart-rate', err) def test_dry_run_posts_nothing_but_reports_counts(self): write_export(self.export, [(0, 20, 'AsleepCore')], [(1, 58)]) post, written, err = self.run_import(dry_run=True) self.assertEqual(post.calls, []) - self.assertEqual(written, {'stages': 0, 'vitals': 0}) + self.assertEqual(written, {'stages': 0, 'vitals': 0, 'breathing': 0, 'hrv': 0}) self.assertIn('1 stage segments', err) self.assertIn('1 heart-rate samples', err) @@ -123,7 +133,7 @@ def test_days_window_drops_old_samples(self): self.assertEqual(len(post.calls[0][1]['segments']), 1) self.assertEqual(len(post.calls[1][1]['samples']), 1) _, written_all, _ = self.run_import(days=0) - self.assertEqual(written_all, {'stages': 2, 'vitals': 2}) + self.assertEqual(written_all, {'stages': 2, 'vitals': 2, 'breathing': 0, 'hrv': 0}) def test_batches_at_the_api_limit(self): write_export(self.export, [], [(m, 60) for m in range(0, 4500)]) @@ -175,6 +185,55 @@ def test_ignores_other_sources(self): post, _, _ = self.run_import(source_pattern='Nope') self.assertEqual(post.calls, []) + def test_posts_breathing_rate_and_hrv_per_metric(self): + write_export(self.export, [], [(1, 58)], rr=[(2, 14.5), (7, 15), (8, 2), (9, 90)], hrv=[(3, 48), (4, 0), (5, 900)]) + post, written, err = self.run_import() + t = lambda m: int((T0 + timedelta(minutes=m)).timestamp()) + self.assertEqual([u.rsplit('/', 1)[1] for u, _ in post.calls], + ['reference-vitals', 'reference-samples', 'reference-samples']) + self.assertEqual(post.calls[1][1], {'side': 'left', 'source': 'apple_watch', 'metric': 'breathing_rate', + 'samples': [{'timestamp': t(2), 'value': 14.5}, {'timestamp': t(7), 'value': 15.0}]}) + self.assertEqual(post.calls[2][1], {'side': 'left', 'source': 'apple_watch', 'metric': 'hrv_sdnn', + 'samples': [{'timestamp': t(3), 'value': 48.0}]}) + self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 2, 'hrv': 1}) + self.assertIn('2 breathing-rate and 1 HRV samples', err) + # --hr-only and --stages-only leave breathing rate and HRV out. + post, _, _ = self.run_import(stages=False, extra=False) + self.assertEqual([u.rsplit('/', 1)[1] for u, _ in post.calls], ['reference-vitals']) + + def test_converts_breathing_and_hrv_units_and_skips_unknown_ones(self): + extra = (quantity_record('RespiratoryRate', T0 + timedelta(minutes=2), 0.25, 'count/s') + + quantity_record('RespiratoryRate', T0 + timedelta(minutes=3), 15, 'count/h') + + quantity_record('HeartRateVariabilitySDNN', T0 + timedelta(minutes=4), 0.042, 's') + + quantity_record('HeartRateVariabilitySDNN', T0 + timedelta(minutes=5), 42, 'us')) + write_export(self.export, [], [(1, 58)], extra=extra) + post, written, _ = self.run_import() + t = lambda m: int((T0 + timedelta(minutes=m)).timestamp()) + self.assertEqual(post.calls[1][1]['samples'], [{'timestamp': t(2), 'value': 15.0}]) + self.assertEqual(post.calls[2][1]['samples'], [{'timestamp': t(4), 'value': 42.0}]) + self.assertEqual(written, {'stages': 0, 'vitals': 1, 'breathing': 1, 'hrv': 1}) + + def test_never_imports_records_sleepypod_wrote_to_health(self): + at = T0 + timedelta(minutes=1) + pod = ''.join([ + hr_record(at, 99, source='sleepypod'), + quantity_record('RespiratoryRate', at, 30, 'count/min', source='sleepypod'), + quantity_record('HeartRateVariabilitySDNN', at, 99, 'ms', source='sleepypod design'), + sleep_record(at, at + timedelta(minutes=30), 'AsleepDeep', source='sleepypod'), + # Even when the source pattern would match it. + hr_record(at, 98, source='sleepypod Watch'), + hr_record(at, 97, source='Free Sleep Watch'), + quantity_record('RespiratoryRate', at, 25, 'count/min', source='FreeSleep'), + quantity_record('HeartRateVariabilitySDNN', at, 97, 'ms', source='Free-Sleep'), + ]) + write_export(self.export, [(0, 20, 'AsleepCore')], [(2, 58)], rr=[(2, 14)], hrv=[(2, 40)], extra=pod) + post, written, _ = self.run_import() + values = [s.get('heartRate', s.get('value')) for _, p in post.calls for s in p.get('samples', [])] + self.assertEqual(values, [58.0, 14.0, 40.0]) + self.assertEqual([s['stage'] for s in post.calls[0][1]['segments']], ['light']) + _, written_any, _ = self.run_import(source_pattern='.') + self.assertEqual(written_any, {'stages': 1, 'vitals': 1, 'breathing': 1, 'hrv': 1}) + def test_base_url_forms(self): self.assertEqual(iw.base_url('192.168.1.88'), 'http://192.168.1.88:3000') self.assertEqual(iw.base_url('pod.local:8080'), 'http://pod.local:8080') diff --git a/src/alarms/contract.ts b/src/alarms/contract.ts new file mode 100644 index 000000000..4410ff74b --- /dev/null +++ b/src/alarms/contract.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +const side = z.enum(['left', 'right']) +const instant = z.iso.datetime({ offset: true }) +const revision = z.string().regex(/^[a-f0-9]{64}$/) +export const nextAlarmReadSchema = z.object({ side }).strict() +// One object keeps the REST/OpenAPI input portable. The service enforces which +// fields each action may contain, as well as the occurrence/revision checks. +export const nextAlarmWriteSchema = z.object({ + side, action: z.enum(['skip', 'time', 'restore']), expectedRevision: revision, + expectedAt: instant.optional(), expectedExpiresAt: instant.optional(), + time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).optional(), +}).strict() +export type NextAlarmWrite = z.infer + +export const occurrenceSchema = z.object({ + scheduleIds: z.array(z.number().int().positive()).min(1), at: instant, originalAt: instant, + time: z.string(), source: z.enum(['routine', 'override']), + duration: z.number(), wakeWindow: z.number(), alarmTemperatureF: z.number(), +}) +export const nextAlarmSchema = z.object({ + apiVersion: z.literal(1), side, timeZone: z.string(), revision, + state: z.enum(['scheduled', 'skipped', 'none', 'away']), + alarm: occurrenceSchema.nullable(), followingAt: instant.nullable(), + canChange: z.boolean(), reason: z.string(), + execution: z.enum(['attempting', 'acknowledged', 'unknown']).nullable(), + acknowledgment: z.literal('accepted_not_physically_verified'), + change: z.object({ + revision: z.uuid(), status: z.enum(['active', 'expired', 'invalidated']), + originalAt: instant, at: instant.nullable(), expiresAt: instant, + }).nullable(), +}) +export type NextAlarm = z.infer +export class NextAlarmError extends Error { + constructor(message: string, readonly code: 'BAD_REQUEST' | 'CONFLICT' | 'PRECONDITION_FAILED') { super(message) } +} diff --git a/src/alarms/dispatch.ts b/src/alarms/dispatch.ts new file mode 100644 index 000000000..e682f3170 --- /dev/null +++ b/src/alarms/dispatch.ts @@ -0,0 +1,19 @@ +import { withinControlRequest, type ControlDispatchScope } from '@/src/controls/context' +import type { Side } from '@/src/hardware/types' +import type { AlarmOccurrence } from './model' +import type { createAlarmStore } from './store' + +/** The DAC queue checks expiry and current ownership again immediately before writing bytes. */ +export async function dispatchAlarm(store: ReturnType, side: Side, candidate: AlarmOccurrence, early: boolean, send: () => Promise) { + const issuedAt = Date.now() + const expiresAt = Math.min(candidate.at + 60_000, issuedAt + 60_000) + const scope: ControlDispatchScope = { + issuedAt, expiresAt, now: Date.now, monotonicNow: () => performance.now(), + monotonicDeadline: performance.now() + Math.max(0, expiresAt - issuedAt), + dispatchCount: 0, deadlineRejected: false, closed: false, + beforeDispatch: (now) => { + if (scope.dispatchCount !== 0 || !store.dispatchable(side, candidate, now, early)) throw new Error('Alarm occurrence changed or was already dispatched; it will not be replayed.') + }, + } + return withinControlRequest(scope, send) +} diff --git a/src/alarms/instance.ts b/src/alarms/instance.ts new file mode 100644 index 000000000..633ea88a5 --- /dev/null +++ b/src/alarms/instance.ts @@ -0,0 +1,7 @@ +import { sqlite } from '@/src/db' +import { createAlarmStore } from './store' + +/** Factory is inert: API reads never initialize the scheduler or DAC. */ +export function alarmStore() { + return createAlarmStore(sqlite) +} diff --git a/src/alarms/model.ts b/src/alarms/model.ts new file mode 100644 index 000000000..fc53ca20c --- /dev/null +++ b/src/alarms/model.ts @@ -0,0 +1,126 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' +import type { Side } from '@/src/hardware/types' +import { DAYS_OF_WEEK } from '@/src/lib/scheduleTime' +import { weeklyWindow, weeklyZone, type WeeklyZone } from '@/src/scheduler/occurrences' +import { alarmTemperatureTargets, alarmWarmupMinutes, ALARM_HOLD_AFTER_MIN, type AlarmOccurrenceCache } from '@/src/temperature/baseline' +import { NextAlarmError, type NextAlarm } from './contract' + +export const alarmRowSchema = z.object({ + id: z.number().int().positive(), side: z.enum(['left', 'right']), dayOfWeek: z.enum(DAYS_OF_WEEK), + time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), + vibrationIntensity: z.number().int().min(1).max(100), vibrationPattern: z.enum(['rise', 'double']), + duration: z.number().int().min(0).max(180), alarmTemperature: z.number().min(55).max(110), + wakeWindow: z.number().int().min(0).max(120), snoozeDuration: z.number().int().nonnegative(), snoozeTaper: z.number().int().min(0).max(100), +}) +export type AlarmRow = z.infer +export const overrideSchema = z.object({ + plan: z.string().regex(/^[a-f0-9]{64}$/), ids: z.array(z.number().int().positive()).min(1), + originalAt: z.number().int().positive(), at: z.number().int().positive().nullable(), expiresAt: z.number().int().positive(), +}).strict() +export type AlarmOverride = z.infer +export interface AlarmContext { + side: Side + timeZone: string + available: boolean + configured: boolean + rows: AlarmRow[] + revision: string | null + override: AlarmOverride | null +} +export interface AlarmOccurrence { + rows: AlarmRow[] + originalAt: number + at: number + skipped: boolean + token: string + source: 'routine' | 'override' +} +export type AlarmExecution = NextAlarm['execution'] +export function alarmDigest(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} +export function alarmPlan(context: AlarmContext): string { + return alarmDigest([context.timeZone, context.rows]) +} +export function validAlarmOverride(context: AlarmContext, now: number): AlarmOverride | null { + const record = context.override + return record && record.plan === alarmPlan(context) && record.expiresAt > now ? record : null +} +function weeklyAt(row: AlarmRow, zone: WeeklyZone, after: number): number { + const [hour, minute] = row.time.split(':').map(Number) + return weeklyWindow(zone, DAYS_OF_WEEK.indexOf(row.dayOfWeek), hour, minute, after).expiresAt +} +export function nextRoutineAlarm(context: AlarmContext, after: number): { at: number, rows: AlarmRow[] } | null { + if (!context.rows.length) return null + const zone = weeklyZone(after, context.timeZone) + const candidates = context.rows.map(row => ({ row, at: weeklyAt(row, zone, after) })) + const at = Math.min(...candidates.map(candidate => candidate.at)) + return { at, rows: candidates.filter(candidate => candidate.at === at).map(candidate => candidate.row) } +} +export function occurrenceAt(context: AlarmContext, originalAt: number, now: number): AlarmOccurrence | null { + const routine = nextRoutineAlarm(context, originalAt - 1) + if (!routine || routine.at !== originalAt) return null + const record = validAlarmOverride(context, now) + const changed = record?.originalAt === originalAt && alarmDigest(record.ids) === alarmDigest(routine.rows.map(row => row.id)) ? record : null + return { rows: routine.rows, originalAt, at: changed?.at ?? originalAt, skipped: changed?.at === null, + source: changed ? 'override' : 'routine', token: alarmDigest([context.timeZone, routine.rows, context.revision, originalAt, changed]) } +} +export function selectedAlarm(context: AlarmContext, now: number): AlarmOccurrence | null { + const changed = validAlarmOverride(context, now) + const originalAt = changed?.originalAt ?? nextRoutineAlarm(context, now - 1)?.at + return originalAt === undefined ? null : occurrenceAt(context, originalAt, now) +} +function profile(row: AlarmRow) { + return { vibrationIntensity: row.vibrationIntensity, vibrationPattern: row.vibrationPattern, duration: row.duration, + alarmTemperature: row.alarmTemperature, wakeWindow: row.wakeWindow, snoozeDuration: row.snoozeDuration, snoozeTaper: row.snoozeTaper } +} +export function consistentAlarm(occurrence: AlarmOccurrence): boolean { + return occurrence.rows.every(row => alarmDigest(profile(row)) === alarmDigest(profile(occurrence.rows[0]))) +} +export function alarmHoldEnd(at: number, rows: AlarmRow[]): number { + return at + Math.max(...rows.map(row => row.duration)) * 1000 + ALARM_HOLD_AFTER_MIN * 60_000 +} +export function alarmClock(at: number, timeZone: string): string { + return new Intl.DateTimeFormat('en-GB', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(at) +} + +/** A user-selected clock time must occur exactly once on the displayed wake date. */ +export function resolveAlarmClock(originalAt: number, time: string, timeZone: string): number { + const formatter = new Intl.DateTimeFormat('en-GB', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + const wall = (at: number) => Object.fromEntries(formatter.formatToParts(at).map(part => [part.type, part.value])) + const date = wall(originalAt) + const [hour, minute] = time.split(':').map(Number) + const desired = Date.UTC(Number(date.year), Number(date.month) - 1, Number(date.day), hour, minute) + const offsets = new Set() + for (const hours of [-48, -24, 0, 24, 48]) { + const sample = desired + hours * 3_600_000 + const parts = wall(sample) + offsets.add(Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute)) - sample) + } + const candidates = [...offsets].map(offset => desired - offset).filter((at) => { + const parts = wall(at) + return parts.year === date.year && parts.month === date.month && parts.day === date.day && `${parts.hour}:${parts.minute}` === time + }) + if (candidates.length !== 1) throw new NextAlarmError('That time is skipped or occurs twice when the clock changes. Choose an unambiguous time on the same wake date.', 'BAD_REQUEST') + return candidates[0] +} + +/** The same effective spans feed arbitration, comfort guards, and delayed shutdown. */ +export function effectiveAlarmTargets(context: AlarmContext, now: number, cache?: AlarmOccurrenceCache) { + if (!context.available) return [] + const changed = validAlarmOverride(context, now) + const targets = alarmTemperatureTargets(context.rows.map(row => ({ id: `alarm:${row.id}`, dayOfWeek: row.dayOfWeek, + time: row.time, temperature: row.alarmTemperature, wakeWindow: row.wakeWindow, duration: row.duration })), context.timeZone, now, cache) + .filter(target => !changed || !context.rows.some(row => changed.ids.includes(row.id) && target.id === `alarm:${row.id}` + && target.startsAt === changed.originalAt - alarmWarmupMinutes(row.wakeWindow) * 60_000)) + if (changed?.at !== null && changed?.at !== undefined) { + for (const row of context.rows.filter(row => changed.ids.includes(row.id))) { + const startsAt = changed.at - alarmWarmupMinutes(row.wakeWindow) * 60_000 + const expiresAt = alarmHoldEnd(changed.at, [row]) + if (now >= startsAt && now < expiresAt) targets.push({ id: `alarm-override:${row.id}`, source: 'schedule', temperature: row.alarmTemperature, + startsAt, expiresAt, createdAt: startsAt, priority: 1 }) + } + } + return targets +} diff --git a/src/alarms/service.ts b/src/alarms/service.ts new file mode 100644 index 000000000..e1b4f1c41 --- /dev/null +++ b/src/alarms/service.ts @@ -0,0 +1,16 @@ +import type { Side } from '@/src/hardware/types' +import type { NextAlarmWrite } from './contract' +import type { createAlarmStore } from './store' + +export async function changeNextAlarm(input: NextAlarmWrite, deps: { + store: ReturnType + now(): number + withSideLock(side: Side, work: () => Promise): Promise + synchronize(side: Side): Promise +}) { + const saved = await deps.withSideLock(input.side, async () => deps.store.write(input, deps.now())) + // Persist first, then register the new timer without holding a hardware lock. + // A lost response is reconciled with GET, never by blindly replaying a write. + const schedulerUpdated = await deps.synchronize(input.side) + return { ...saved, schedulerUpdated } +} diff --git a/src/alarms/store.ts b/src/alarms/store.ts new file mode 100644 index 000000000..88488e5bd --- /dev/null +++ b/src/alarms/store.ts @@ -0,0 +1,141 @@ +import { randomUUID } from 'node:crypto' +import type Database from 'better-sqlite3' +import type { Side } from '@/src/hardware/types' +import { activeSleeperSides, type BedConfiguration } from '@/src/lib/singleSleeper' +import { NextAlarmError, nextAlarmSchema, nextAlarmWriteSchema, type NextAlarm, type NextAlarmWrite } from './contract' +import { alarmClock, alarmDigest, alarmHoldEnd, alarmPlan, alarmRowSchema, consistentAlarm, nextRoutineAlarm, occurrenceAt, overrideSchema, + resolveAlarmClock, selectedAlarm, type AlarmContext, type AlarmExecution, type AlarmOccurrence, type AlarmOverride } from './model' + +const globals = globalThis as typeof globalThis & { __sp_alarmStatements?: WeakMap> } +const statementCaches = globals.__sp_alarmStatements ??= new WeakMap>() +function statement(database: Database.Database, sql: string) { + let cache = statementCaches.get(database) + if (!cache) { + cache = new Map() + statementCaches.set(database, cache) + } + let prepared = cache.get(sql) + if (!prepared) { + prepared = database.prepare(sql) + cache.set(sql, prepared) + } + return prepared +} + +/** Prepared reads are also used by the 1 Hz temperature controller; no runtime is started. */ +export function readAlarmContext(database: Database.Database, side: Side): AlarmContext { + const settings = statement(database, 'SELECT timezone,bed_mode AS bedMode,unused_zone_mode AS unusedZoneMode FROM device_settings LIMIT 1').get() as ({ timezone: string } & BedConfiguration) | undefined + if (!settings?.timezone) return { side, timeZone: 'UTC', configured: false, available: false, rows: [], revision: null, override: null } + try { + new Intl.DateTimeFormat('en', { timeZone: settings.timezone }).format(0) + } + catch { throw new NextAlarmError('The Pod time zone is unavailable.', 'PRECONDITION_FAILED') } + const sides = statement(database, 'SELECT side,away_mode AS awayMode FROM side_settings').all() as { side: Side, awayMode: number }[] + const modes = { ...settings, ...Object.fromEntries(sides.map(row => [row.side, { awayMode: !!row.awayMode }])) } + const rows = statement(database, `SELECT id,side,day_of_week AS dayOfWeek,time,vibration_intensity AS vibrationIntensity, + vibration_pattern AS vibrationPattern,duration,alarm_temperature AS alarmTemperature,wake_window AS wakeWindow, + snooze_duration AS snoozeDuration,snooze_taper AS snoozeTaper FROM alarm_schedules WHERE side=? AND enabled=1 ORDER BY id`).all(side) + const override = statement(database, 'SELECT revision,record FROM alarm_overrides WHERE side=?').get(side) as { revision: string, record: string | null } | undefined + return { side, timeZone: settings.timezone, configured: true, available: activeSleeperSides(modes).includes(side), + rows: rows.map(row => alarmRowSchema.parse(row)), revision: override?.revision ?? null, + override: override?.record ? overrideSchema.parse(JSON.parse(override.record)) : null } +} + +export function createAlarmStore(database: Database.Database) { + const context = (side: Side) => readAlarmContext(database, side) + const execution = (side: Side, occurrence: Pick): AlarmExecution => { + const states = occurrence.rows.map(row => (statement(database, 'SELECT outcome FROM alarm_occurrences WHERE side=? AND schedule_id=? AND original_at=?') + .get(side, row.id, occurrence.originalAt) as { outcome: NonNullable } | undefined)?.outcome) + return states.includes('unknown') ? 'unknown' : states.includes('attempting') ? 'attempting' : states.includes('acknowledged') ? 'acknowledged' : null + } + const page = (side: Side, now: number): NextAlarm => { + const current = context(side) + if (!current.configured) throw new NextAlarmError('Set the Pod time zone before changing alarms.', 'PRECONDITION_FAILED') + const selected = selectedAlarm(current, now) + const status = selected ? execution(side, selected) : null + const changed = current.override + const change = changed + ? { revision: current.revision, status: changed.expiresAt <= now ? 'expired' as const : changed.plan !== alarmPlan(current) ? 'invalidated' as const : 'active' as const, + originalAt: new Date(changed.originalAt).toISOString(), at: changed.at === null ? null : new Date(changed.at).toISOString(), expiresAt: new Date(changed.expiresAt).toISOString() } + : null + const reason = !current.available + ? 'This side is away or inactive.' + : !selected + ? 'No enabled alarm is scheduled.' + : status + ? 'This occurrence has already been attempted. Use the current alarm controls to stop or snooze it.' + : selected.at <= now + ? 'This occurrence has elapsed; it will not be replayed.' + : !consistentAlarm(selected) ? 'Conflicting alarms share this wake time. Resolve them in Schedule before making a one-time change.' : '' + const following = selected ? nextRoutineAlarm(current, Math.max(selected.at, selected.originalAt)) : null + return nextAlarmSchema.parse({ apiVersion: 1, side, timeZone: current.timeZone, + revision: alarmDigest([current, selected, status, change?.status]), + state: !current.available ? 'away' : !selected ? 'none' : selected.skipped ? 'skipped' : 'scheduled', + alarm: selected + ? { scheduleIds: selected.rows.map(row => row.id), at: new Date(selected.at).toISOString(), originalAt: new Date(selected.originalAt).toISOString(), + time: alarmClock(selected.at, current.timeZone), source: selected.source, duration: selected.rows[0].duration, + wakeWindow: selected.rows[0].wakeWindow, alarmTemperatureF: selected.rows[0].alarmTemperature } + : null, + followingAt: following ? new Date(following.at).toISOString() : null, + canChange: reason === '', reason, execution: status, acknowledgment: 'accepted_not_physically_verified', change }) + } + const matches = (side: Side, candidate: AlarmOccurrence, now: number, early: boolean) => { + const current = context(side) + const live = occurrenceAt(current, candidate.originalAt, now) + if (!current.available || !live || live.skipped || live.token !== candidate.token || live.at !== candidate.at) return false + if (!candidate.rows.every(row => live.rows.some(saved => alarmDigest(saved) === alarmDigest(row)))) return false + const earliest = live.at - (early ? candidate.rows[0].wakeWindow * 60_000 : 0) + return now >= earliest && now <= live.at + 60_000 + } + const eligible = (side: Side, candidate: AlarmOccurrence, now: number, early: boolean) => matches(side, candidate, now, early) && execution(side, candidate) === null + return { + context, execution, page, eligible, + dispatchable: (side: Side, candidate: AlarmOccurrence, now: number, early: boolean) => matches(side, candidate, now, early) && execution(side, candidate) === 'attempting', + write(request: NextAlarmWrite, now: number): NextAlarm { + const input = nextAlarmWriteSchema.parse(request) + return database.transaction(() => { + const before = page(input.side, now) + if (before.revision !== input.expectedRevision) throw new NextAlarmError('The next alarm changed. Refresh it before applying this change.', 'CONFLICT') + const current = context(input.side) + let record: AlarmOverride | null = null + if (input.action === 'restore') { + if (input.expectedAt !== undefined || input.time !== undefined || !input.expectedExpiresAt) throw new NextAlarmError('Restore requires only the displayed revision and expiry.', 'BAD_REQUEST') + if (!before.change || before.change.expiresAt !== input.expectedExpiresAt) throw new NextAlarmError('That temporary change is no longer current.', 'CONFLICT') + } + else { + if (input.expectedExpiresAt !== undefined || !input.expectedAt || (input.action === 'time') !== (input.time !== undefined)) throw new NextAlarmError('Specify the displayed occurrence and a clock time only for a time change.', 'BAD_REQUEST') + if (!before.alarm || before.alarm.at !== input.expectedAt || !before.canChange) throw new NextAlarmError(before.reason || 'That alarm is no longer current.', 'CONFLICT') + const selected = selectedAlarm(current, now) + if (!selected) throw new NextAlarmError('That alarm is no longer current.', 'CONFLICT') + const at = input.time ? resolveAlarmClock(selected.at, input.time, current.timeZone) : null + if (at !== null) { + if (at <= now) throw new NextAlarmError('Choose a future time on this alarm’s wake date.', 'BAD_REQUEST') + const following = nextRoutineAlarm(current, selected.originalAt) + if (following && at >= following.at) throw new NextAlarmError('This change would cross another scheduled alarm. Choose an earlier time or edit the weekly schedule.', 'BAD_REQUEST') + } + record = { plan: alarmPlan(current), ids: selected.rows.map(row => row.id), originalAt: selected.originalAt, at, + expiresAt: Math.max(alarmHoldEnd(selected.originalAt, selected.rows), alarmHoldEnd(at ?? selected.originalAt, selected.rows)) } + } + statement(database, `INSERT INTO alarm_overrides(side,revision,record) VALUES (?,?,?) + ON CONFLICT(side) DO UPDATE SET revision=excluded.revision,record=excluded.record`).run(input.side, randomUUID(), record ? JSON.stringify(record) : null) + return page(input.side, now) + }).immediate() + }, + /** Claim before transport. Uncertain/crashed attempts remain consumed after restart. */ + claim(side: Side, candidate: AlarmOccurrence, now: number, early: boolean): boolean { + return database.transaction(() => { + if (!eligible(side, candidate, now, early)) return false + for (const row of candidate.rows) statement(database, `INSERT INTO alarm_occurrences(side,schedule_id,original_at,effective_at,outcome,attempted_at) VALUES (?,?,?,?,?,?)`) + .run(side, row.id, candidate.originalAt, candidate.at, 'attempting', now) + statement(database, 'DELETE FROM alarm_occurrences WHERE attempted_at < ?').run(now - 30 * 86_400_000) + return true + }).immediate() + }, + finish(side: Side, candidate: AlarmOccurrence, outcome: 'acknowledged' | 'unknown'): void { + database.transaction(() => { + for (const row of candidate.rows) statement(database, 'UPDATE alarm_occurrences SET outcome=? WHERE side=? AND schedule_id=? AND original_at=?') + .run(outcome, side, row.id, candidate.originalAt) + }).immediate() + }, + } +} diff --git a/src/alarms/tests/dispatch.test.ts b/src/alarms/tests/dispatch.test.ts new file mode 100644 index 000000000..7bd1f48ff --- /dev/null +++ b/src/alarms/tests/dispatch.test.ts @@ -0,0 +1,60 @@ +import { required } from '@/src/alarms/tests/required' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { beforeControlWrite } from '@/src/controls/context' +import { dispatchAlarm } from '../dispatch' +import { selectedAlarm } from '../model' +import { alarmFixture } from './fixture' +let fixture: ReturnType +beforeEach(() => { + fixture = alarmFixture() + vi.useFakeTimers() + vi.setSystemTime('2026-10-26T07:00:00Z') +}) +afterEach(() => { + fixture.database.close() + vi.useRealTimers() +}) + +it('checks the current plan again after a queued command is delayed', async () => { + const candidate = required(selectedAlarm(fixture.store.context('left'), fixture.now), 'alarm fixture') + expect(fixture.store.claim('left', candidate, Date.now(), false)).toBe(true) + let release!: () => void + const wait = new Promise((resolve) => { + release = resolve + }) + const write = vi.fn() + const sent = dispatchAlarm(fixture.store, 'left', candidate, false, async () => { + await wait + beforeControlWrite() + write() + }) + fixture.database.exec('UPDATE alarm_schedules SET enabled=0') + release() + await expect(sent).rejects.toThrow('changed') + expect(write).not.toHaveBeenCalled() +}) +it('expires at the queue boundary and also rejects wall-clock rollback', async () => { + const candidate = required(selectedAlarm(fixture.store.context('left'), fixture.now), 'alarm fixture') + fixture.store.claim('left', candidate, Date.now(), false) + await expect(dispatchAlarm(fixture.store, 'left', candidate, false, async () => { + vi.setSystemTime('2026-10-26T07:01:00Z') + beforeControlWrite() + })).rejects.toThrow('expired') + vi.setSystemTime('2026-10-26T07:00:00Z') + await expect(dispatchAlarm(fixture.store, 'left', candidate, false, async () => { + vi.setSystemTime('2026-10-26T06:59:59Z') + beforeControlWrite() + })).rejects.toThrow('expired') +}) +it('allows exactly one write in its dispatch scope', async () => { + const candidate = required(selectedAlarm(fixture.store.context('left'), fixture.now), 'alarm fixture') + fixture.store.claim('left', candidate, Date.now(), false) + const write = vi.fn() + await expect(dispatchAlarm(fixture.store, 'left', candidate, false, async () => { + beforeControlWrite() + write() + beforeControlWrite() + write() + })).rejects.toThrow('already dispatched') + expect(write).toHaveBeenCalledTimes(1) +}) diff --git a/src/alarms/tests/fixture.ts b/src/alarms/tests/fixture.ts new file mode 100644 index 000000000..c51ffd5f1 --- /dev/null +++ b/src/alarms/tests/fixture.ts @@ -0,0 +1,19 @@ +import { migratedComfortDatabase } from '@/src/comfort/tests/databaseFixture' +import { createAlarmStore } from '../store' + +export function alarmDatabase(filename?: string) { + const database = migratedComfortDatabase(filename) + database.exec('INSERT OR IGNORE INTO device_settings(id,timezone) VALUES (1,\'UTC\'); INSERT OR IGNORE INTO side_settings(side,name) VALUES (\'left\',\'Left\'),(\'right\',\'Right\')') + return database +} +export function insertAlarm(database: ReturnType, side = 'left', time = '07:00', day = 'monday', window = 0) { + return Number(database.prepare(`INSERT INTO alarm_schedules(side,day_of_week,time,vibration_intensity,vibration_pattern,duration,alarm_temperature,wake_window) + VALUES (?,?,?,50,'rise',60,80,?)`).run(side, day, time, window).lastInsertRowid) +} +export function alarmFixture() { + const database = alarmDatabase() + const id = insertAlarm(database) + const store = createAlarmStore(database) + const now = Date.parse('2026-10-26T05:00:00Z') + return { database, store, id, now } +} diff --git a/src/alarms/tests/occurrence.test.ts b/src/alarms/tests/occurrence.test.ts new file mode 100644 index 000000000..e6b98fbab --- /dev/null +++ b/src/alarms/tests/occurrence.test.ts @@ -0,0 +1,179 @@ +import { required } from '@/src/alarms/tests/required' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createAlarmStore } from '../store' +import { alarmDatabase, alarmFixture, insertAlarm } from './fixture' +import { effectiveAlarmTargets, occurrenceAt, resolveAlarmClock, selectedAlarm } from '../model' +import { changeNextAlarm } from '../service' +import { withSideLock } from '@/src/hardware/sideLock' + +let fixture: ReturnType +beforeEach(() => { + fixture = alarmFixture() +}) +afterEach(() => fixture.database.close()) +const timestamp = (value: string) => Date.parse(`2026-10-26T${value}:00Z`) +function change(action: 'skip' | 'time', time?: string, now = fixture.now) { + const before = fixture.store.page('left', now) + return fixture.store.write({ side: 'left', action, time, expectedAt: required(before.alarm, 'alarm fixture').at, expectedRevision: before.revision }, now) +} +function restore(now = fixture.now) { + const before = fixture.store.page('left', now) + return fixture.store.write({ side: 'left', action: 'restore', expectedRevision: before.revision, expectedExpiresAt: required(before.change, 'alarm fixture').expiresAt }, now) +} + +describe('persisted next-alarm occurrences', () => { + it('skips just one event, restores without weekly mutations, and rejects replay after restore', () => { + const { database, store, now } = fixture + const rows = database.prepare('SELECT * FROM alarm_schedules').all() + const original = store.page('left', now) + const input = { side: 'left' as const, action: 'skip' as const, expectedAt: required(original.alarm, 'alarm fixture').at, expectedRevision: original.revision } + const skipped = store.write(input, now) + expect(skipped).toMatchObject({ state: 'skipped', change: { status: 'active', at: null }, followingAt: '2026-11-02T07:00:00.000Z' }) + expect(() => store.write(input, now)).toThrow('changed') + expect(restore()).toMatchObject({ state: 'scheduled', change: null }) + expect(() => store.write(input, now)).toThrow('changed') + expect(database.prepare('SELECT * FROM alarm_schedules').all()).toEqual(rows) + change('skip') + expect(store.page('left', timestamp('07:17'))).toMatchObject({ state: 'scheduled', change: { status: 'expired' }, alarm: { at: '2026-11-02T07:00:00.000Z' } }) + }) + it('moves warm-up and post-alarm hold to the selected absolute time', () => { + change('time', '08:00') + const context = fixture.store.context('left') + expect(effectiveAlarmTargets(context, timestamp('06:45'))).toEqual([]) + expect(effectiveAlarmTargets(context, timestamp('07:30'))).toMatchObject([{ startsAt: timestamp('07:30'), expiresAt: timestamp('08:16'), temperature: 80 }]) + expect(effectiveAlarmTargets(context, timestamp('08:16'))).toEqual([]) + restore() + expect(effectiveAlarmTargets(fixture.store.context('left'), timestamp('06:45'))).toMatchObject([{ startsAt: timestamp('06:30') }]) + }) + it('retains original suppression when moved earlier, including its whole hold', () => { + change('time', '06:00') + const context = fixture.store.context('left') + expect(effectiveAlarmTargets(context, timestamp('05:45'))).toHaveLength(1) + expect(effectiveAlarmTargets(context, timestamp('06:45'))).toEqual([]) + expect(fixture.store.page('left', timestamp('06:45')).canChange).toBe(false) + expect(fixture.store.page('left', timestamp('07:15')).change?.status).toBe('active') + expect(effectiveAlarmTargets(context, timestamp('07:15'))).toEqual([]) + expect(effectiveAlarmTargets(context, timestamp('07:16'))).toEqual([]) + }) + it('never suppresses the following week or the other physical side', () => { + insertAlarm(fixture.database, 'right') + change('skip') + expect(fixture.store.page('right', fixture.now).state).toBe('scheduled') + expect(effectiveAlarmTargets(fixture.store.context('right'), timestamp('06:40'))).toHaveLength(1) + expect(effectiveAlarmTargets(fixture.store.context('left'), Date.parse('2026-11-02T06:40Z'))).toHaveLength(1) + }) + it('rejects past, different-day, crossed-event and malformed changes', () => { + expect(() => change('time', '04:59')).toThrow('future') + insertAlarm(fixture.database, 'left', '08:00') + expect(() => change('time', '08:00')).toThrow('cross') + expect(() => change('time', '09:00')).toThrow('cross') + const before = fixture.store.page('left', fixture.now) + expect(() => fixture.store.write({ side: 'left', action: 'skip', time: '08:00', expectedAt: required(before.alarm, 'alarm fixture').at, expectedRevision: before.revision }, fixture.now)).toThrow('clock time') + expect(() => fixture.store.write({ side: 'left', action: 'skip', expectedAt: '2026-10-27T07:00:00.000Z', expectedRevision: before.revision }, fixture.now)).toThrow('current') + }) + it('invalidates temporary changes after weekly profile, ownership, or zone changes', () => { + const changed = change('time', '08:00') + fixture.database.exec('UPDATE alarm_schedules SET duration=90') + expect(fixture.store.page('left', fixture.now)).toMatchObject({ change: { status: 'invalidated' }, alarm: { time: '07:00' } }) + expect(() => fixture.store.write({ side: 'left', action: 'restore', expectedRevision: changed.revision, expectedExpiresAt: required(changed.change, 'alarm fixture').expiresAt }, fixture.now)).toThrow('changed') + restore() + change('skip') + fixture.database.exec('UPDATE side_settings SET away_mode=1 WHERE side=\'left\'') + expect(fixture.store.page('left', fixture.now)).toMatchObject({ state: 'away', canChange: false }) + expect(effectiveAlarmTargets(fixture.store.context('left'), timestamp('06:45'))).toEqual([]) + fixture.database.exec('UPDATE device_settings SET timezone=\'America/Los_Angeles\'') + expect(fixture.store.page('left', fixture.now).change?.status).toBe('invalidated') + }) + it('groups identical simultaneous alarms and makes conflicting profiles uneditable', () => { + const duplicate = insertAlarm(fixture.database) + expect(fixture.store.page('left', fixture.now).alarm?.scheduleIds).toEqual([fixture.id, duplicate]) + change('skip') + expect(effectiveAlarmTargets(fixture.store.context('left'), timestamp('06:45'))).toEqual([]) + restore() + fixture.database.prepare('UPDATE alarm_schedules SET vibration_intensity=80 WHERE id=?').run(duplicate) + expect(fixture.store.page('left', fixture.now)).toMatchObject({ canChange: false, reason: expect.stringContaining('Conflicting') }) + }) + it('fails closed on corrupt override storage instead of discarding the user’s skip', () => { + change('skip') + fixture.database.exec('UPDATE alarm_overrides SET record=\'{}\'') + expect(() => fixture.store.page('left', fixture.now)).toThrow() + }) + it('rejects skipped, stale, future and late execution candidates', () => { + const { store, now } = fixture + const candidate = required(selectedAlarm(store.context('left'), now), 'alarm fixture') + expect(store.claim('left', candidate, now, false)).toBe(false) + expect(store.claim('left', candidate, candidate.at + 60_001, false)).toBe(false) + change('skip') + expect(store.claim('left', candidate, candidate.at, false)).toBe(false) + restore() + expect(store.claim('left', candidate, candidate.at, false)).toBe(false) + const current = required(selectedAlarm(store.context('left'), now), 'alarm fixture') + expect(store.claim('left', current, current.at, false)).toBe(true) + expect(store.claim('left', current, current.at, false)).toBe(false) + expect(store.page('left', current.at - 1)).toMatchObject({ canChange: false, execution: 'attempting' }) + }) + it('uses one durable attempt across early wake, edited time, restore and clock callbacks', () => { + fixture.database.exec('UPDATE alarm_schedules SET wake_window=30') + change('time', '08:00') + const candidate = required(selectedAlarm(fixture.store.context('left'), fixture.now), 'alarm fixture') + expect(fixture.store.claim('left', candidate, timestamp('07:29'), true)).toBe(false) + expect(fixture.store.claim('left', candidate, timestamp('07:30'), true)).toBe(true) + fixture.store.finish('left', candidate, 'unknown') + expect(fixture.store.page('left', timestamp('07:31'))).toMatchObject({ execution: 'unknown', canChange: false }) + restore(timestamp('07:31')) + const original = required(occurrenceAt(fixture.store.context('left'), timestamp('07:00'), timestamp('07:31')), 'alarm fixture') + expect(fixture.store.claim('left', original, timestamp('07:31'), false)).toBe(false) + expect(fixture.store.claim('left', candidate, timestamp('08:00'), false)).toBe(false) + }) + it('keeps two connections and restarts from consuming an already attempted event', () => { + const directory = mkdtempSync(join(tmpdir(), 'alarm-restart-')) + try { + const path = join(directory, 'config.db') + let database = alarmDatabase(path) + insertAlarm(database) + let store = createAlarmStore(database) + const page = store.page('left', fixture.now) + store.write({ side: 'left', action: 'time', time: '08:00', expectedRevision: page.revision, expectedAt: required(page.alarm, 'alarm fixture').at }, fixture.now) + database.close() + database = alarmDatabase(path) + store = createAlarmStore(database) + const candidate = required(selectedAlarm(store.context('left'), fixture.now), 'alarm fixture') + expect(candidate.at).toBe(timestamp('08:00')) + expect(store.claim('left', candidate, timestamp('08:00'), false)).toBe(true) + database.close() + database = alarmDatabase(path) + store = createAlarmStore(database) + expect(store.claim('left', candidate, timestamp('08:00'), false)).toBe(false) + expect(store.page('left', timestamp('07:59')).execution).toBe('attempting') + database.close() + } + finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + it('serializes concurrent edits and reports a failed scheduler update after persistence', async () => { + const before = fixture.store.page('left', fixture.now) + const input = { side: 'left' as const, action: 'skip' as const, expectedRevision: before.revision, expectedAt: required(before.alarm, 'alarm fixture').at } + const deps = { store: fixture.store, now: () => fixture.now, withSideLock, synchronize: async () => false } + const results = await Promise.allSettled([changeNextAlarm(input, deps), changeNextAlarm(input, deps)]) + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1) + expect(results.find(result => result.status === 'fulfilled')).toMatchObject({ value: { schedulerUpdated: false, state: 'skipped' } }) + expect(fixture.store.page('left', fixture.now).state).toBe('skipped') + }) +}) + +describe('user-selected wall clocks', () => { + it('rejects nonexistent and repeated DST times without changing the saved wake date', () => { + expect(() => resolveAlarmClock(Date.parse('2026-03-08T15:00Z'), '02:30', 'America/Los_Angeles')).toThrow('skipped or occurs twice') + expect(() => resolveAlarmClock(Date.parse('2026-11-01T15:00Z'), '01:30', 'America/Los_Angeles')).toThrow('skipped or occurs twice') + expect(resolveAlarmClock(Date.parse('2026-11-01T15:00Z'), '03:30', 'America/Los_Angeles')).toBe(Date.parse('2026-11-01T11:30Z')) + }) + it('supports fractional offsets and non-hour daylight-saving changes', () => { + expect(resolveAlarmClock(Date.parse('2026-10-26T01:15Z'), '08:00', 'Asia/Kathmandu')).toBe(Date.parse('2026-10-26T02:15Z')) + expect(() => resolveAlarmClock(Date.parse('2026-10-04T00:00Z'), '02:15', 'Australia/Lord_Howe')).toThrow() + expect(() => resolveAlarmClock(Date.parse('2026-04-05T00:00Z'), '01:45', 'Australia/Lord_Howe')).toThrow() + }) +}) diff --git a/src/alarms/tests/required.ts b/src/alarms/tests/required.ts new file mode 100644 index 000000000..6dce27bdb --- /dev/null +++ b/src/alarms/tests/required.ts @@ -0,0 +1,4 @@ +export function required(value: Value | null | undefined, description = 'alarm fixture'): Value { + if (value === null || value === undefined) throw new Error(`Missing ${description}`) + return value +} diff --git a/src/auth/access.test.ts b/src/auth/access.test.ts new file mode 100644 index 000000000..c08b5091d --- /dev/null +++ b/src/auth/access.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { migratedComfortDatabase } from '@/src/comfort/tests/databaseFixture' +import { authorized, issueSession, requireAccess, SESSION_COOKIE, SESSION_SECONDS, PERSONAL_SESSION_COOKIE } from './access' +import { DELETE, GET, POST } from '../../app/api/auth/session/route' + +const token = 'test-only-owner-key-with-over-thirty-two-characters' +const origin = 'http://pod.local:3000' +const now = 1_790_000_000_000 +let directory: string + +function browserHeaders(session = issueSession(), source = origin) { + return new Headers({ host: 'pod.local:3000', origin: source, cookie: `${SESSION_COOKIE}=${session}` }) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'sleepypod-auth-test-')) + const databasePath = join(directory, 'configuration.db') + migratedComfortDatabase(databasePath).close() + vi.stubEnv('DATABASE_URL', databasePath) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', token) + vi.useFakeTimers() + vi.setSystemTime(now) +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.useRealTimers() + rmSync(directory, { recursive: true, force: true }) +}) + +describe('controller access', () => { + it('fails closed when missing configuration or credentials', () => { + expect(requireAccess(new Request(origin))?.status).toBe(401) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', '') + expect(requireAccess(new Request(origin))?.status).toBe(503) + expect(() => issueSession()).toThrow() + }) + + it('accepts native bearer credentials and rejects query-string tokens', () => { + expect(requireAccess(new Request(origin, { headers: { authorization: `Bearer ${token}` } }))).toBeNull() + expect(requireAccess(new Request(`${origin}/?token=${token}`))?.status).toBe(401) + expect(authorized(new Headers({ authorization: `Bearer ${token}wrong` }))).toBe(false) + }) + + it('requires same-origin cookies for writes and same-host cookies for streams', () => { + expect(authorized(browserHeaders(), 'POST')).toBe(true) + expect(authorized(browserHeaders(undefined, 'http://10.0.1.99:3000'), 'POST')).toBe(false) + const headers = browserHeaders() + headers.delete('origin') + expect(authorized(headers)).toBe(true) + expect(authorized(headers, 'POST')).toBe(false) + expect(authorized(headers, 'GET', true)).toBe(false) + headers.set('origin', origin) + headers.set('host', 'pod.local:3001') + expect(authorized(headers, 'GET', true)).toBe(true) + headers.set('origin', 'http://attacker.local:3000') + expect(authorized(headers, 'GET', true)).toBe(false) + }) + + it('rejects tampering, duplicate cookies, expiry and credentials after rotation', () => { + const session = issueSession() + expect(authorized(browserHeaders(session))).toBe(true) + expect(authorized(browserHeaders(session + 'x'))).toBe(false) + const duplicate = browserHeaders(session) + duplicate.append('cookie', `${SESSION_COOKIE}=${session}`) + expect(authorized(duplicate)).toBe(false) + vi.setSystemTime(now + SESSION_SECONDS * 1000) + expect(authorized(browserHeaders(session))).toBe(false) + vi.setSystemTime(now) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', token + '-rotated') + expect(authorized(browserHeaders(session))).toBe(false) + }) + + it('suppresses old owner browser credentials when a personal scope is present', async () => { + const headers = browserHeaders() + headers.set('cookie', `${headers.get('cookie')}; ${PERSONAL_SESSION_COOKIE}=personal-scope`) + expect(authorized(headers)).toBe(false) + expect(authorized(headers, 'POST')).toBe(false) + expect(authorized(headers, 'GET', true)).toBe(false) + expect(await GET(new Request(origin, { headers })).json()).toMatchObject({ authenticated: false, personalSessionActive: true }) + // An explicit native owner key is separate from browser session state. + headers.set('authorization', `Bearer ${token}`) + expect(authorized(headers)).toBe(true) + }) +}) + +describe('browser session endpoint', () => { + function login(body: string, source = origin) { + return POST(new Request(`${origin}/api/auth/session`, { + method: 'POST', headers: { 'host': 'pod.local:3000', 'origin': source, 'content-type': 'application/json' }, body, + })) + } + + it('issues an HttpOnly session without echoing the key', async () => { + const response = await login(JSON.stringify({ token })) + expect(response.status).toBe(200) + const cookie = response.headers.get('set-cookie') ?? '' + expect(cookie).toContain('HttpOnly; SameSite=Strict') + expect(cookie).not.toContain(token) + const request = new Request(origin, { headers: { host: 'pod.local:3000', cookie } }) + expect(await GET(request).json()).toMatchObject({ authenticated: true }) + expect(DELETE(new Request(origin, { method: 'DELETE', headers: browserHeaders() })).headers.get('set-cookie')).toContain('Max-Age=0') + }) + + it('rejects invalid tokens, malformed or oversized bodies and cross-site login', async () => { + expect((await login(JSON.stringify({ token: 'wrong' }))).status).toBe(401) + expect((await login('{')).status).toBe(400) + expect((await login('x'.repeat(9000))).status).toBe(413) + expect((await login(JSON.stringify({ token }), 'https://attacker.example')).status).toBe(403) + }) + + it('only clears personal scope when the owner explicitly exits it', async () => { + const earlierOwnerLogin = await login(JSON.stringify({ token })) + expect(earlierOwnerLogin.headers.getSetCookie()).toHaveLength(1) + expect(earlierOwnerLogin.headers.get('set-cookie')).not.toContain(PERSONAL_SESSION_COOKIE) + const reconnect = await login(JSON.stringify({ token, exitPersonalSession: true })) + expect(reconnect.headers.getSetCookie()).toHaveLength(3) + expect(reconnect.headers.getSetCookie()[2]).toContain(`${PERSONAL_SESSION_COOKIE}=; Path=/;`) + expect(reconnect.headers.getSetCookie()[2]).toContain('Max-Age=0') + }) +}) diff --git a/src/auth/access.ts b/src/auth/access.ts new file mode 100644 index 000000000..8a9cf50d7 --- /dev/null +++ b/src/auth/access.ts @@ -0,0 +1,94 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import { browserScopeAllows, hasBrowserScope } from './browserScope' +import { legacyPrivacyDenial, legacyPrivacyState } from '@/src/household/legacyPrivacy' + +export const SESSION_COOKIE = 'sleepypod_session' +export const PERSONAL_SESSION_COOKIE = 'sleepypod_personal_session' +export const SESSION_SECONDS = 12 * 60 * 60 +const TOKEN_MIN_LENGTH = 32 + +function ownerToken(): string | null { + const token = process.env.SLEEPYPOD_AUTH_TOKEN + return token && token.length >= TOKEN_MIN_LENGTH ? token : null +} + +function equalSecret(received: string, expected: string): boolean { + return timingSafeEqual(createHash('sha256').update(received).digest(), createHash('sha256').update(expected).digest()) +} + +export function authConfigured(): boolean { + return ownerToken() !== null +} + +export function validOwnerToken(received: unknown): boolean { + const token = ownerToken() + return token !== null && typeof received === 'string' && received.length <= 4096 && equalSecret(received, token) +} + +/** Signed, expiring browser credentials never contain the owner's API token. Rotation invalidates them. */ +export function issueSession(now = Date.now(), scoped = false): string { + const token = ownerToken() + if (!token) throw new Error('Owner authentication is not configured') + const payload = `${Math.floor(now / 1000) + SESSION_SECONDS}.${randomBytes(24).toString('base64url')}` + const version = scoped ? 'v2' : 'v1' + const signature = createHmac('sha256', token).update(`sleepypod-session-${version}:${payload}`).digest('base64url') + return `${scoped ? 'v2.' : ''}${payload}.${signature}` +} + +function validSession(received: string, now = Date.now()): boolean { + const token = ownerToken() + if (!token || received.length > 256) return false + const scoped = received.startsWith('v2.') + const [expires, nonce, signature, extra] = (scoped ? received.slice(3) : received).split('.') + if (extra !== undefined || !/^\d+$/.test(expires ?? '') || !/^[\w-]{32}$/.test(nonce ?? '') || !signature) return false + const remaining = Number(expires) - Math.floor(now / 1000) + if (!Number.isFinite(remaining) || remaining <= 0 || remaining > SESSION_SECONDS) return false + const expected = createHmac('sha256', token).update(`sleepypod-session-${scoped ? 'v2' : 'v1'}:${expires}.${nonce}`).digest('base64url') + return equalSecret(signature, expected) +} + +export function sameOrigin(headers: Headers, allowDifferentPort = false): boolean { + const origin = headers.get('origin') + const host = headers.get('host') + if (!origin || !host) return false + try { + const source = new URL(origin) + if (!['http:', 'https:'].includes(source.protocol)) return false + return allowDifferentPort ? source.hostname === new URL(`http://${host}`).hostname : source.host === host + } + catch { return false } +} + +/** Cookie-authenticated writes and streams also require an explicit same-host browser origin. */ +export function authorized(headers: Headers, method = 'GET', streaming = false): boolean { + const authorization = headers.get('authorization') + if (authorization !== null) { + return authorization.startsWith('Bearer ') && validOwnerToken(authorization.slice(7)) + && (!headers.has('origin') || sameOrigin(headers, streaming)) + } + const cookies = (headers.get('cookie') ?? '').split(';').map(cookie => cookie.trim()) + // An older owner login response must not reactivate owner APIs after a + // personal account switch in another tab. Explicit owner reconnection clears it. + if (!hasBrowserScope(headers) && cookies.some(cookie => cookie.startsWith(`${PERSONAL_SESSION_COOKIE}=`))) return false + const matches = cookies.filter(cookie => cookie.startsWith(`${SESSION_COOKIE}=`)) + if (matches.length !== 1 || !validSession(matches[0].slice(SESSION_COOKIE.length + 1))) return false + const session = matches[0].slice(SESSION_COOKIE.length + 1) + if (session.startsWith('v2.') && !hasBrowserScope(headers)) return false + if (!session.startsWith('v2.') && process.env.SLEEPYPOD_HOUSEHOLD_PREPARATION === '1') return false + if (!browserScopeAllows(headers, 'owner', session)) return false + if (streaming || !['GET', 'HEAD'].includes(method.toUpperCase())) return sameOrigin(headers, streaming) + return !headers.has('origin') || sameOrigin(headers) +} + +export function requireAccess(request: Request): Response | null { + if (authorized(request.headers, request.method)) return legacyPrivacyDenial() + return Response.json({ error: authConfigured() ? 'Authentication required' : 'Owner authentication is not configured' }, { + status: authConfigured() ? 401 : 503, + headers: { 'Cache-Control': 'no-store', 'WWW-Authenticate': 'Bearer' }, + }) +} + +/** Raw streams are an owner legacy surface, never a personal data channel. */ +export function legacyStreamAuthorized(headers: Headers): boolean { + return authorized(headers, 'GET', true) && legacyPrivacyState() === 'legacy' +} diff --git a/src/auth/browserScope.ts b/src/auth/browserScope.ts new file mode 100644 index 000000000..027b0a9b9 --- /dev/null +++ b/src/auth/browserScope.ts @@ -0,0 +1,73 @@ +import { createHash, randomBytes } from 'node:crypto' +import Database from 'better-sqlite3' + +export const BROWSER_SCOPE_COOKIE = 'sleepypod_browser_scope' +const SCOPE_SECONDS = 30 * 24 * 60 * 60 +type ScopeKind = 'owner' | 'personal' | 'signed_out' +export class BrowserScopeConflict extends Error {} +interface ScopeRow { kind: ScopeKind, session_hash: string, expires_at: number } +const digest = (value: string) => createHash('sha256').update(value).digest('hex') + +function scopeKey(headers: Headers): string | null { + const matches = (headers.get('cookie') ?? '').split(';').map(value => value.trim()).filter(value => value.startsWith(`${BROWSER_SCOPE_COOKIE}=`)) + const key = matches.length === 1 ? matches[0].slice(BROWSER_SCOPE_COOKIE.length + 1) : '' + return /^[\w-]{43}$/.test(key) ? key : null +} + +export function hasBrowserScope(headers: Headers): boolean { + return (headers.get('cookie') ?? '').split(';').some(value => value.trim().startsWith(`${BROWSER_SCOPE_COOKIE}=`)) +} + +/** Short independent connections share persisted scope across Next proxy/route workers. */ +function withDatabase(readOnly: boolean, operation: (database: Database.Database) => T): T { + const path = process.env.DATABASE_URL?.replace(/^file:/, '') || './sleepypod.dev.db' + const database = new Database(path, { readonly: readOnly, fileMustExist: true }) + try { + database.pragma('busy_timeout = 5000') + return operation(database) + } + finally { database.close() } +} + +function readScope(headers: Headers): ScopeRow | null { + const key = scopeKey(headers) + if (!key) return null + try { + return withDatabase(true, database => database.prepare('SELECT kind, session_hash, expires_at FROM household_browser_scopes WHERE key_hash = ? AND expires_at > ?').get(digest(key), Date.now()) as ScopeRow | undefined) ?? null + } + catch { return null } +} + +export function personalBrowserScopeActive(headers: Headers): boolean { + return hasBrowserScope(headers) && readScope(headers)?.kind !== 'owner' +} + +export function browserScopeRevision(headers: Headers): string | null { + return readScope(headers)?.session_hash ?? null +} + +/** A delayed Set-Cookie response cannot revive a superseded browser identity. */ +export function browserScopeAllows(headers: Headers, kind: 'owner' | 'personal', session: string): boolean { + if (!hasBrowserScope(headers)) return kind === 'owner' + const scope = readScope(headers) + return scope?.kind === kind && scope.session_hash === digest(session) +} + +export function bindBrowserScope(request: Request, kind: ScopeKind, session: string, connection?: Database.Database): string { + const key = scopeKey(request.headers) ?? randomBytes(32).toString('base64url') + const now = Date.now() + const persist = (database: Database.Database) => { + database.transaction(() => { + database.prepare('DELETE FROM household_browser_scopes WHERE expires_at <= ?').run(now) + const current = database.prepare('SELECT session_hash FROM household_browser_scopes WHERE key_hash = ?').get(digest(key)) as { session_hash: string } | undefined + if (current && request.headers.get('x-sleepypod-browser-revision') !== current.session_hash) + throw new BrowserScopeConflict('The browser account changed. Retry explicitly with its current state.') + database.prepare('INSERT INTO household_browser_scopes (key_hash, kind, session_hash, expires_at) VALUES (?, ?, ?, ?) ON CONFLICT(key_hash) DO UPDATE SET kind = excluded.kind, session_hash = excluded.session_hash, expires_at = excluded.expires_at') + .run(digest(key), kind, digest(kind === 'signed_out' ? randomBytes(32).toString('base64url') : session), now + SCOPE_SECONDS * 1000) + }).immediate() + } + if (connection) persist(connection) + else withDatabase(false, persist) + const secure = new URL(request.url).protocol === 'https:' || process.env.SLEEPYPOD_HTTPS_ONLY === '1' + return `${BROWSER_SCOPE_COOKIE}=${key}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SCOPE_SECONDS}${secure ? '; Secure' : ''}` +} diff --git a/src/auth/privateResponse.test.ts b/src/auth/privateResponse.test.ts new file mode 100644 index 000000000..388410f01 --- /dev/null +++ b/src/auth/privateResponse.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { migratedComfortDatabase } from '@/src/comfort/tests/databaseFixture' +import { bindBrowserScope, browserScopeRevision } from './browserScope' +import { issueSession, SESSION_COOKIE } from './access' +import { ownerRequest, TEST_OWNER_TOKEN } from './tests/support' +import { privateApiResponse } from './privateResponse' + +afterEach(() => vi.unstubAllEnvs()) +const bytes = (value: string) => new TextEncoder().encode(value) +function responseBody(response: Response): ReadableStream { + if (!response.body) throw new Error('Expected response body') + return response.body +} + +describe('private API responses', () => { + it('keeps a streaming error body and adapter headers without caching either identity', async () => { + const response = new Response(new ReadableStream({ start(controller) { + controller.enqueue(new TextEncoder().encode('private fixture')) + controller.close() + } }), { status: 409, headers: { 'Vary': 'Accept, cookie', 'Cache-Control': 'public, max-age=3600', 'Retry-After': '30' } }) + const result = privateApiResponse(ownerRequest('http://pod/api/status'), response) + expect(result.status).toBe(409) + expect(result.headers.get('Cache-Control')).toBe('private, no-store') + expect(result.headers.get('Vary')).toBe('Accept, cookie, Authorization') + expect(result.headers.get('Retry-After')).toBe('30') + expect(response.bodyUsed).toBe(false) + expect(await result.text()).toBe('private fixture') + }) + + it('rejects a response prepared after owner key rotation and cancels its source', async () => { + const request = ownerRequest('http://pod/api/history') + const cancel = vi.fn() + const stopProducer = vi.fn() + const source = new Response(new ReadableStream({ cancel })) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN + '-new') + const response = privateApiResponse(request, source, { onCancel: stopProducer }) + expect(response.status).toBe(401) + expect(await response.text()).not.toContain('history') + expect(cancel).toHaveBeenCalledOnce() + expect(stopProducer).toHaveBeenCalledOnce() + }) + + it('does not prefetch output, and stops between chunks after access changes', async () => { + const request = ownerRequest('http://pod/api/history') + const pull = vi.fn((controller: ReadableStreamDefaultController) => controller.enqueue(bytes('private'))) + const cancel = vi.fn() + const source = new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 })) + const response = privateApiResponse(request, source) + await Promise.resolve() + expect(pull).not.toHaveBeenCalled() + const reader = responseBody(response).getReader() + expect((await reader.read()).value).toEqual(bytes('private')) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN + '-new') + await expect(reader.read()).rejects.toThrow('access changed') + expect(pull).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledOnce() + }) + + it('rechecks after an awaited source read before releasing a private chunk', async () => { + const request = ownerRequest('http://pod/api/history') + let sourceController!: ReadableStreamDefaultController + const cancel = vi.fn() + const response = privateApiResponse(request, new Response(new ReadableStream({ + start(controller) { sourceController = controller }, cancel, + }, { highWaterMark: 0 }))) + const pending = responseBody(response).getReader().read() + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN + '-new') + sourceController.enqueue(bytes('must not escape')) + await expect(pending).rejects.toThrow('access changed') + expect(cancel).toHaveBeenCalledOnce() + }) + + it('honors a persisted browser switch without waiting for cookie expiry', async () => { + const directory = mkdtempSync(join(tmpdir(), 'response-scope-')) + try { + const databasePath = join(directory, 'configuration.db') + migratedComfortDatabase(databasePath).close() + vi.stubEnv('DATABASE_URL', databasePath) + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN) + const session = issueSession(Date.now(), true) + const scope = bindBrowserScope(new Request('http://pod/api/auth/session'), 'owner', session).split(';')[0] + const headers = new Headers({ host: 'pod', origin: 'http://pod', cookie: `${scope}; ${SESSION_COOKIE}=${session}` }) + const response = privateApiResponse(new Request('http://pod/api/history', { headers }), Response.json({ private: true })) + const revision = browserScopeRevision(headers) + if (!revision) throw new Error('Expected browser scope') + headers.set('x-sleepypod-browser-revision', revision) + bindBrowserScope(new Request('http://pod/api/household/session', { headers }), 'personal', 'a-different-person-session') + await expect(response.text()).rejects.toThrow('access changed') + } + finally { rmSync(directory, { recursive: true, force: true }) } + }) + + it('rechecks download-specific restrictions even when the credential stays valid', async () => { + const request = ownerRequest('http://pod/api/db-backup') + let restricted = false + const cancel = vi.fn() + const response = privateApiResponse(request, new Response(new ReadableStream({ cancel })), { + additionalAccess: () => restricted ? Response.json({ error: 'Personal household' }, { status: 403 }) : null, + }) + restricted = true + await expect(response.text()).rejects.toThrow('access changed') + expect(cancel).toHaveBeenCalledOnce() + }) + + it.each(['abort', 'cancel'] as const)('cancels a blocked producer on %s without waiting for another chunk', async (reason) => { + const controller = new AbortController() + const request = ownerRequest('http://pod/api/history', { signal: controller.signal }) + const cancel = vi.fn() + const stopProducer = vi.fn() + const response = privateApiResponse(request, new Response(new ReadableStream({ cancel })), { onCancel: stopProducer }) + const reader = responseBody(response).getReader() + const pending = reader.read() + if (reason === 'abort') { + controller.abort() + await expect(pending).rejects.toThrow('interrupted') + } + else { + await reader.cancel() + expect((await pending).done).toBe(true) + } + expect(cancel).toHaveBeenCalledOnce() + expect(stopProducer).toHaveBeenCalledOnce() + }) + + it('preserves a valid empty response', () => { + const response = privateApiResponse(ownerRequest('http://pod/api/control'), new Response(null, { status: 204 })) + expect(response.status).toBe(204) + expect(response.body).toBeNull() + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) +}) diff --git a/src/auth/privateResponse.ts b/src/auth/privateResponse.ts new file mode 100644 index 000000000..4c6c95b28 --- /dev/null +++ b/src/auth/privateResponse.ts @@ -0,0 +1,78 @@ +import { requireAccess } from './access' + +interface PrivateResponseOptions { + /** A download can acquire a stricter policy while its source is being read. */ + additionalAccess?: () => Response | null + onCancel?: () => void +} + +function privateHeaders(response: Response): Headers { + const headers = new Headers(response.headers) + headers.set('Cache-Control', 'private, no-store') + const vary = (headers.get('Vary') ?? '').split(',').map(value => value.trim()).filter(Boolean) + for (const identity of ['Authorization', 'Cookie']) { + if (!vary.some(value => value.toLowerCase() === identity.toLowerCase())) vary.push(identity) + } + headers.set('Vary', vary.join(', ')) + return headers +} + +/** Recheck after awaited work and at each output boundary, including an awaited read. */ +export function privateApiResponse(request: Request, response: Response, options: PrivateResponseOptions = {}): Response { + const access = () => request.signal.aborted + ? Response.json({ error: 'Request interrupted' }, { status: 408 }) + : requireAccess(request) ?? options.additionalAccess?.() ?? null + const rejected = access() + if (rejected) { + options.onCancel?.() + void response.body?.cancel().catch(() => {}) + return new Response(rejected.body, { status: rejected.status, headers: privateHeaders(rejected) }) + } + if (!response.body) return new Response(null, { status: response.status, statusText: response.statusText, headers: privateHeaders(response) }) + + const reader = response.body.getReader() + let settled = false + let abort: () => void + const finish = () => { + settled = true + request.signal.removeEventListener('abort', abort) + } + const cancelSource = (reason?: unknown) => { + if (settled) return + finish() + options.onCancel?.() + void reader.cancel(reason).catch(() => {}).finally(() => reader.releaseLock()) + } + const body = new ReadableStream({ + start(controller) { + abort = () => { + if (settled) return + controller.error(new Error('Private response interrupted')) + cancelSource() + } + request.signal.addEventListener('abort', abort, { once: true }) + if (request.signal.aborted) abort() + }, + async pull(controller) { + try { + if (access()) throw new Error('Private response access changed') + const chunk = await reader.read() + if (settled) return + if (access()) throw new Error('Private response access changed') + if (chunk.done) { + finish() + reader.releaseLock() + controller.close() + } + else controller.enqueue(chunk.value) + } + catch (error) { + if (settled) return + controller.error(error) + cancelSource(error) + } + }, + cancel: cancelSource, + }, { highWaterMark: 0 }) + return new Response(body, { status: response.status, statusText: response.statusText, headers: privateHeaders(response) }) +} diff --git a/src/auth/tests/support.ts b/src/auth/tests/support.ts new file mode 100644 index 000000000..5a810208e --- /dev/null +++ b/src/auth/tests/support.ts @@ -0,0 +1,35 @@ +import type * as FileSystem from 'node:fs' +import type DatabaseConstructor from 'better-sqlite3' +import { afterEach, beforeEach, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Route tests may mock their application database. Access checks still read a +// real, isolated privacy boundary instead of depending on a checkout database. +const { mkdtempSync, rmSync } = await vi.importActual('node:fs') +const { default: PrivacyDatabase } = await vi.importActual<{ default: typeof DatabaseConstructor }>('better-sqlite3') +let privacyDirectory: string +let previousDatabasePath: string | undefined +beforeEach(() => { + previousDatabasePath = process.env.DATABASE_URL + privacyDirectory = mkdtempSync(join(tmpdir(), 'sleepypod-legacy-access-')) + const path = join(privacyDirectory, 'privacy.db') + const database = new PrivacyDatabase(path) + database.exec('CREATE TABLE household_privacy_boundary (id INTEGER PRIMARY KEY, required_at INTEGER NOT NULL, reason TEXT NOT NULL)') + database.close() + vi.stubEnv('DATABASE_URL', path) +}) +afterEach(() => { + rmSync(privacyDirectory, { recursive: true, force: true }) + if (previousDatabasePath === undefined) delete process.env.DATABASE_URL + else process.env.DATABASE_URL = previousDatabasePath +}) + +export const TEST_OWNER_TOKEN = 'test-only-sleepypod-owner-key-not-for-production' + +export function ownerRequest(input: RequestInfo | URL, init?: RequestInit): Request { + vi.stubEnv('SLEEPYPOD_AUTH_TOKEN', TEST_OWNER_TOKEN) + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${TEST_OWNER_TOKEN}`) + return new Request(input, { ...init, headers }) +} diff --git a/src/automation/engine.ts b/src/automation/engine.ts index ec16d4489..fed414e4f 100644 --- a/src/automation/engine.ts +++ b/src/automation/engine.ts @@ -11,7 +11,7 @@ * - Runaway guard: a rule exceeding N hardware actions/hour auto-disables. * - Manual-override hold: touching the dial suspends autopilot on that side. * - Run-once gate: never writes a side with an active run-once session. - * - Dry-run: notify-only; logs would-fire events without touching hardware. + * - Dry-run: audit-only; logs would-fire events without delivering alerts or touching hardware. * * Every evaluation (i.e. every tick where a rule's trigger is active) writes one * row to automation_runs with outcome fired/skipped/clamped/dry_run/error — the @@ -41,6 +41,7 @@ import { type Side, } from './types' import { WindowStore } from './windows' +import { finiteSignal } from './signalValidity' /** How many minutes after a timeOfDay slot a late tick may still fire it. */ const TIME_OF_DAY_GRACE_MIN = 10 @@ -58,7 +59,7 @@ export interface AutomationEngineDeps { /** Persist enabled=false when the runaway guard trips. */ disableRule: (automationId: number) => Promise /** Side-effect for notify actions (e.g. push/log); never touches hardware. */ - notify: (automationId: number, message: string) => void + notify: (automationId: number, message: string) => undefined | { notificationId: number, duplicate: boolean } log?: (msg: string) => void } @@ -219,7 +220,8 @@ export class AutomationEngine { if (lease.expiresAt <= now) this.leases[side].delete(id) } } - const snapshot = this.deps.signals.read() + const snapshot = Object.fromEntries(Object.entries(this.deps.signals.read()) + .map(([key, value]) => [key, finiteSignal(value)])) const { nowMinutes, dayOfWeek, dateKey } = this.deps.clock() // Feed windowed-aggregate buffers, then prune to the largest window asked. @@ -383,12 +385,11 @@ export class AutomationEngine { return } - // Policies remain valid between triggers, but a stopped engine cannot keep - // owning a side forever. Conditions are checked on every evaluator tick. - for (const side of ['left', 'right'] as const) { - for (const [id, lease] of this.leases[side]) { - if (id.startsWith(`rule:${rule.id}:`) && lease.policy) lease.expiresAt = now + 2 * AUTOMATION_TICK_MS - } + // Recheck action inputs between triggers as well as the IF condition. + const withdrawn = this.refreshPolicies(rule, ctx, now) + if (withdrawn.length > 0) { + await this.deps.recordRun(rule.id, 'skipped', { reason: 'policy-input-unavailable', requests: withdrawn }) + if (generation !== this.generation) return } if (!triggered && !unownedPolicies.size) return @@ -434,6 +435,41 @@ export class AutomationEngine { } } + /** Relative actions use the routine baseline, never their own previous output. */ + private actionContext(ctx: EvalContext): EvalContext { + return { + ...ctx, + windows: this.actionWindows, + signal: (key) => { + const target = /^(left|right)\.(targetTemperature|currentTemperature)$/.exec(key) + return target ? this.deps.control.automationBaseline(target[1] as Side) : ctx.signal(key) + }, + } + } + + /** Withdraw live policies with missing inputs; leave explicit one-shot holds intact. */ + private refreshPolicies(rule: AutomationRule, ctx: EvalContext, now: number): string[] { + const withdrawn: string[] = [] + const actionContext = this.actionContext(ctx) + const triggerAvailable = rule.trigger.kind !== 'signalChange' || ctx.signal(rule.trigger.signal) !== undefined + for (const [index, action] of rule.actions.entries()) { + if (action.kind === 'notify') continue + const requestId = `rule:${rule.id}:${index}` + for (const side of ['left', 'right'] as const) { + const lease = this.leases[side].get(requestId) + if (!lease?.policy) continue + if (!triggerAvailable || (action.temp && evaluateExpr(action.temp, actionContext) === undefined)) { + this.leases[side].delete(requestId) + withdrawn.push(`${side}:${requestId}`) + } + else { + lease.expiresAt = now + 2 * AUTOMATION_TICK_MS + } + } + } + return withdrawn + } + private async runAction( rule: AutomationRule, action: Action, @@ -442,8 +478,9 @@ export class AutomationEngine { now: number, ): Promise { if (action.kind === 'notify') { - this.deps.notify(rule.id, action.message) - return [{ kind: 'notify', notified: true }] + if (rule.dryRun) return [{ kind: 'notify', dryRun: true }] + const receipt = this.deps.notify(rule.id, action.message) + return [{ kind: 'notify', notified: true, ...receipt }] } const sides: Side[] = action.side ? [action.side] : (rule.side ? [rule.side] : ['left', 'right']) const results: ActionResult[] = [] @@ -463,14 +500,7 @@ export class AutomationEngine { results.push({ kind: action.kind, side, requestId: id, temp: previous.temperature, antiThrash: true }) continue } - const actionContext: EvalContext = { - ...ctx, - windows: this.actionWindows, - signal: (key) => { - const target = /^(left|right)\.(targetTemperature|currentTemperature)$/.exec(key) - return target ? this.deps.control.automationBaseline(target[1] as Side) : ctx.signal(key) - }, - } + const actionContext = this.actionContext(ctx) const raw = expr ? evaluateExpr(expr, actionContext) : 75 if (raw === undefined) { this.leases[side].delete(id) @@ -509,6 +539,8 @@ interface ActionResult { kind: Action['kind'] side?: Side notified?: boolean + notificationId?: number + duplicate?: boolean sent?: boolean dryRun?: boolean antiThrash?: boolean diff --git a/src/automation/expressions.ts b/src/automation/expressions.ts index d4878f19e..15b1768b2 100644 --- a/src/automation/expressions.ts +++ b/src/automation/expressions.ts @@ -7,6 +7,7 @@ import type { DayOfWeek, Expr } from './types' import type { WindowStore } from './windows' +import { finiteSignal } from './signalValidity' export interface EvalContext { /** Resolve a scalar signal key (e.g. `left.currentTemperature`) to a number. */ @@ -32,37 +33,39 @@ export function clamp(value: number, min: number | undefined, max: number | unde export function evaluateExpr(expr: Expr, ctx: EvalContext): number | undefined { switch (expr.kind) { case 'literal': - return expr.value + return finiteSignal(expr.value) case 'signal': - return ctx.signal(expr.signal) + return finiteSignal(ctx.signal(expr.signal)) case 'window': - return ctx.windows.aggregate(expr.fn, expr.signal, expr.lastMin, ctx.nowMs) + // History cannot keep a dependent rule active after its source disappears. + if (finiteSignal(ctx.signal(expr.signal)) === undefined) return undefined + return finiteSignal(ctx.windows.aggregate(expr.fn, expr.signal, expr.lastMin, ctx.nowMs)) case 'binary': { - const l = evaluateExpr(expr.left, ctx) - const r = evaluateExpr(expr.right, ctx) - if (l === undefined || r === undefined) return undefined + const left = evaluateExpr(expr.left, ctx) + const right = evaluateExpr(expr.right, ctx) + if (left === undefined || right === undefined) return undefined switch (expr.op) { case '+': - return l + r + return finiteSignal(left + right) case '-': - return l - r + return finiteSignal(left - right) case '*': - return l * r + return finiteSignal(left * right) case '/': - return r === 0 ? undefined : l / r + return right === 0 ? undefined : finiteSignal(left / right) } return undefined } case 'clamp': { - const v = evaluateExpr(expr.value, ctx) - if (v === undefined) return undefined - const lo = evaluateExpr(expr.min, ctx) - const hi = evaluateExpr(expr.max, ctx) - return clamp(v, lo, hi) + const value = evaluateExpr(expr.value, ctx) + const lower = evaluateExpr(expr.min, ctx) + const upper = evaluateExpr(expr.max, ctx) + if (value === undefined || lower === undefined || upper === undefined || lower > upper) return undefined + return clamp(value, lower, upper) } } } diff --git a/src/automation/instance.ts b/src/automation/instance.ts index 04b437e46..2dcab5537 100644 --- a/src/automation/instance.ts +++ b/src/automation/instance.ts @@ -11,8 +11,10 @@ import { eq } from 'drizzle-orm' import { db } from '@/src/db' import { automationRuns, automations, deviceSettings } from '@/src/db/schema' import { getTemperatureController } from '@/src/temperature/instance' +import { publishAutomationNotification } from '@/src/notifications/instance' import { AutomationEngine } from './engine' import { BiometricsSignalReader } from './signals.biometrics' +import { RoomSignalReader } from '@/src/home-context/instance' import { CompositeSignalReader, DeviceSignalReader, clockInTimezone } from './signals' import type { Action, @@ -97,6 +99,7 @@ export async function getAutomationEngine(): Promise { // authoritative for any overlapping key (e.g. water.low). const reader = new CompositeSignalReader([ new BiometricsSignalReader(), + new RoomSignalReader(), new DeviceSignalReader(), ]) const engine = new AutomationEngine({ @@ -107,18 +110,21 @@ export async function getAutomationEngine(): Promise { loadRules, recordRun, disableRule, - notify: (id, message) => console.log(`[automation notify] rule ${id}: ${message}`), + notify: publishAutomationNotification, log: msg => console.log(`[automation] ${msg}`), }) - await engine.start() - // Restore the global kill-switch from persisted settings (default on). + // Resolve persisted consent before any timer can evaluate a rule. + // Missing or unreadable settings must never enable actuators by default. + let enabled = false try { const [settings] = await db.select({ on: deviceSettings.autopilotEnabled }).from(deviceSettings).limit(1) - if (settings && settings.on === false) await engine.setGlobalEnabled(false) + enabled = settings?.on === true } catch { - // Settings unreadable (e.g. fresh DB) — leave autopilot enabled. + console.warn('[automation] settings unreadable; automatic control remains disabled') } + await engine.setGlobalEnabled(enabled) + await engine.start() await engine.tick() state.engineInstance = engine console.log('AutomationEngine initialized with timezone:', timezone) diff --git a/src/automation/signalValidity.ts b/src/automation/signalValidity.ts new file mode 100644 index 000000000..0e08f0b33 --- /dev/null +++ b/src/automation/signalValidity.ts @@ -0,0 +1,4 @@ +/** Numeric automation inputs must remain finite through conversion and arithmetic. */ +export function finiteSignal(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} diff --git a/src/automation/signals.biometrics.ts b/src/automation/signals.biometrics.ts index 78585237c..77e173350 100644 --- a/src/automation/signals.biometrics.ts +++ b/src/automation/signals.biometrics.ts @@ -21,7 +21,7 @@ * `{side}.presence` is the shared virtual occupancy sensor (`lib/occupancy`) * collapsed to 1 (a person is in bed) / 0 (empty, or a light load such as a * pet). It reads `undefined` while presence can't be sensed — no fresh - * capSense2 frame or no calibration — so rules skip rather than fire blind. + * supported capacitance frame or no calibration — so rules skip rather than fire blind. * `{side}.presence.zones` is the lit head/torso/legs count behind that call. * * Automation temperature signals are Fahrenheit by contract because automation @@ -35,6 +35,8 @@ import { centiDegreesToF, centiPercentToPercent } from '@/src/lib/tempUtils' import { getOccupancy } from '@/src/lib/occupancy' import { getLatestCapSenseSnapshot } from '@/src/streaming/piezoStream' import { mean, reduceCap } from './capReduce' +import { finiteSignal } from './signalValidity' +import { isMechanicallyQualifiedVital } from '@/src/biometrics/mechanical/eligibility' import type { SignalReader, SignalSnapshot } from './signals' // Freshness windows (ms). A latest row older than its window is treated as @@ -51,72 +53,87 @@ export class BiometricsSignalReader implements SignalReader { read(): SignalSnapshot { const out: SignalSnapshot = {} const now = Date.now() - const fresh = (ts: Date | number, maxAgeMs: number): boolean => - now - (ts instanceof Date ? ts.getTime() : ts) <= maxAgeMs + const fresh = (timestamp: Date | number, maxAgeMs: number): boolean => { + const observedAt = timestamp instanceof Date ? timestamp.getTime() : timestamp + return finiteSignal(observedAt) !== undefined && observedAt <= now && now - observedAt <= maxAgeMs + } + const publish = (key: string, value: unknown, convert?: (reading: number) => number): void => { + const reading = finiteSignal(value) + if (reading === undefined) return + const converted = convert ? finiteSignal(convert(reading)) : reading + if (converted !== undefined) out[key] = converted + } try { for (const side of SIDES) { - const [v] = biometricsDb.select().from(vitals) + const [vitalReading] = biometricsDb.select().from(vitals) .where(eq(vitals.side, side)).orderBy(desc(vitals.timestamp)).limit(1).all() - if (v && fresh(v.timestamp, VITALS_FRESH_MS)) { - if (v.heartRate != null) out[`${side}.heartRate`] = v.heartRate - if (v.hrv != null) out[`${side}.hrv`] = v.hrv - if (v.breathingRate != null) out[`${side}.breathingRate`] = v.breathingRate + if (vitalReading && fresh(vitalReading.timestamp, VITALS_FRESH_MS) + && isMechanicallyQualifiedVital(biometricsDb.$client, vitalReading.id, now)) { + publish(`${side}.heartRate`, vitalReading.heartRate) + publish(`${side}.hrv`, vitalReading.hrv) + publish(`${side}.breathingRate`, vitalReading.breathingRate) } - const [m] = biometricsDb.select().from(movement) + const [movementReading] = biometricsDb.select().from(movement) .where(eq(movement.side, side)).orderBy(desc(movement.timestamp)).limit(1).all() - if (m && fresh(m.timestamp, MOVEMENT_FRESH_MS)) { - out[`${side}.movement`] = m.totalMovement + if (movementReading && fresh(movementReading.timestamp, MOVEMENT_FRESH_MS)) { + publish(`${side}.movement`, movementReading.totalMovement) } } - const [bt] = biometricsDb.select().from(bedTemp).orderBy(desc(bedTemp.timestamp)).limit(1).all() - if (bt && fresh(bt.timestamp, ENV_FRESH_MS)) { - if (bt.ambientTemp != null) out['ambient.temperature'] = centiDegreesToF(bt.ambientTemp) - if (bt.humidity != null) out['ambient.humidity'] = centiPercentToPercent(bt.humidity) + const [bedReading] = biometricsDb.select().from(bedTemp).orderBy(desc(bedTemp.timestamp)).limit(1).all() + if (bedReading && fresh(bedReading.timestamp, ENV_FRESH_MS)) { + publish('ambient.temperature', bedReading.ambientTemp, centiDegreesToF) + publish('ambient.humidity', bedReading.humidity, centiPercentToPercent) const zonesBySide = { - left: { outer: bt.leftOuterTemp, center: bt.leftCenterTemp, inner: bt.leftInnerTemp }, - right: { outer: bt.rightOuterTemp, center: bt.rightCenterTemp, inner: bt.rightInnerTemp }, + left: { outer: bedReading.leftOuterTemp, center: bedReading.leftCenterTemp, inner: bedReading.leftInnerTemp }, + right: { outer: bedReading.rightOuterTemp, center: bedReading.rightCenterTemp, inner: bedReading.rightInnerTemp }, } for (const side of SIDES) { - const z = zonesBySide[side] - const presentF = [z.outer, z.center, z.inner] - .filter((t): t is number => t != null) + const zones = zonesBySide[side] + const presentF = [zones.outer, zones.center, zones.inner] + .filter((temperature): temperature is number => finiteSignal(temperature) !== undefined) .map(centiDegreesToF) - if (presentF.length > 0) out[`${side}.surfaceTemp`] = mean(presentF) - if (presentF.length >= 2) out[`${side}.surfaceTemp.spread`] = Math.max(...presentF) - Math.min(...presentF) - if (z.inner != null && z.outer != null) out[`${side}.surfaceTemp.gradient`] = centiDegreesToF(z.inner) - centiDegreesToF(z.outer) + if (presentF.length > 0) publish(`${side}.surfaceTemp`, mean(presentF)) + if (presentF.length >= 2) publish(`${side}.surfaceTemp.spread`, Math.max(...presentF) - Math.min(...presentF)) + const inner = finiteSignal(zones.inner) + const outer = finiteSignal(zones.outer) + if (inner !== undefined && outer !== undefined) { + publish(`${side}.surfaceTemp.gradient`, centiDegreesToF(inner) - centiDegreesToF(outer)) + } } } - const [fz] = biometricsDb.select().from(freezerTemp).orderBy(desc(freezerTemp.timestamp)).limit(1).all() - if (fz && fresh(fz.timestamp, ENV_FRESH_MS)) { - if (fz.leftWaterTemp != null) out['left.waterTemp'] = centiDegreesToF(fz.leftWaterTemp) - if (fz.rightWaterTemp != null) out['right.waterTemp'] = centiDegreesToF(fz.rightWaterTemp) + const [waterReading] = biometricsDb.select().from(freezerTemp).orderBy(desc(freezerTemp.timestamp)).limit(1).all() + if (waterReading && fresh(waterReading.timestamp, ENV_FRESH_MS)) { + publish('left.waterTemp', waterReading.leftWaterTemp, centiDegreesToF) + publish('right.waterTemp', waterReading.rightWaterTemp, centiDegreesToF) } - const [al] = biometricsDb.select().from(ambientLight).orderBy(desc(ambientLight.timestamp)).limit(1).all() - if (al && fresh(al.timestamp, ENV_FRESH_MS) && al.lux != null) { - out['ambient.light'] = al.lux + const [lightReading] = biometricsDb.select().from(ambientLight).orderBy(desc(ambientLight.timestamp)).limit(1).all() + if (lightReading && fresh(lightReading.timestamp, ENV_FRESH_MS) && lightReading.lux != null) { + publish('ambient.light', lightReading.lux) } for (const side of SIDES) { const occ = getOccupancy(side) if (!occ.available) continue out[`${side}.presence`] = occ.classification === 'person' ? 1 : 0 - if (occ.level.zones !== null) out[`${side}.presence.zones`] = occ.level.zones + publish(`${side}.presence.zones`, occ.level.zones) } const cap = getLatestCapSenseSnapshot() - if (cap && fresh(cap.receivedAtMs, CAP_FRESH_MS)) { + if (cap && fresh(cap.receivedAtMs, CAP_FRESH_MS) && fresh(cap.ts * 1000, CAP_FRESH_MS)) { for (const side of SIDES) { const raw = cap[side] - const r = reduceCap(Array.isArray(raw) ? raw : [raw]) - if (r) { - out[`${side}.cap.max`] = r.max - out[`${side}.cap.mean`] = r.mean - out[`${side}.cap.spread`] = r.spread + const channels = Array.isArray(raw) ? raw : [raw] + if (channels.some(value => finiteSignal(value) === undefined)) continue + const reduced = reduceCap(channels) + if (reduced) { + publish(`${side}.cap.max`, reduced.max) + publish(`${side}.cap.mean`, reduced.mean) + publish(`${side}.cap.spread`, reduced.spread) } } } diff --git a/src/automation/signals.ts b/src/automation/signals.ts index df950dcaf..5e91137d6 100644 --- a/src/automation/signals.ts +++ b/src/automation/signals.ts @@ -6,14 +6,13 @@ * signal not present resolves to `undefined`, which makes dependent conditions * skip rather than fire on stale/missing data. * - * P0 wires the device-status signals that are reliably available now (per-side - * temperature/level from the live DAC monitor). Ambient and biometric signals - * are part of the catalog but read `undefined` until their sources are wired in - * a later phase — the engine degrades to "skip", which is the safe default. + * The DAC reader supplies fresh device status; the companion biometrics reader + * supplies timestamped environmental, occupancy and biometric measurements. */ import { getDacMonitorIfRunning } from '@/src/hardware/dacMonitor.instance' import type { AutomationRule, DayOfWeek, Expr } from './types' +import { finiteSignal } from './signalValidity' export type SignalSnapshot = Record @@ -66,23 +65,23 @@ export function clockInTimezone( } /** - * Production reader backed by the live DAC monitor's last status frame. Reads - * are lazy-imported so the engine module stays decoupled from hardware in tests. + * Production reader backed by a healthy, unchanged DAC observation. Fifteen + * seconds covers the monitor's slowest five-second polling cadence. */ export class DeviceSignalReader implements SignalReader { read(): SignalSnapshot { const snapshot: SignalSnapshot = {} try { - const status = getDacMonitorIfRunning()?.getLastStatus() + const status = getDacMonitorIfRunning()?.getFreshStatus(15_000) if (!status) return snapshot for (const side of ['left', 'right'] as const) { - const s = side === 'left' ? status.leftSide : status.rightSide - if (!s) continue + const reading = side === 'left' ? status.leftSide : status.rightSide + if (!reading) continue // null = level 0 (off); leave the signal absent so conditions don't // fire against a phantom neutral temperature. - snapshot[`${side}.currentTemperature`] = s.currentTemperature ?? undefined - snapshot[`${side}.targetTemperature`] = s.targetTemperature ?? undefined - snapshot[`${side}.currentLevel`] = s.currentLevel + snapshot[`${side}.currentTemperature`] = finiteSignal(reading.currentTemperature) + snapshot[`${side}.targetTemperature`] = finiteSignal(reading.targetTemperature) + snapshot[`${side}.currentLevel`] = finiteSignal(reading.currentLevel) } if (status.waterLevel === 'low' || status.waterLevel === 'ok') { snapshot['water.low'] = status.waterLevel === 'low' ? 1 : 0 diff --git a/src/automation/tests/biometricsSignalReader.test.ts b/src/automation/tests/biometricsSignalReader.test.ts index 248f50295..56a907888 100644 --- a/src/automation/tests/biometricsSignalReader.test.ts +++ b/src/automation/tests/biometricsSignalReader.test.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import { randomUUID } from 'node:crypto' import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' @@ -6,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as schema from '@/src/db/biometrics-schema' import type { LatestCapSenseSnapshot } from '@/src/streaming/piezoStream' import { BiometricsSignalReader } from '../signals.biometrics' +import { quietMechanicalSnapshot } from '@/src/biometrics/mechanical/tests/fixtures' const state = vi.hoisted(() => ({ db: null as ReturnType> | null, @@ -24,6 +26,30 @@ let raw: Database.Database let db: ReturnType> const reader = new BiometricsSignalReader() +/** These scalar/freshness fixtures now carry synthetic, whole-window evidence. */ +function qualifiedFixtureRead() { + if (raw.open) { + const now = Date.now(), snapshot = quietMechanicalSnapshot(now) + snapshot.state.cleanSinceMs = now - 900000 + snapshot.spans[0].startMs = now - 900000 + raw.prepare('INSERT OR REPLACE INTO biometric_mechanical_state VALUES (1,?)').run(JSON.stringify(snapshot.state)) + raw.prepare('INSERT OR REPLACE INTO biometric_mechanical_spans VALUES (?,?,?,?)') + .run(snapshot.spans[0].id, snapshot.state.epoch, now - 900000, now) + for (const row of db.select().from(schema.vitals).all()) { + if (row.processingRunId) continue + const duration = Math.max(row.heartRate === null ? 0 : 30000, row.breathingRate === null ? 0 : 60000, row.hrv === null ? 0 : 300000) + if (!duration) continue + const run = randomUUID(), timestamp = row.timestamp.getTime() + raw.prepare('INSERT INTO biometric_processing_runs (id,side,kind,bundle,config_hash,input_identity,started_at_ms) VALUES (?,?,\'live\',\'{}\',?,?,?)') + .run(run, row.side, '0'.repeat(64), JSON.stringify({ sourceSide: row.side, timestampBasis: 'firmware_event_time_v1', mechanicalContext: 'mechanical-context-v1' }), timestamp - duration) + raw.prepare('UPDATE vitals SET processing_run_id=? WHERE id=?').run(run, row.id) + raw.prepare('INSERT INTO vitals_quality (vitals_id,side,timestamp,quality_score,flags,created_at) VALUES (?,?,?,0.9,?,?)') + .run(row.id, row.side, timestamp / 1000, JSON.stringify([`mechanical_context_v1:${snapshot.state.epoch}:${snapshot.state.spanId}:${timestamp - duration - 1000}:${timestamp + 1000}`]), now / 1000) + } + } + return reader.read() +} + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) @@ -58,15 +84,19 @@ const environment = { } describe('BiometricsSignalReader', () => { - it('returns no signals before any samples arrive', () => { + it('withholds a fresh legacy row without inventing mechanical qualification', () => { + db.insert(schema.vitals).values({ side: 'left', timestamp: ago(2000), heartRate: 60 }).run() expect(reader.read()).toEqual({}) }) + it('returns no signals before any samples arrive', () => { + expect(qualifiedFixtureRead()).toEqual({}) + }) it('selects the newest row independently for each side and source', () => { // Insert older rows last: an unordered query must not accidentally pass. for (const [side, heartRate, totalMovement] of [['left', 61, 0], ['right', 72, 15]] as const) { db.insert(schema.vitals).values([ - { side, timestamp: NOW, heartRate, hrv: 40, breathingRate: 12 }, + { side, timestamp: ago(2000), heartRate, hrv: 40, breathingRate: 12 }, { side, timestamp: ago(60_000), heartRate: 99, hrv: 99, breathingRate: 99 }, ]).run() db.insert(schema.movement).values([ @@ -74,7 +104,7 @@ describe('BiometricsSignalReader', () => { { side, timestamp: ago(60_000), totalMovement: 99 }, ]).run() } - expect(reader.read()).toEqual({ + expect(qualifiedFixtureRead()).toEqual({ 'left.heartRate': 61, 'left.hrv': 40, 'left.breathingRate': 12, 'left.movement': 0, 'right.heartRate': 72, 'right.hrv': 40, 'right.breathingRate': 12, 'right.movement': 15, }) @@ -82,24 +112,24 @@ describe('BiometricsSignalReader', () => { it('keeps zero vitals but does not resurrect missing values from older rows', () => { db.insert(schema.vitals).values([ - { side: 'left', timestamp: NOW, heartRate: 0, hrv: 0, breathingRate: 0 }, + { side: 'left', timestamp: ago(2000), heartRate: 0, hrv: 0, breathingRate: 0 }, { side: 'right', timestamp: NOW }, { side: 'right', timestamp: ago(1000), heartRate: 65, hrv: 30, breathingRate: 14 }, ]).run() - expect(reader.read()).toEqual({ 'left.heartRate': 0, 'left.hrv': 0, 'left.breathingRate': 0 }) + expect(qualifiedFixtureRead()).toEqual({ 'left.heartRate': 0, 'left.hrv': 0, 'left.breathingRate': 0 }) }) it.each([0, 1])('expires vitals and movement %i ms past their five-minute boundary', (extra) => { db.insert(schema.vitals).values({ side: 'left', timestamp: ago(300_000), heartRate: 60 }).run() db.insert(schema.movement).values({ side: 'right', timestamp: ago(300_000), totalMovement: 7 }).run() vi.setSystemTime(NOW.getTime() + extra) - expect(reader.read()).toEqual(extra ? {} : { 'left.heartRate': 60, 'right.movement': 7 }) + expect(qualifiedFixtureRead()).toEqual(extra ? {} : { 'left.heartRate': 60, 'right.movement': 7 }) }) it.each([0, 1])('expires all environment sources %i ms past their fifteen-minute boundary', (extra) => { seedEnvironment(ago(900_000)) vi.setSystemTime(NOW.getTime() + extra) - expect(reader.read()).toEqual(extra ? {} : environment) + expect(qualifiedFixtureRead()).toEqual(extra ? {} : environment) }) it('uses the newest environment rows even when they contain missing readings', () => { @@ -107,14 +137,14 @@ describe('BiometricsSignalReader', () => { db.insert(schema.bedTemp).values({ timestamp: NOW }).run() db.insert(schema.freezerTemp).values({ timestamp: NOW }).run() db.insert(schema.ambientLight).values({ timestamp: NOW }).run() - expect(reader.read()).toEqual({}) + expect(qualifiedFixtureRead()).toEqual({}) }) it('averages only available surface zones and requires both endpoints for a gradient', () => { db.insert(schema.bedTemp).values({ timestamp: NOW, leftCenterTemp: 0, rightOuterTemp: 1000, rightCenterTemp: 3000, }).run() - expect(reader.read()).toEqual({ + expect(qualifiedFixtureRead()).toEqual({ 'left.surfaceTemp': 32, 'right.surfaceTemp': 68, 'right.surfaceTemp.spread': 36, }) }) @@ -122,7 +152,7 @@ describe('BiometricsSignalReader', () => { it('computes a gradient when the center is missing and keeps independent nullable water readings', () => { db.insert(schema.bedTemp).values({ timestamp: NOW, leftOuterTemp: 3000, leftInnerTemp: 1000 }).run() db.insert(schema.freezerTemp).values({ timestamp: NOW, rightWaterTemp: 0 }).run() - expect(reader.read()).toEqual({ + expect(qualifiedFixtureRead()).toEqual({ 'left.surfaceTemp': 68, 'left.surfaceTemp.spread': 36, 'left.surfaceTemp.gradient': -36, 'right.waterTemp': 32, }) @@ -131,26 +161,38 @@ describe('BiometricsSignalReader', () => { it('drops stale sources without suppressing fresh sources on either side', () => { seedEnvironment(ago(901_000)) db.insert(schema.vitals).values({ side: 'left', timestamp: ago(301_000), heartRate: 60 }).run() - db.insert(schema.vitals).values({ side: 'right', timestamp: NOW, heartRate: 70 }).run() + db.insert(schema.vitals).values({ side: 'right', timestamp: ago(2000), heartRate: 70 }).run() db.insert(schema.movement).values({ side: 'left', timestamp: NOW, totalMovement: 3 }).run() - expect(reader.read()).toEqual({ 'right.heartRate': 70, 'left.movement': 3 }) + expect(qualifiedFixtureRead()).toEqual({ 'right.heartRate': 70, 'left.movement': 3 }) + }) + + it('rejects future stored observations rather than resurrecting an older reading', () => { + seedEnvironment(NOW) + const future = new Date(NOW.getTime() + 1000) + seedEnvironment(future) + db.insert(schema.vitals).values([ + { side: 'left', timestamp: NOW, heartRate: 65 }, + { side: 'left', timestamp: future, heartRate: 90 }, + ]).run() + db.insert(schema.movement).values({ side: 'right', timestamp: future, totalMovement: 10 }).run() + expect(qualifiedFixtureRead()).toEqual({}) }) it('reduces capSense2 body channels without including the two reference channels', () => { state.cap = { - type: 'capSense2', ts: 0, receivedAtMs: NOW.getTime(), + type: 'capSense2', ts: NOW.getTime() / 1000, receivedAtMs: NOW.getTime(), left: [1, 2, 3, 4, 5, 6, 999, -999], right: [10, 20, 30, 40, 50, 60, 9999, -9999], } - expect(reader.read()).toEqual({ + expect(qualifiedFixtureRead()).toEqual({ 'left.cap.max': 6, 'left.cap.mean': 3.5, 'left.cap.spread': 5, 'right.cap.max': 60, 'right.cap.mean': 35, 'right.cap.spread': 50, }) }) - it.each([0, 1])('expires scalar capSense %i ms past receipt freshness, ignoring firmware time', (extra) => { - state.cap = { type: 'capSense', ts: 0, receivedAtMs: NOW.getTime() - 30_000, left: 0, right: 12 } + it.each([0, 1])('expires scalar capSense %i ms past receipt freshness with a current firmware timestamp', (extra) => { + state.cap = { type: 'capSense', ts: NOW.getTime() / 1000, receivedAtMs: NOW.getTime() - 30_000, left: 0, right: 12 } vi.setSystemTime(NOW.getTime() + extra) - expect(reader.read()).toEqual(extra + expect(qualifiedFixtureRead()).toEqual(extra ? {} : { 'left.cap.max': 0, 'left.cap.mean': 0, 'left.cap.spread': 0, @@ -159,22 +201,22 @@ describe('BiometricsSignalReader', () => { }) it('omits empty capacitive arrays independently by side', () => { - state.cap = { type: 'capSense2', ts: 0, receivedAtMs: NOW.getTime(), left: [], right: [4, 6] } - expect(reader.read()).toEqual({ 'right.cap.max': 6, 'right.cap.mean': 5, 'right.cap.spread': 2 }) + state.cap = { type: 'capSense2', ts: NOW.getTime() / 1000, receivedAtMs: NOW.getTime(), left: [], right: [4, 6] } + expect(qualifiedFixtureRead()).toEqual({ 'right.cap.max': 6, 'right.cap.mean': 5, 'right.cap.spread': 2 }) }) it('warns and returns an empty snapshot if the database is unavailable', () => { raw.close() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(reader.read()).toEqual({}) + expect(qualifiedFixtureRead()).toEqual({}) expect(warn).toHaveBeenCalledWith('[automation] BiometricsSignalReader.read failed:', expect.any(Error)) }) it('retains signals already read when a later source fails', () => { - db.insert(schema.vitals).values({ side: 'left', timestamp: NOW, heartRate: 63 }).run() + db.insert(schema.vitals).values({ side: 'left', timestamp: ago(2000), heartRate: 63 }).run() raw.exec('DROP TABLE movement') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(reader.read()).toEqual({ 'left.heartRate': 63 }) + expect(qualifiedFixtureRead()).toEqual({ 'left.heartRate': 63 }) expect(warn).toHaveBeenCalledOnce() }) }) diff --git a/src/automation/tests/engine.test.ts b/src/automation/tests/engine.test.ts index 9efb6a755..ed3a4146b 100644 --- a/src/automation/tests/engine.test.ts +++ b/src/automation/tests/engine.test.ts @@ -44,7 +44,7 @@ interface Harness { disabled: number[] } -function makeHarness(rules: AutomationRule[], lock: AutomationEngineDeps['withSideLock'] = async (_side, fn) => fn()): Harness { +function makeHarness(rules: AutomationRule[], lock: AutomationEngineDeps['withSideLock'] = async (_side, fn) => fn(), notify?: AutomationEngineDeps['notify']): Harness { // Base clock is a realistic epoch so a `tick` trigger's first evaluation is // due (production `now` is always >> any everyMin window from epoch 0). let nowMs = 1_700_000_000_000 @@ -76,7 +76,7 @@ function makeHarness(rules: AutomationRule[], lock: AutomationEngineDeps['withSi recordRun: async (id, outcome, detail) => { runs.push({ id, outcome, detail: detail as RunDetail }) }, disableRule: async (id) => { disabled.push(id) }, hasActiveRunOnceSession: async () => runOnce, - notify: (id, message) => notifies.push({ id, message }), + notify: notify ?? ((id, message) => { notifies.push({ id, message }) }), } const { engine, control } = engineFixture(deps) @@ -164,7 +164,7 @@ describe('AutomationEngine — outcomes & audit log', () => { }) describe('AutomationEngine — dry-run', () => { - it('emits notify but never touches hardware, logging dry_run', async () => { + it('records notify proposals without delivering alerts or touching hardware', async () => { const actions: Action[] = [ { kind: 'notify', message: 'would warm' }, { kind: 'setTemperature', temp: lit(72) }, @@ -173,7 +173,7 @@ describe('AutomationEngine — dry-run', () => { await h.engine.reload() await h.engine.tick() expect(h.hwCalls).toHaveLength(0) - expect(h.notifies).toHaveLength(1) + expect(h.notifies).toHaveLength(0) expect(h.runs[0].outcome).toBe('dry_run') }) }) @@ -557,6 +557,20 @@ describe('AutomationEngine — window-bounded eval', () => { }) describe('AutomationEngine — action error', () => { + it('records a durable receipt and reports failed persistence as an error rather than delivery', async () => { + const publish = vi.fn().mockReturnValueOnce({ notificationId: 42, duplicate: false }).mockImplementationOnce(() => { + throw new Error('Inbox unavailable') + }) + const harness = makeHarness([rule({ actions: [{ kind: 'notify', message: 'Room changed' }] })], undefined, publish) + await harness.engine.reload() + await harness.engine.tick() + expect(harness.runs[0]).toMatchObject({ outcome: 'fired', detail: { actions: [{ notified: true, notificationId: 42, duplicate: false }] } }) + harness.advance(60_000) + await harness.engine.tick() + expect(harness.runs[1]).toMatchObject({ outcome: 'error', detail: { reason: 'eval-threw', message: 'Inbox unavailable' } }) + expect(harness.hwCalls).toEqual([]) + }) + it('records an error outcome when a hardware write throws', async () => { const runs: { id: number, outcome: RunOutcome, detail: RunDetail }[] = [] const deps: AutomationEngineDeps = { @@ -938,6 +952,86 @@ describe('AutomationEngine — shared control requests', () => { expect(h.control.status('left').source).toBeNull() }) + it('withdraws a window-conditioned policy when its source disappears, despite retained history', async () => { + const harness = makeHarness([rule({ + conditions: { kind: 'compare', op: '>', left: { kind: 'window', fn: 'avg', signal: 'room', lastMin: 10 }, right: lit(70) }, + actions: [{ kind: 'setTemperature', mode: 'policy', temp: lit(68) }, { kind: 'notify', message: 'warm room' }], + })]) + harness.setSignal('room', 80) + await harness.engine.reload() + await harness.engine.tick() + expect(harness.control.status('left').source).toBe('autopilot') + harness.setSignal('room', undefined) + harness.advance(60_000) + await harness.engine.tick() + expect(harness.control.status('left').source).toBeNull() + expect(harness.notifies).toHaveLength(1) + expect(harness.runs.at(-1)?.detail.reason).toBe('condition-unknown') + harness.setSignal('room', 80) + harness.advance(60_000) + await harness.engine.tick() + expect(harness.control.status('left').source).toBe('autopilot') + expect(harness.notifies).toHaveLength(2) + }) + + it.each([undefined, NaN, Infinity])('withdraws a policy with unavailable action input (%s) before its next trigger', async (value) => { + const harness = makeHarness([rule({ + trigger: tickEvery(10), + actions: [{ kind: 'setTemperature', mode: 'policy', temp: { kind: 'window', fn: 'avg', signal: 'room', lastMin: 10 } }], + })]) + harness.setSignal('room', 72) + await harness.engine.reload() + await harness.engine.tick() + expect(harness.control.status('left').targetTemperature).toBe(72) + harness.setSignal('room', value) + harness.advance(60_000) + await harness.engine.tick() + expect(harness.control.status('left').source).toBeNull() + expect(harness.runs.at(-1)?.detail.reason).toBe('policy-input-unavailable') + harness.setSignal('room', 74) + harness.advance(10 * 60_000) + await harness.engine.tick() + expect(harness.control.status('left').targetTemperature).toBe(74) + expect(harness.hwCalls.every(call => Number.isFinite(call.temp))).toBe(true) + }) + + it('withdraws a policy when its trigger source disappears and recovers without replaying notifications', async () => { + const harness = makeHarness([rule({ + trigger: { kind: 'signalChange', signal: 'room' }, + actions: [{ kind: 'setTemperature', mode: 'policy', temp: lit(68) }, { kind: 'notify', message: 'change' }], + })]) + harness.setSignal('room', 70) + await harness.engine.reload() + await harness.engine.tick() + expect(harness.control.status('left').source).toBe('autopilot') + harness.setSignal('room', undefined) + harness.advance(60_000) + await harness.engine.tick() + expect(harness.control.status('left').source).toBeNull() + harness.setSignal('room', 70) + harness.advance(60_000) + await harness.engine.tick() + expect(harness.control.status('left').source).toBe('autopilot') + expect(harness.notifies).toHaveLength(0) + }) + + it('retains an explicitly bounded one-shot after its original input disappears', async () => { + const harness = makeHarness([rule({ + trigger: tickEvery(10), + actions: [{ kind: 'setTemperature', mode: 'one-shot', temp: sig('room'), holdMinutes: 3 }], + })]) + harness.setSignal('room', 72) + await harness.engine.reload() + await harness.engine.tick() + harness.setSignal('room', undefined) + harness.advance(60_000) + await harness.engine.tick() + expect(harness.control.status('left').source).toBe('autopilot') + harness.advance(3 * 60_000) + await harness.engine.tick() + expect(harness.control.status('left').source).toBeNull() + }) + it('revokes disabled and edited rules immediately on reload', async () => { const rules = [rule({ actions: [{ kind: 'setTemperature', temp: lit(70) }] })] const h = makeHarness(rules) diff --git a/src/automation/tests/expressions.test.ts b/src/automation/tests/expressions.test.ts index 6ed36267b..31b127d32 100644 --- a/src/automation/tests/expressions.test.ts +++ b/src/automation/tests/expressions.test.ts @@ -106,11 +106,43 @@ describe('evaluateExpr', () => { expect(evaluateExpr(expr, c)).toBeUndefined() }) + it.each(['avg', 'min', 'max', 'sum', 'count'] as const)('withholds historical %s when the current source disappears', (fn) => { + const windows = new WindowStore() + windows.record('room', 80, 0) + const expression: Expr = { kind: 'window', fn, signal: 'room', lastMin: 10 } + expect(evaluateExpr(expression, ctx({}, windows))).toBeUndefined() + expect(evaluateExpr(expression, ctx({ room: NaN }, windows))).toBeUndefined() + expect(evaluateExpr(expression, ctx({ room: 80 }, windows))).toBe(fn === 'count' ? 1 : 80) + }) + + it.each([NaN, Infinity, -Infinity])('treats non-finite literals and signals (%s) as missing', (value) => { + expect(evaluateExpr({ kind: 'literal', value }, ctx({}))).toBeUndefined() + expect(evaluateExpr({ kind: 'signal', signal: 'room' }, ctx({ room: value }))).toBeUndefined() + }) + + it('withholds overflowing arithmetic and a missing or inverted clamp bound', () => { + const overflow: Expr = { kind: 'binary', op: '*', left: { kind: 'literal', value: 1e308 }, right: { kind: 'literal', value: 2 } } + expect(evaluateExpr(overflow, ctx({}))).toBeUndefined() + const bounded: Expr = { kind: 'clamp', value: { kind: 'literal', value: 72 }, min: { kind: 'signal', signal: 'minimum' }, max: { kind: 'signal', signal: 'maximum' } } + expect(evaluateExpr(bounded, ctx({ minimum: 70 }))).toBeUndefined() + expect(evaluateExpr(bounded, ctx({ maximum: 90 }))).toBeUndefined() + expect(evaluateExpr(bounded, ctx({ minimum: 90, maximum: 70 }))).toBeUndefined() + expect(evaluateExpr(bounded, ctx({ minimum: 70, maximum: 90 }))).toBe(72) + }) + + it('invalid samples cannot poison later valid window aggregates', () => { + const windows = new WindowStore() + windows.record('room', Infinity, 0) + windows.record('room', 80, NaN) + windows.record('room', 70, 0) + expect(evaluateExpr({ kind: 'window', fn: 'avg', signal: 'room', lastMin: 10 }, ctx({ room: 70 }, windows))).toBe(70) + }) + it('reads windowed aggregates from the store', () => { const windows = new WindowStore() windows.record('left.movement', 300, 0) windows.record('left.movement', 100, 0) - const c = { ...ctx({}, windows), nowMs: 0 } + const c = { ...ctx({ 'left.movement': 100 }, windows), nowMs: 0 } expect(evaluateExpr({ kind: 'window', fn: 'avg', signal: 'left.movement', lastMin: 10 }, c)).toBe(200) }) }) diff --git a/src/automation/tests/instance.test.ts b/src/automation/tests/instance.test.ts index 82f7b6036..044992032 100644 --- a/src/automation/tests/instance.test.ts +++ b/src/automation/tests/instance.test.ts @@ -21,6 +21,8 @@ vi.mock('@/src/temperature/instance', () => ({ getTemperatureController: () => c const startMock = vi.fn(async () => {}) const stopMock = vi.fn() const setGlobalEnabledMock = vi.fn() +const notifyMock = vi.fn(() => ({ notificationId: 42, duplicate: false })) +vi.mock('@/src/notifications/instance', () => ({ publishAutomationNotification: notifyMock })) let capturedDeps: any = null vi.mock('../engine', () => { @@ -131,7 +133,8 @@ describe('automation/instance — init & caching', () => { expect(ctorMock).toHaveBeenCalledTimes(1) expect(startMock).toHaveBeenCalledTimes(1) expect(mod.getAutomationEngineIfRunning()).toBe(a) - expect(setGlobalEnabledMock).not.toHaveBeenCalled() // kill-switch on → no override + expect(setGlobalEnabledMock).toHaveBeenCalledWith(true) + expect(setGlobalEnabledMock.mock.invocationCallOrder[0]).toBeLessThan(startMock.mock.invocationCallOrder[0]) expect(log).toHaveBeenCalledWith('AutomationEngine initialized with timezone:', 'UTC') log.mockRestore() }) @@ -174,9 +177,10 @@ describe('automation/instance — kill-switch restore', () => { const mod = await freshModule() await mod.getAutomationEngine() expect(setGlobalEnabledMock).toHaveBeenCalledWith(false) + expect(setGlobalEnabledMock.mock.invocationCallOrder[0]).toBeLessThan(startMock.mock.invocationCallOrder[0]) }) - it('leaves autopilot enabled when the kill-switch read throws', async () => { + it('keeps autopilot disabled when the kill-switch read throws', async () => { // First select (timezone) succeeds; second (kill-switch) throws. let calls = 0 selectImpl = async () => { @@ -186,7 +190,16 @@ describe('automation/instance — kill-switch restore', () => { } const mod = await freshModule() await mod.getAutomationEngine() - expect(setGlobalEnabledMock).not.toHaveBeenCalled() + expect(setGlobalEnabledMock).toHaveBeenCalledWith(false) + expect(setGlobalEnabledMock.mock.invocationCallOrder[0]).toBeLessThan(startMock.mock.invocationCallOrder[0]) + }) + + it('keeps autopilot disabled when settings are absent', async () => { + selectImpl = async () => [] + const mod = await freshModule() + await mod.getAutomationEngine() + expect(setGlobalEnabledMock).toHaveBeenCalledWith(false) + expect(setGlobalEnabledMock.mock.invocationCallOrder[0]).toBeLessThan(startMock.mock.invocationCallOrder[0]) }) }) @@ -299,6 +312,7 @@ describe('automation/instance — injected dependency closures', () => { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) capturedDeps.notify(9, 'hello') + expect(notifyMock).toHaveBeenCalledWith(9, 'hello') capturedDeps.log('a message') expect(log).toHaveBeenCalled() log.mockRestore() diff --git a/src/automation/tests/signals.biometrics.test.ts b/src/automation/tests/signals.biometrics.test.ts index 8cebc89f8..b5917ea5f 100644 --- a/src/automation/tests/signals.biometrics.test.ts +++ b/src/automation/tests/signals.biometrics.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +// Eligibility has real SQLite/window tests; these fixtures isolate scalar conversion. +const eligibility = vi.hoisted(() => ({ allowed: true })) +vi.mock('@/src/biometrics/mechanical/eligibility', () => ({ isMechanicallyQualifiedVital: () => eligibility.allowed })) + // Hoisted DB mock — drives BiometricsSignalReader by stubbing the chained // biometricsDb.select().from(table)[.where()].orderBy().limit().all() call. // Rows are keyed by table; per-side tables (vitals, movement) resolve left @@ -95,6 +99,7 @@ describe('reduceCap', () => { describe('BiometricsSignalReader', () => { beforeEach(() => { + eligibility.allowed = true vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-15T12:00:00Z')) dbMock.state.rows = {} @@ -177,6 +182,19 @@ describe('BiometricsSignalReader', () => { expect(out['right.cap.spread']).toBe(0) }) + it('withholds unqualified vitals without hiding independent environmental signals', () => { + eligibility.allowed = false + dbMock.state.rows = { + 'vitals.left': [{ timestamp: new Date(Date.now() - 1000), heartRate: 55, hrv: 40, breathingRate: 14 }], + 'ambientLight': [{ timestamp: new Date(Date.now() - 1000), lux: 12 }], + } + const out = new BiometricsSignalReader().read() + expect(out['left.heartRate']).toBeUndefined() + expect(out['left.hrv']).toBeUndefined() + expect(out['left.breathingRate']).toBeUndefined() + expect(out['ambient.light']).toBe(12) + }) + it('treats rows older than their freshness window as absent', () => { const staleShort = new Date(Date.now() - 6 * 60_000) // past the 5-min vitals/movement window const staleEnv = new Date(Date.now() - 16 * 60_000) // past the 15-min environment window @@ -200,6 +218,54 @@ describe('BiometricsSignalReader', () => { expect(new BiometricsSignalReader().read()).toEqual({}) }) + it.each([ + new Date('2026-01-15T12:00:01Z'), + new Date('invalid'), + NaN, Infinity, -Infinity, + ])('withholds malformed and future timestamps (%s) across all stored sources', (timestamp) => { + dbMock.state.rows = { + 'vitals.left': [{ timestamp, heartRate: 55, hrv: 40, breathingRate: 14 }], + 'movement.left': [{ timestamp, totalMovement: 120 }], + 'bedTemp': [{ timestamp, ambientTemp: 2000, humidity: 4550, leftOuterTemp: 3000 }], + 'freezerTemp': [{ timestamp, leftWaterTemp: 1500 }], + 'ambientLight': [{ timestamp, lux: 12 }], + 'vitals.right': [{ timestamp: new Date(), heartRate: 60 }], + } + expect(new BiometricsSignalReader().read()).toEqual({ 'right.heartRate': 60 }) + }) + + it('does not propagate malformed numeric readings or manufacture missing values', () => { + const timestamp = new Date() + dbMock.state.rows = { + 'vitals.left': [{ timestamp, heartRate: NaN, hrv: Infinity, breathingRate: '14' }], + 'movement.left': [{ timestamp, totalMovement: -Infinity }], + 'bedTemp': [{ timestamp, ambientTemp: Infinity, humidity: NaN, leftOuterTemp: NaN, leftCenterTemp: 3000, leftInnerTemp: Infinity }], + 'freezerTemp': [{ timestamp, leftWaterTemp: '1500', rightWaterTemp: 1500 }], + 'ambientLight': [{ timestamp, lux: -Infinity }], + } + capMock.snapshot = { + type: 'capSense2', ts: Date.now() / 1000, receivedAtMs: Date.now(), + left: [1, 2, NaN, 4, 5, 6, 0, 0], right: [10, 10, 10, 10, 10, 10, 0, 0], + } + expect(new BiometricsSignalReader().read()).toEqual({ + 'left.surfaceTemp': 86, 'right.waterTemp': 59, + 'right.cap.max': 10, 'right.cap.mean': 10, 'right.cap.spread': 0, + }) + }) + + it.each([ + { receivedAtMs: 0 }, { receivedAtMs: Infinity }, { receivedAtMs: NaN }, + { receivedAtMs: Date.parse('2026-01-15T12:00:00Z') + 1 }, + { ts: 0 }, { ts: Infinity }, { ts: NaN }, + { ts: Date.parse('2026-01-15T12:00:01Z') / 1000 }, + ])('requires both cap receipt and source timestamps to be fresh (%j)', (overrides) => { + capMock.snapshot = { + type: 'capSense', ts: Date.now() / 1000, receivedAtMs: Date.now(), + left: 42, right: 17, ...overrides, + } + expect(new BiometricsSignalReader().read()).toEqual({}) + }) + it('exposes the classified presence sensor as 1/0 only while presence is sensable', () => { occupancyMock.bySide = { left: { available: true, classification: 'person', zones: 3 }, diff --git a/src/automation/tests/signals.test.ts b/src/automation/tests/signals.test.ts index 2372a9b49..e46b0dc58 100644 --- a/src/automation/tests/signals.test.ts +++ b/src/automation/tests/signals.test.ts @@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AutomationRule, Condition, Expr } from '../types' // The DAC monitor is mocked at the import boundary so DeviceSignalReader can be -// exercised without real hardware. `getLastStatusMock` is swapped per test. -let getLastStatusMock: () => unknown = () => null +// exercised without real hardware. `getFreshStatusMock` is swapped per test. +let getFreshStatusMock: (maxAgeMs: number) => unknown = () => null let monitorRunning = true vi.mock('@/src/hardware/dacMonitor.instance', () => ({ - getDacMonitorIfRunning: () => (monitorRunning ? { getLastStatus: getLastStatusMock } : null), + getDacMonitorIfRunning: () => (monitorRunning ? { getFreshStatus: getFreshStatusMock } : null), })) import { @@ -18,7 +18,7 @@ import { } from '../signals' beforeEach(() => { - getLastStatusMock = () => null + getFreshStatusMock = () => null monitorRunning = true }) @@ -49,12 +49,33 @@ describe('DeviceSignalReader', () => { }) it('returns an empty snapshot when there is no last status frame', () => { - getLastStatusMock = () => null + getFreshStatusMock = () => null expect(new DeviceSignalReader().read()).toEqual({}) }) + it('requires a fresh healthy observation rather than a cached last status', () => { + const freshStatus = vi.fn(() => null) + getFreshStatusMock = freshStatus + expect(new DeviceSignalReader().read()).toEqual({}) + expect(freshStatus).toHaveBeenCalledExactlyOnceWith(15_000) + }) + + it('withholds non-finite device values while retaining valid independent signals', () => { + getFreshStatusMock = () => ({ + leftSide: { currentTemperature: NaN, targetTemperature: Infinity, currentLevel: -Infinity }, + rightSide: { currentTemperature: 70, targetTemperature: 68, currentLevel: -5 }, + waterLevel: 'ok', + }) + const snapshot = new DeviceSignalReader().read() + expect(snapshot['left.currentTemperature']).toBeUndefined() + expect(snapshot['left.targetTemperature']).toBeUndefined() + expect(snapshot['left.currentLevel']).toBeUndefined() + expect(snapshot['right.currentTemperature']).toBe(70) + expect(snapshot['water.low']).toBe(0) + }) + it('maps both sides plus a low water flag', () => { - getLastStatusMock = () => ({ + getFreshStatusMock = () => ({ leftSide: { currentTemperature: 75, targetTemperature: 80, currentLevel: 10 }, rightSide: { currentTemperature: 70, targetTemperature: 68, currentLevel: -5 }, waterLevel: 'low', @@ -71,7 +92,7 @@ describe('DeviceSignalReader', () => { }) it('omits temperature signals for an off side reporting null level-0 temps', () => { - getLastStatusMock = () => ({ + getFreshStatusMock = () => ({ leftSide: { currentTemperature: null, targetTemperature: null, currentLevel: 0 }, rightSide: { currentTemperature: 70, targetTemperature: 68, currentLevel: -5 }, waterLevel: 'ok', @@ -87,7 +108,7 @@ describe('DeviceSignalReader', () => { }) it('encodes an ok water level as 0 and skips an absent side', () => { - getLastStatusMock = () => ({ + getFreshStatusMock = () => ({ leftSide: { currentTemperature: 72, targetTemperature: 72, currentLevel: 0 }, rightSide: undefined, waterLevel: 'ok', @@ -101,7 +122,7 @@ describe('DeviceSignalReader', () => { }) it('omits the water flag for an unknown water level', () => { - getLastStatusMock = () => ({ + getFreshStatusMock = () => ({ leftSide: { currentTemperature: 72, targetTemperature: 72, currentLevel: 0 }, rightSide: { currentTemperature: 72, targetTemperature: 72, currentLevel: 0 }, waterLevel: 'unknown', @@ -111,7 +132,7 @@ describe('DeviceSignalReader', () => { it('warns and degrades to an empty snapshot when the read throws', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - getLastStatusMock = () => { + getFreshStatusMock = () => { throw new Error('frame corrupt') } expect(new DeviceSignalReader().read()).toEqual({}) diff --git a/src/automation/windows.ts b/src/automation/windows.ts index 81d57f608..682369f32 100644 --- a/src/automation/windows.ts +++ b/src/automation/windows.ts @@ -7,6 +7,7 @@ */ import type { WindowFn } from './types' +import { finiteSignal } from './signalValidity' interface Sample { t: number // epoch ms @@ -18,6 +19,7 @@ export class WindowStore { /** Record a numeric sample for a signal key at time `atMs`. */ record(key: string, value: number, atMs: number): void { + if (finiteSignal(value) === undefined || finiteSignal(atMs) === undefined) return let buf = this.buffers.get(key) if (!buf) { buf = [] diff --git a/src/bedroom/contract.ts b/src/bedroom/contract.ts new file mode 100644 index 000000000..56c759700 --- /dev/null +++ b/src/bedroom/contract.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' + +export const BEDROOM_METRICS = { + temperature: { label: 'Temperature', unit: '°F', units: ['°C', '°F'], minimum: -40, maximum: 140, disagreement: 4 }, + humidity: { label: 'Humidity', unit: '%', units: ['%'], minimum: 0, maximum: 100, disagreement: 15 }, + pm25: { label: 'Fine particles', unit: 'µg/m³', units: ['µg/m³', 'μg/m³'], minimum: 0, maximum: 2000, disagreement: null }, + co2: { label: 'Carbon dioxide', unit: 'ppm', units: ['ppm'], minimum: 0, maximum: 10000, disagreement: null }, + airQuality: { label: 'Air quality', unit: 'category', units: ['category'], minimum: 1, maximum: 5, disagreement: null }, + power: { label: 'Power', unit: 'on/off', units: ['on/off'], minimum: 0, maximum: 1, disagreement: null }, + fanSpeed: { label: 'Fan speed', unit: '%', units: ['%'], minimum: 0, maximum: 100, disagreement: null }, + filterLife: { label: 'Filter remaining', unit: '%', units: ['%'], minimum: 0, maximum: 100, disagreement: null }, +} as const + +export const bedroomMetricSchema = z.enum(['temperature', 'humidity', 'pm25', 'co2', 'airQuality', 'power', 'fanSpeed', 'filterLife']) +export type BedroomMetric = z.infer +const identifier = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$/) +const timestamp = z.number().int().positive().max(8_000_000_000_000) +export const bedroomSourceSchema = z.object({ + id: identifier, + deviceId: identifier, + deviceName: z.string().trim().min(1).max(80), + label: z.string().trim().min(1).max(80), + provider: z.enum(['home_assistant', 'homebridge', 'apple_home']), + externalId: z.string().trim().min(1).max(180), + model: z.string().trim().max(80).default(''), + location: z.enum(['left', 'right', 'room']).default('room'), + metric: bedroomMetricSchema, + unit: z.enum(['°C', '°F', '%', 'µg/m³', 'μg/m³', 'ppm', 'category', 'on/off']), + maxAgeSeconds: z.number().int().min(30).max(900), + enabled: z.boolean(), + useForControl: z.boolean().default(false), +}).strict().superRefine((source, context) => { + if (!(BEDROOM_METRICS[source.metric].units as readonly string[]).includes(source.unit)) { + context.addIssue({ code: 'custom', path: ['unit'], message: 'Unit does not match the sensor measurement.' }) + } +}) +export type BedroomSource = z.infer + +export const bedroomConfigSchema = z.object({ + enabled: z.boolean(), + roomName: z.string().trim().min(1).max(60), + sources: z.array(bedroomSourceSchema).max(40), +}).strict().superRefine((config, context) => { + const identities = config.sources.map(source => source.id) + const physicalMetrics = config.sources.map(source => `${source.deviceId}/${source.metric}`) + const externalIds = config.sources.map(source => `${source.provider}/${source.externalId}`) + if ([identities, physicalMetrics, externalIds].some(values => new Set(values).size !== values.length)) { + context.addIssue({ code: 'custom', path: ['sources'], message: 'Each sensor and each physical-device measurement must be mapped once.' }) + } +}) +export type BedroomConfig = z.infer +export const defaultBedroomConfig: BedroomConfig = { enabled: false, roomName: 'Bedroom', sources: [] } + +/** A successful query is not a new measurement. Publishers must identify the clock they actually have. */ +export const bedroomReportSchema = z.object({ + schemaVersion: z.literal(1), revision: z.number().int().positive(), sourceId: identifier, + available: z.boolean(), value: z.number().finite().nullable(), + observedAt: timestamp, measuredAt: timestamp.nullable(), + timeBasis: z.enum(['measurement', 'device_read', 'integration_report', 'cached']), +}).strict().superRefine((report, context) => { + if (report.available !== (report.value !== null) + || (report.measuredAt !== null && report.measuredAt > report.observedAt) + || (report.available && report.timeBasis === 'measurement' && report.measuredAt === null) + || (!report.available && report.measuredAt !== null)) { + context.addIssue({ code: 'custom', message: 'Availability, value and measurement timestamps disagree.' }) + } +}) +export type BedroomReport = z.infer + +export const bedroomObservationSchema = z.object({ + source: bedroomSourceSchema, + status: z.enum(['disabled', 'waiting', 'unavailable', 'stale', 'cached', 'fresh']), + value: z.number().nullable(), lastValue: z.number().nullable(), unit: z.string(), + observedAt: timestamp.nullable(), measuredAt: timestamp.nullable(), receivedAt: timestamp.nullable(), expiresAt: timestamp.nullable(), + timeBasis: bedroomReportSchema.shape.timeBasis.nullable(), + controlEligible: z.boolean(), +}) +export type BedroomObservation = z.infer +export const bedroomAggregateSchema = z.object({ + metric: bedroomMetricSchema, unit: z.string(), value: z.number().nullable(), + status: z.enum(['ready', 'disagreement', 'unavailable']), + sourceIds: z.array(identifier), spread: z.number().nullable(), expiresAt: timestamp.nullable(), +}) +export type BedroomAggregate = z.infer +export const bedroomGuidanceSchema = z.object({ + state: z.enum(['ready', 'unavailable', 'disagreement']), + offsetF: z.union([z.literal(-1), z.literal(0), z.literal(1)]), + reason: z.string(), sourceIds: z.array(identifier), expiresAt: timestamp.nullable(), + configuration: z.string().regex(/^[a-f0-9]{64}$/).nullable(), +}) +export type BedroomComfortGuidance = z.infer +export const bedroomStatusSchema = z.object({ + apiVersion: z.literal(1), generatedAt: timestamp, revision: z.number().int().nonnegative(), + config: bedroomConfigSchema, observations: z.array(bedroomObservationSchema), aggregates: z.array(bedroomAggregateSchema), + guidance: bedroomGuidanceSchema, + mqtt: z.object({ connected: z.boolean(), topic: z.string().nullable() }).optional(), +}) diff --git a/src/bedroom/fusion.ts b/src/bedroom/fusion.ts new file mode 100644 index 000000000..2b8bb52f0 --- /dev/null +++ b/src/bedroom/fusion.ts @@ -0,0 +1,70 @@ +import { BEDROOM_METRICS, type BedroomAggregate, type BedroomMetric, type BedroomObservation, type BedroomComfortGuidance } from './contract' + +const roomMetrics: BedroomMetric[] = ['temperature', 'humidity', 'pm25', 'co2', 'airQuality'] + +/** Recheck the room lease at the decision point, including while a previous target is in cooldown. */ +export function currentBedroomGuidance(input: BedroomComfortGuidance | null | undefined, now: number): boolean { + return input?.state === 'ready' && input.configuration !== null && /^[a-f0-9]{64}$/.test(input.configuration) + && Number.isInteger(input.offsetF) && Math.abs(input.offsetF) <= 1 && input.sourceIds.length > 0 + && input.expiresAt !== null && Number.isSafeInteger(input.expiresAt) && now < input.expiresAt + && input.expiresAt - now <= 900_000 +} + +/** Independent physical devices vote once. Conflicting climate readings never become a control signal. */ +export function combineBedroomSensors(observations: BedroomObservation[], now: number): BedroomAggregate[] { + return roomMetrics.map((metric) => { + const definition = BEDROOM_METRICS[metric] + const independent = new Map() + for (const observation of observations) { + if (observation.source.metric !== metric || observation.status !== 'fresh' || !observation.controlEligible + || !observation.source.enabled || !observation.source.useForControl + || observation.value === null || !Number.isFinite(observation.value) || observation.expiresAt === null + || observation.expiresAt <= now || observation.observedAt === null || observation.observedAt > now) continue + if (!independent.has(observation.source.deviceId)) independent.set(observation.source.deviceId, { + sourceId: observation.source.id, value: observation.value, expiresAt: observation.expiresAt, + }) + } + const contributors = [...independent.values()] + const values = contributors.map(item => item.value).sort((first, second) => first - second) + const maximum = values.at(-1) ?? null + const spread = maximum === null ? null : maximum - values[0] + const disagreement = spread !== null && definition.disagreement !== null && spread > definition.disagreement + const middle = Math.floor(values.length / 2) + const median = values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2 + // Air-quality metrics keep the worst qualified reading; categorical AQ is not a particulate concentration. + const value = metric === 'temperature' || metric === 'humidity' ? median : maximum + return { + metric, unit: definition.unit, value: values.length && !disagreement ? value : null, + status: !values.length ? 'unavailable' : disagreement ? 'disagreement' : 'ready', + sourceIds: contributors.map(item => item.sourceId), spread, + expiresAt: contributors.length ? Math.min(...contributors.map(item => item.expiresAt)) : null, + } + }) +} + +/** A bounded room contribution to the existing comfort controller, never a hardware command. */ +export function bedroomComfortGuidance(aggregates: BedroomAggregate[], now: number, configuration: string | null = null): BedroomComfortGuidance { + const temperature = aggregates.find(item => item.metric === 'temperature') + const humidity = aggregates.find(item => item.metric === 'humidity') + const inputs = [temperature, humidity] + const sourceIds = inputs.flatMap(item => item?.sourceIds ?? []) + if (inputs.some(item => item?.status === 'disagreement')) return { + state: 'disagreement', offsetF: 0, reason: 'Bedroom sensors disagree. Room-based adjustments are withheld.', sourceIds, expiresAt: null, configuration, + } + if (!temperature || !humidity || temperature.status !== 'ready' || humidity.status !== 'ready' + || temperature.value === null || humidity.value === null || temperature.expiresAt === null || humidity.expiresAt === null + || now >= temperature.expiresAt || now >= humidity.expiresAt) return { + state: 'unavailable', offsetF: 0, reason: 'Waiting for qualified bedroom temperature and humidity.', sourceIds, expiresAt: null, configuration, + } + const temperatureF = temperature.value + const humidityPercent = humidity.value + const offsetF = temperatureF > 75 || (temperatureF >= 72 && humidityPercent > 65) ? -1 : temperatureF < 64 ? 1 : 0 + return { + state: 'ready', offsetF, sourceIds, expiresAt: Math.min(temperature.expiresAt, humidity.expiresAt), configuration, + reason: offsetF === -1 + ? 'Warm bedroom conditions suggest a one-degree cooler comfort target.' + : offsetF === 1 + ? 'Cool bedroom conditions suggest a one-degree warmer comfort target.' + : 'Bedroom conditions do not add a temperature adjustment.', + } +} diff --git a/src/bedroom/instance.ts b/src/bedroom/instance.ts new file mode 100644 index 000000000..167427ba9 --- /dev/null +++ b/src/bedroom/instance.ts @@ -0,0 +1,11 @@ +import { sqlite } from '@/src/db' +import { createBedroomStore } from './store' + +const globals = globalThis as typeof globalThis & { __sp_bedroom?: ReturnType } +export function getBedroom() { + return globals.__sp_bedroom ??= createBedroomStore(sqlite) +} + +export function getBedroomIfRunning() { + return globals.__sp_bedroom ?? null +} diff --git a/src/bedroom/mqtt.ts b/src/bedroom/mqtt.ts new file mode 100644 index 000000000..23cc481c8 --- /dev/null +++ b/src/bedroom/mqtt.ts @@ -0,0 +1,10 @@ +import type { createBedroomStore } from './store' + +/** Dedicated read-only topic. Broker replay never becomes current bedroom telemetry. */ +export function receiveBedroomMqtt(store: ReturnType, payload: Buffer, flags: { retain: boolean, dup: boolean }): boolean { + if (flags.retain || flags.dup || payload.length === 0 || payload.length > 4096) return false + try { + return store.receive(JSON.parse(payload.toString('utf8')), 'mqtt').accepted + } + catch { return false } +} diff --git a/src/bedroom/presentation.ts b/src/bedroom/presentation.ts new file mode 100644 index 000000000..b1060cc8d --- /dev/null +++ b/src/bedroom/presentation.ts @@ -0,0 +1,31 @@ +import type { BedroomObservation } from './contract' + +/** Compare elapsed browser time with the server's remaining lease, without assuming clocks are identical. */ +export function bedroomReadingCurrent(generatedAt: number, receivedAt: number, expiresAt: number | null, now: number, failed = false): boolean { + if (failed || !Number.isSafeInteger(generatedAt) || !Number.isSafeInteger(receivedAt) || expiresAt === null) return false + const elapsed = now - receivedAt + return elapsed >= 0 && elapsed < 15_000 && expiresAt - generatedAt > elapsed +} + +/** Historical display is separate from freshness and never contributes to an aggregate or control decision. */ +export function bedroomReportedReading(observation: BedroomObservation, generatedAt: number) { + const timestamp = observation.measuredAt ?? observation.observedAt + if (observation.status === 'disabled' || observation.status === 'unavailable' + || observation.lastValue === null || !Number.isFinite(observation.lastValue) + || timestamp === null || !Number.isSafeInteger(timestamp) || timestamp <= 0 + || !Number.isSafeInteger(generatedAt) || timestamp > generatedAt) return null + return { value: observation.lastValue, timestamp } +} + +export function bedroomValueLabel(metric: BedroomObservation['source']['metric'], value: number | null, unit: string) { + if (value === null) return '—' + if (metric === 'power') return value === 1 ? 'On' : 'Off' + if (metric === 'airQuality') return ['Unknown', 'Excellent', 'Good', 'Fair', 'Inferior', 'Poor'][value] ?? 'Unknown' + return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${unit === '°F' || unit === '%' ? '' : ' '}${unit}` +} + +export const bedroomProviderHint = { + apple_home: 'Waiting for an Apple Home sensor export. Discovering a HomePod alone does not deliver its temperature or humidity.', + homebridge: 'Waiting for the Homebridge publisher. Cached reports can be displayed but cannot drive room adjustments.', + home_assistant: 'Waiting for a sensor report from Home Assistant. Check the publisher and its source mapping.', +} diff --git a/src/bedroom/store.ts b/src/bedroom/store.ts new file mode 100644 index 000000000..152140b08 --- /dev/null +++ b/src/bedroom/store.ts @@ -0,0 +1,151 @@ +import type Database from 'better-sqlite3' +import { createHash } from 'node:crypto' +import { BEDROOM_METRICS, bedroomConfigSchema, bedroomReportSchema, bedroomStatusSchema, defaultBedroomConfig } from './contract' +import type { BedroomConfig, BedroomObservation, BedroomReport, BedroomSource } from './contract' +import { bedroomComfortGuidance, combineBedroomSensors } from './fusion' + +interface StoredReading { + sourceId: string + revision: number + observedAt: number + receivedAt: number + lastMeasuredAt: number | null + report: string +} +const columns = 'source_id AS sourceId, revision, observed_at AS observedAt, received_at AS receivedAt, last_measured_at AS lastMeasuredAt, report' +const RETENTION_MS = 30 * 86400000 +const HISTORY_LIMIT = 250000 +export class BedroomConfigConflict extends Error {} + +function normalizedValue(source: BedroomSource, value: number | null) { + return value !== null && source.unit === '°C' ? value * 9 / 5 + 32 : value +} + +/** Durable clocks reject replay; process-local receipts prevent restart from promoting saved data to live. */ +export function createBedroomStore(database: Database.Database, clock = Date.now) { + const live = new Map() + let mqttConnected = false + let mqttTopic: string | null = null + function now() { + const current = clock() + if (!Number.isSafeInteger(current) || current <= 0 || current > 7_999_999_000_000) throw new Error('Bedroom clock unavailable') + return current + } + function configuration(): { revision: number, config: BedroomConfig } { + const row = database.prepare('SELECT revision, config FROM bedroom_settings WHERE id = 1').get() as { revision: number, config: string } | undefined + return row + ? { revision: row.revision, config: bedroomConfigSchema.parse(JSON.parse(row.config)) } + : { revision: 0, config: defaultBedroomConfig } + } + function status() { + const generatedAt = now() + const { revision, config } = configuration() + const rows = database.prepare(`SELECT ${columns} FROM bedroom_latest WHERE revision = ?`).all(revision) as StoredReading[] + const observations: BedroomObservation[] = config.sources.map((source) => { + const row = rows.find(item => item.sourceId === source.id) + const report = row ? bedroomReportSchema.parse(JSON.parse(row.report)) : null + const referenceAt = report?.timeBasis === 'measurement' ? report.measuredAt : report?.observedAt + const expiresAt = referenceAt ? referenceAt + source.maxAgeSeconds * 1000 : null + const state = !config.enabled || !source.enabled + ? 'disabled' + : !row || !report || live.get(source.id)?.observedAt !== row.observedAt + ? 'waiting' + : !report.available + ? 'unavailable' + : generatedAt < report.observedAt || expiresAt === null || expiresAt <= generatedAt + ? 'stale' + : report.timeBasis === 'cached' ? 'cached' : 'fresh' + const value = report ? normalizedValue(source, report.value) : null + return { source, status: state, value: state === 'fresh' ? value : null, lastValue: value, + unit: BEDROOM_METRICS[source.metric].unit, observedAt: report?.observedAt ?? null, + measuredAt: report?.measuredAt ?? null, receivedAt: row?.receivedAt ?? null, expiresAt, + timeBasis: report?.timeBasis ?? null, controlEligible: state === 'fresh' && source.useForControl } + }) + const aggregates = combineBedroomSensors(observations, generatedAt) + return bedroomStatusSchema.parse({ apiVersion: 1, generatedAt, revision, config, observations, aggregates, + guidance: bedroomComfortGuidance(aggregates, generatedAt, configurationDigest()), mqtt: { connected: mqttConnected, topic: mqttTopic } }) + } + function configurationDigest() { + const { revision, config } = configuration() + return createHash('sha256').update(JSON.stringify([revision, config])).digest('hex') + } + return { + configuration, configurationDigest, status, + configure(input: BedroomConfig, expectedRevision: number) { + const config = bedroomConfigSchema.parse(input) + const result = database.transaction(() => { + const current = configuration() + if (current.revision !== expectedRevision) throw new BedroomConfigConflict('Bedroom configuration changed. Reload before saving.') + const revision = current.revision + 1 + database.prepare('INSERT INTO bedroom_settings (id, revision, config) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET revision=excluded.revision, config=excluded.config') + .run(revision, JSON.stringify(config)) + database.prepare('DELETE FROM bedroom_latest').run() + return { revision, config } + })() + live.clear() + return result + }, + connection(connected: boolean, topic: string | null = null) { + mqttConnected = connected + mqttTopic = topic + for (const [sourceId, receipt] of live) if (receipt.transport === 'mqtt') live.delete(sourceId) + }, + receive(input: unknown, transport: 'http' | 'mqtt' = 'http') { + if (transport === 'mqtt' && !mqttConnected) return { accepted: false, reason: 'broker_disconnected' } + const parsed = bedroomReportSchema.safeParse(input) + if (!parsed.success) return { accepted: false, reason: 'invalid_report' } + const report = parsed.data + const receivedAt = now() + const result = database.transaction(() => { + const { revision, config } = configuration() + const source = config.sources.find(item => item.id === report.sourceId) + if (!config.enabled || !source?.enabled) return { accepted: false, reason: 'source_not_enabled' } + if (revision !== report.revision) return { accepted: false, reason: 'configuration_changed' } + if (report.observedAt > receivedAt || receivedAt - report.observedAt >= 30000) return { accepted: false, reason: 'report_clock' } + const previous = database.prepare(`SELECT ${columns} FROM bedroom_latest WHERE source_id = ?`).get(source.id) as StoredReading | undefined + if (previous && (report.observedAt <= previous.observedAt || receivedAt < previous.receivedAt)) return { accepted: false, reason: 'replayed_report' } + const oldReport: BedroomReport | null = previous ? bedroomReportSchema.parse(JSON.parse(previous.report)) : null + if (previous && receivedAt - previous.receivedAt < 5000 && (report.available || !oldReport?.available)) return { accepted: false, reason: 'report_too_frequent' } + const value = normalizedValue(source, report.value) + if (report.available && value !== null) { + const definition = BEDROOM_METRICS[source.metric] + if (value < definition.minimum || value > definition.maximum + || (['power', 'airQuality'].includes(source.metric) && !Number.isInteger(value))) return { accepted: false, reason: 'invalid_value' } + const referenceAt = report.timeBasis === 'measurement' ? report.measuredAt : report.observedAt + if (referenceAt === null) return { accepted: false, reason: 'measurement_clock_missing' } + if (receivedAt - referenceAt >= source.maxAgeSeconds * 1000) return { accepted: false, reason: 'measurement_expired' } + if (report.measuredAt !== null && report.measuredAt <= (previous?.lastMeasuredAt ?? 0)) return { accepted: false, reason: 'replayed_measurement' } + } + database.prepare(`INSERT INTO bedroom_latest (source_id, revision, observed_at, received_at, last_measured_at, report) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(source_id) DO UPDATE SET revision=excluded.revision, + observed_at=excluded.observed_at, received_at=excluded.received_at, last_measured_at=excluded.last_measured_at, report=excluded.report`) + .run(source.id, revision, report.observedAt, receivedAt, report.measuredAt ?? previous?.lastMeasuredAt ?? null, JSON.stringify(report)) + database.prepare('INSERT INTO bedroom_history (revision, source_id, received_at, source, report) VALUES (?, ?, ?, ?, ?)') + .run(revision, source.id, receivedAt, JSON.stringify(source), JSON.stringify(report)) + database.prepare('DELETE FROM bedroom_history WHERE received_at < ?').run(receivedAt - RETENTION_MS) + // Indexed endpoints avoid scanning the entire retained history for every sensor packet. + database.prepare('DELETE FROM bedroom_history WHERE id <= (SELECT MAX(id) FROM bedroom_history) - ?').run(HISTORY_LIMIT) + return { accepted: true, reason: 'accepted' } + })() + if (result.accepted) live.set(report.sourceId, { observedAt: report.observedAt, transport }) + return result + }, + guidance() { + return status().guidance + }, + signals(): Record { + return Object.fromEntries(status().aggregates.flatMap(item => item.status === 'ready' && item.value !== null + ? [[`room.${item.metric}`, item.value]] + : [])) + }, + history(beforeId?: number) { + const generatedAt = now() + const rows = database.prepare(`SELECT id, revision, received_at AS receivedAt, source, report FROM bedroom_history + WHERE received_at >= ? AND received_at <= ? ${beforeId === undefined ? '' : 'AND id < ?'} ORDER BY id DESC LIMIT 201`) + .all(generatedAt - RETENTION_MS, generatedAt, ...(beforeId === undefined ? [] : [beforeId])) as { id: number, revision: number, receivedAt: number, source: string, report: string }[] + return { apiVersion: 1 as const, generatedAt, items: rows.slice(0, 200).map(row => ({ ...row, + source: JSON.parse(row.source) as BedroomSource, report: JSON.parse(row.report) as BedroomReport })), + nextBeforeId: rows.length > 200 ? rows[199].id : null } + }, + } +} diff --git a/src/bedroom/tests/fusion.test.ts b/src/bedroom/tests/fusion.test.ts new file mode 100644 index 000000000..609896294 --- /dev/null +++ b/src/bedroom/tests/fusion.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { bedroomConfigSchema, bedroomReportSchema, bedroomSourceSchema, type BedroomMetric, type BedroomObservation } from '../contract' +import { bedroomComfortGuidance, combineBedroomSensors } from '../fusion' + +const now = 1793067000000 +function reading(id: string, metric: BedroomMetric, value: number, change: Partial = {}): BedroomObservation { + return { + source: bedroomSourceSchema.parse({ id, deviceId: id, deviceName: id, label: metric, provider: 'home_assistant', + externalId: `sensor.${id}`, metric, unit: metric === 'temperature' ? '°F' : metric === 'pm25' ? 'µg/m³' : '%', + maxAgeSeconds: 120, enabled: true, useForControl: true }), + status: 'fresh', value, lastValue: value, unit: metric === 'temperature' ? '°F' : '%', + observedAt: now - 1000, measuredAt: now - 1000, receivedAt: now, expiresAt: now + 119000, + timeBasis: 'measurement', controlEligible: true, ...change, + } +} + +describe('bedroom sensor agreement', () => { + it('combines independent bedside and room sources and reports their identities', () => { + const readings = [reading('left', 'temperature', 76), reading('right', 'temperature', 77), reading('monitor', 'temperature', 78), reading('humidity', 'humidity', 50)] + const result = combineBedroomSensors(readings, now) + expect(result[0]).toMatchObject({ status: 'ready', value: 77, spread: 2, sourceIds: ['left', 'right', 'monitor'] }) + expect(bedroomComfortGuidance(result, now)).toMatchObject({ state: 'ready', offsetF: -1 }) + expect(bedroomComfortGuidance(result, now + 120000)).toMatchObject({ state: 'unavailable', offsetF: 0 }) + }) + it('withholds conflicting readings instead of averaging away disagreement', () => { + const result = combineBedroomSensors([reading('left', 'temperature', 62), reading('right', 'temperature', 80), reading('humidity', 'humidity', 50)], now) + expect(result[0]).toMatchObject({ status: 'disagreement', value: null, spread: 18 }) + expect(bedroomComfortGuidance(result, now).state).toBe('disagreement') + }) + it.each([ + { status: 'cached' as const }, { status: 'stale' as const }, { controlEligible: false }, + { expiresAt: now }, { observedAt: now + 1 }, { value: NaN }, + ])('does not turn excluded readings into a control input: %j', (change) => { + expect(combineBedroomSensors([reading('monitor', 'temperature', 78, change)], now)[0].status).toBe('unavailable') + }) + it('does not count a second integration of the same physical sensor twice', () => { + const first = reading('monitor', 'temperature', 70) + const duplicate = { ...first, value: 73, source: { ...first.source, id: 'other_bridge' } } + expect(combineBedroomSensors([first, duplicate, reading('left', 'temperature', 72)], now)[0]) + .toMatchObject({ value: 71, sourceIds: ['monitor', 'left'] }) + expect(bedroomConfigSchema.safeParse({ enabled: true, roomName: 'Bedroom', sources: [first.source, duplicate.source] }).success).toBe(false) + }) + it('keeps air quality separate from thermal guidance and uses the worst particulate reading', () => { + const result = combineBedroomSensors([reading('temp', 'temperature', 70), reading('rh', 'humidity', 50), reading('particles1', 'pm25', 5), reading('particles2', 'pm25', 55)], now) + expect(result.find(item => item.metric === 'pm25')?.value).toBe(55) + expect(bedroomComfortGuidance(result, now).offsetF).toBe(0) + }) + it('requires an actual measurement time when a publisher claims measured data', () => { + expect(bedroomReportSchema.safeParse({ schemaVersion: 1, revision: 1, sourceId: 'monitor', available: true, + value: 72, observedAt: now, measuredAt: null, timeBasis: 'measurement' }).success).toBe(false) + }) +}) diff --git a/src/bedroom/tests/presentation.test.ts b/src/bedroom/tests/presentation.test.ts new file mode 100644 index 000000000..5718b4444 --- /dev/null +++ b/src/bedroom/tests/presentation.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from 'vitest' +import { bedroomObservationSchema } from '../contract' +import { bedroomReadingCurrent, bedroomReportedReading } from '../presentation' + +it('uses the server lease duration even if the phone clock differs, and expires disconnected data', () => { + const generatedAt = 1793067000000 + const receivedAt = generatedAt - 30000 + expect(bedroomReadingCurrent(generatedAt, receivedAt, generatedAt + 5000, receivedAt)).toBe(true) + expect(bedroomReadingCurrent(generatedAt, receivedAt, generatedAt + 5000, receivedAt + 5000)).toBe(false) + expect(bedroomReadingCurrent(generatedAt, receivedAt, generatedAt + 120000, receivedAt + 15000)).toBe(false) + expect(bedroomReadingCurrent(generatedAt, receivedAt, generatedAt + 120000, receivedAt, true)).toBe(false) + expect(bedroomReadingCurrent(generatedAt, receivedAt, generatedAt + 120000, receivedAt - 1)).toBe(false) +}) + +it('shows only timestamped past reports as history, retaining zero without promoting freshness', () => { + const now = 1793067000000 + const observation = bedroomObservationSchema.parse({ + source: { id: 'power', deviceId: 'diffuser', deviceName: 'Diffuser', label: 'Power', provider: 'homebridge', + externalId: 'synthetic-power', metric: 'power', unit: 'on/off', maxAgeSeconds: 120, enabled: true, useForControl: false }, + status: 'cached', value: null, lastValue: 0, unit: 'on/off', observedAt: now - 60000, + measuredAt: null, receivedAt: now - 60000, expiresAt: now - 1000, timeBasis: 'cached', controlEligible: false, + }) + expect(bedroomReportedReading(observation, now)).toEqual({ value: 0, timestamp: now - 60000 }) + expect(bedroomReportedReading({ ...observation, observedAt: now + 1 }, now)).toBeNull() + expect(bedroomReportedReading({ ...observation, observedAt: null }, now)).toBeNull() + expect(bedroomReportedReading({ ...observation, lastValue: null }, now)).toBeNull() + expect(bedroomReportedReading({ ...observation, lastValue: NaN }, now)).toBeNull() + expect(bedroomReportedReading({ ...observation, status: 'disabled' }, now)).toBeNull() + expect(bedroomReportedReading({ ...observation, status: 'unavailable' }, now)).toBeNull() +}) diff --git a/src/bedroom/tests/signals.test.ts b/src/bedroom/tests/signals.test.ts new file mode 100644 index 000000000..4b95a1a1c --- /dev/null +++ b/src/bedroom/tests/signals.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { migratedComfortDatabase } from '@/src/comfort/tests/databaseFixture' +import { createBedroomStore } from '../store' +import { bedroomConfigSchema } from '../contract' + +const runtime = vi.hoisted(() => ({ database: undefined as unknown as ReturnType })) +vi.mock('@/src/db', () => ({ get sqlite() { + return runtime.database +} })) +let database: ReturnType +beforeEach(() => { + database = migratedComfortDatabase() + runtime.database = database + vi.resetModules() +}) +afterEach(() => { + delete (globalThis as typeof globalThis & { __sp_bedroom?: unknown }).__sp_bedroom + delete (globalThis as typeof globalThis & { __sp_roomContext?: unknown }).__sp_roomContext + database.close() +}) + +it('switches rule input to the combined sources and withdraws missing data without a legacy fallback', async () => { + const { getRoomContext, RoomSignalReader } = await import('@/src/home-context/instance') + const legacy = getRoomContext() + const legacyRead = vi.spyOn(legacy, 'signals').mockReturnValue({ 'room.temperature': 90 }) + const reader = new RoomSignalReader() + expect(reader.read()).toEqual({ 'room.temperature': 90 }) + const combined = createBedroomStore(database) + combined.configure(bedroomConfigSchema.parse({ enabled: true, roomName: 'Bedroom', sources: [] }), 0) + legacyRead.mockClear() + expect(reader.read()).toEqual({}) + expect(legacyRead).not.toHaveBeenCalled() +}) diff --git a/src/bedroom/tests/store.test.ts b/src/bedroom/tests/store.test.ts new file mode 100644 index 000000000..d7fcdfc35 --- /dev/null +++ b/src/bedroom/tests/store.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { migratedComfortDatabase } from '@/src/comfort/tests/databaseFixture' +import { bedroomConfigSchema, bedroomSourceSchema } from '../contract' +import { createBedroomStore } from '../store' +import { receiveBedroomMqtt } from '../mqtt' + +const start = 1793067000000 +const source = bedroomSourceSchema.parse({ id: 'monitor_temperature', deviceId: 'monitor', deviceName: 'Air monitor', + label: 'Room temperature', provider: 'home_assistant', externalId: 'sensor.monitor_temperature', metric: 'temperature', + unit: '°C', maxAgeSeconds: 120, enabled: true, useForControl: true }) +const config = bedroomConfigSchema.parse({ enabled: true, roomName: 'Bedroom', sources: [source] }) +let database: ReturnType, store: ReturnType, now: number +function report(change: Record = {}) { + return { schemaVersion: 1, revision: 1, sourceId: source.id, available: true, value: 24, + observedAt: now, measuredAt: now - 1000, timeBasis: 'measurement', ...change } +} +beforeEach(() => { + database = migratedComfortDatabase() + now = start + store = createBedroomStore(database, () => now) + store.configure(config, 0) +}) +afterEach(() => database.close()) + +describe('persisted bedroom inputs', () => { + it('rejects broker replay and withdraws only broker-delivered readings when the connection is lost', () => { + const packet = () => Buffer.from(JSON.stringify(report())) + expect(receiveBedroomMqtt(store, packet(), { retain: false, dup: false })).toBe(false) + store.connection(true, 'fixture/pod/bedroom/report') + expect(receiveBedroomMqtt(store, packet(), { retain: true, dup: false })).toBe(false) + expect(receiveBedroomMqtt(store, packet(), { retain: false, dup: true })).toBe(false) + expect(receiveBedroomMqtt(store, packet(), { retain: false, dup: false })).toBe(true) + expect(store.status().observations[0].status).toBe('fresh') + store.connection(false) + expect(store.signals()).toEqual({}) + store.connection(true, 'fixture/pod/bedroom/report') + expect(store.status().observations[0].status).toBe('waiting') + expect(receiveBedroomMqtt(store, packet(), { retain: false, dup: false })).toBe(false) + now += 5000 + expect(store.receive(report()).accepted).toBe(true) + store.connection(false) + expect(store.status().observations[0].status).toBe('fresh') + }) + it('uses source units, preserves measurement time and expires without a new packet', () => { + expect(store.status().observations[0].status).toBe('waiting') + expect(store.receive(report()).accepted).toBe(true) + expect(store.status().observations[0]).toMatchObject({ value: 75.2, unit: '°F', measuredAt: start - 1000, + observedAt: start, receivedAt: start, status: 'fresh', controlEligible: true }) + expect(store.signals()).toEqual({ 'room.temperature': 75.2 }) + now += 119000 + expect(store.status().observations[0]).toMatchObject({ value: null, lastValue: 75.2, status: 'stale', controlEligible: false }) + expect(store.signals()).toEqual({}) + expect(store.history().items[0].report.value).toBe(24) + expect(store.history().items[0].source.unit).toBe('°C') + }) + it('does not refresh cached values just because the bridge answered a query', () => { + expect(store.receive(report({ timeBasis: 'cached', measuredAt: null })).accepted).toBe(true) + expect(store.status().observations[0]).toMatchObject({ status: 'cached', value: null, lastValue: 75.2, controlEligible: false }) + expect(store.signals()).toEqual({}) + }) + it('distinguishes a confirmed device read from a hardware measurement timestamp', () => { + expect(store.receive(report({ timeBasis: 'device_read', measuredAt: null })).accepted).toBe(true) + expect(store.status().observations[0]).toMatchObject({ timeBasis: 'device_read', measuredAt: null, observedAt: start, expiresAt: start + 120000 }) + }) + it('does not reuse saved readings as live after restart or reconfigure', () => { + const first = report() + store.receive(first) + store = createBedroomStore(database, () => now) + expect(store.status().observations[0].status).toBe('waiting') + expect(store.receive(first).accepted).toBe(false) + now += 6000 + expect(store.receive(report({ measuredAt: start - 1000 })).reason).toBe('replayed_measurement') + expect(store.receive(report()).accepted).toBe(true) + expect(() => store.configure(config, 0)).toThrow(/changed/) + store.configure(config, 1) + expect(store.status().observations[0].status).toBe('waiting') + expect(store.receive(report()).reason).toBe('configuration_changed') + }) + it('withdraws an unavailable source immediately and preserves the measurement watermark', () => { + store.receive(report()) + now += 1 + expect(store.receive(report({ available: false, value: null, measuredAt: null })).accepted).toBe(true) + expect(store.status().observations[0].status).toBe('unavailable') + now += 6000 + expect(store.receive(report({ measuredAt: start - 1000 })).reason).toBe('replayed_measurement') + expect(store.signals()).toEqual({}) + }) + it.each([ + { value: 200 }, { value: NaN }, { value: '24' }, { available: false }, { sourceId: 'another' }, + { observedAt: start + 1 }, { observedAt: start - 30000, measuredAt: start - 31000 }, + { measuredAt: start - 120000 }, { measuredAt: start + 1 }, { revision: 2 }, { unexpected: 'field' }, + ])('rejects malformed, expired, unmapped and future observations: %j', (change) => { + expect(store.receive(report(change)).accepted).toBe(false) + expect(store.signals()).toEqual({}) + }) + it('keeps display-only sensors out of automation and accepts multiple devices per metric', () => { + const bedside = { ...source, id: 'left_temperature', deviceId: 'left_homepod', externalId: 'left.temp', unit: '°F' as const, useForControl: false } + store.configure({ ...config, sources: [source, bedside] }, 1) + store.receive(report({ revision: 2 })) + store.receive(report({ revision: 2, sourceId: bedside.id, value: 95 })) + expect(store.status().observations).toHaveLength(2) + expect(store.status().aggregates[0]).toMatchObject({ value: 75.2, status: 'ready', sourceIds: [source.id] }) + }) +}) diff --git a/src/biometrics/backtest/jobs.ts b/src/biometrics/backtest/jobs.ts index afdd33f19..68dd61f45 100644 --- a/src/biometrics/backtest/jobs.ts +++ b/src/biometrics/backtest/jobs.ts @@ -57,14 +57,16 @@ export class BacktestJobStore { if (protocol.waveformStages === undefined) delete protocol.waveformStages key.parse(idempotencyKey) const requestHash = configHash({ datasetId, protocol }) - const dataset = new DatasetStore(this.db).read(datasetId) - if (protocol.mode === 'signal_replay' && dataset.snapshot.version !== 'waveform-dataset-v1') throw new ReferenceInputError('INSUFFICIENT_RAW_DATA', 'Signal replay requires retained waveform inputs') return this.db.transaction((tx) => { const existing = tx.select().from(experiments).where(eq(experiments.idempotencyKey, idempotencyKey)).get() if (existing) { if (existing.requestHash !== requestHash) throw new ReferenceInputError('REVISION_CONFLICT', 'Experiment idempotency key changed') return existing } + // Recover a committed request before reading inputs that the owner may + // since have released. New experiments still require verified retention. + const dataset = new DatasetStore(tx).read(datasetId) + if (protocol.mode === 'signal_replay' && dataset.snapshot.version !== 'waveform-dataset-v1') throw new ReferenceInputError('INSUFFICIENT_RAW_DATA', 'Signal replay requires retained waveform inputs') const total = tx.select({ count: sql`count(*)` }).from(experiments).get()?.count ?? 0 if (total >= 1000) throw new ReferenceInputError('RESOURCE_LIMIT', 'Experiment history has reached its 1000-entry bound') return tx.insert(experiments).values({ id: randomUUID(), datasetId, idempotencyKey, requestHash, side: dataset.side, diff --git a/src/biometrics/backtest/tests/jobs.test.ts b/src/biometrics/backtest/tests/jobs.test.ts index f6057b741..46e736a82 100644 --- a/src/biometrics/backtest/tests/jobs.test.ts +++ b/src/biometrics/backtest/tests/jobs.test.ts @@ -144,6 +144,20 @@ it('refuses signal experiments without immutable waveform inputs', () => { }) describe('experiment and job bookkeeping', () => { + it('recovers an exact experiment retry after dataset release without reopening inputs or accepting a changed request', () => { + const original = store.createExperiment(datasetId, protocol, 'lost-experiment-reply') + datasets.release(datasetId) + now += 60000 + const restarted = new BacktestJobStore(db, () => now) + expect(restarted.createExperiment(datasetId, { ...protocol, waveformStages: undefined }, 'lost-experiment-reply')).toEqual(original) + expect(() => restarted.createExperiment(datasetId, { ...protocol, clockOffsetMs: 1 }, 'lost-experiment-reply')).toThrow('idempotency key changed') + expect(() => restarted.createExperiment('00000000-0000-4000-8000-000000000000', protocol, 'lost-experiment-reply')).toThrow('idempotency key changed') + expect(() => restarted.createExperiment(datasetId, protocol, 'new-experiment')).toThrow('explicitly released') + expect(() => restarted.createExperiment(datasetId, { ...protocol, assumedStageCalibrationQuality: 2 }, 'lost-experiment-reply')).toThrow() + expect(() => restarted.createExperiment(datasetId, protocol, '')).toThrow() + expect(() => datasets.read(datasetId)).toThrow('explicitly released') + expect(db.select().from(schema.biometricExperiments).all()).toHaveLength(2) + }) it('deduplicates experiments by idempotency key and pages them newest first', () => { const experiment = store.getExperiment(experimentId) expect(experiment.protocol).toEqual(protocol) diff --git a/src/biometrics/mechanical/context.ts b/src/biometrics/mechanical/context.ts new file mode 100644 index 000000000..fc9b5fd20 --- /dev/null +++ b/src/biometrics/mechanical/context.ts @@ -0,0 +1,50 @@ +import { z } from 'zod' + +export const MECHANICAL_CONTEXT_VERSION = 'mechanical-context-v1' as const +export const MECHANICAL_FRESH_MS = 10_000 +export const MECHANICAL_SETTLE_MS = 10_000 +export const MECHANICAL_RETENTION_MS = 35 * 86400000 +export const MECHANICAL_MAX_SPANS = 20_000 +const timestamp = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const mechanicalKindSchema = z.enum(['priming', 'vibration', 'base']) +export type MechanicalKind = z.infer +export const mechanicalReasonSchema = z.enum(['priming', 'vibration_command', 'base_motion', 'device_unavailable', 'base_unavailable', 'alarm_state_unavailable', 'settling', 'controller_restart', 'controller_stopped', 'clock_regression', 'observation_gap']) +export type MechanicalReason = z.infer +export const mechanicalStateSchema = z.object({ + version: z.literal(MECHANICAL_CONTEXT_VERSION), epoch: z.string().uuid(), + publishedAtMs: timestamp, confirmedThroughMs: timestamp.nullable(), validUntilMs: timestamp, + blockedUntilMs: timestamp, cleanSinceMs: timestamp.nullable(), spanId: z.string().uuid().nullable(), + reasons: z.array(mechanicalReasonSchema).min(0).max(12), + commandAtMs: timestamp.nullable(), commandUntilMs: timestamp, +}).strict().refine(state => (state.cleanSinceMs === null) === (state.spanId === null), 'Quiet-span identity mismatch') + .refine(state => state.confirmedThroughMs === null || state.confirmedThroughMs <= state.publishedAtMs, 'Future mechanical observation') + .refine(state => state.cleanSinceMs === null + ? state.reasons.length > 0 + : state.reasons.length === 0 && state.confirmedThroughMs !== null + && state.blockedUntilMs <= state.cleanSinceMs && state.cleanSinceMs <= state.confirmedThroughMs + && state.validUntilMs === state.confirmedThroughMs + MECHANICAL_FRESH_MS, 'Invalid quiet evidence') +export type MechanicalState = z.infer + +export interface MechanicalObservation { + now: number + deviceAt: number | null + baseAt: number | null + priming: boolean | null + vibrationCommandActive: boolean | null + baseMoving: boolean | null +} + +/** Only actual source timestamps extend evidence; polling a cached value does not. */ +export function mechanicalObservation(input: MechanicalObservation) { + const now = timestamp.parse(input.now) + const fresh = (at: number | null) => at !== null && Number.isSafeInteger(at) && at > 0 && at <= now && now - at < MECHANICAL_FRESH_MS + const reasons: MechanicalReason[] = [] + if (!fresh(input.deviceAt) || typeof input.priming !== 'boolean') reasons.push('device_unavailable') + else if (input.priming) reasons.push('priming') + if (typeof input.vibrationCommandActive !== 'boolean') reasons.push('alarm_state_unavailable') + else if (input.vibrationCommandActive) reasons.push('vibration_command') + if (!fresh(input.baseAt) || typeof input.baseMoving !== 'boolean') reasons.push('base_unavailable') + else if (input.baseMoving) reasons.push('base_motion') + const observedAt = reasons.length || input.deviceAt === null || input.baseAt === null ? null : Math.min(input.deviceAt, input.baseAt) + return { now, reasons, observedAt } +} diff --git a/src/biometrics/mechanical/dispatch.ts b/src/biometrics/mechanical/dispatch.ts new file mode 100644 index 000000000..5cdf68ff3 --- /dev/null +++ b/src/biometrics/mechanical/dispatch.ts @@ -0,0 +1,24 @@ +import type { MechanicalKind } from './context' + +interface Observer { command: (kind: MechanicalKind, durationMs: number) => void } +const globals = globalThis as typeof globalThis & { __sp_mechanicalObserver?: Observer } + +/** Production installs the recorder after migrations, before hardware initialization. */ +export function installMechanicalObserver(observer: Observer) { + if (globals.__sp_mechanicalObserver && globals.__sp_mechanicalObserver !== observer) throw new Error('Mechanical observer already installed') + globals.__sp_mechanicalObserver = observer + return () => { + if (globals.__sp_mechanicalObserver === observer) delete globals.__sp_mechanicalObserver + } +} + +/** Starting interference needs a durable record. Emergency cancellation remains available. */ +export function recordMechanicalCommand(kind: MechanicalKind, durationMs = 0, emergency = false) { + try { + globals.__sp_mechanicalObserver?.command(kind, durationMs) + } + catch (error) { + if (!emergency) throw error + console.warn('[biometrics] Could not record cancellation context; await fresh observations before learning.') + } +} diff --git a/src/biometrics/mechanical/eligibility.ts b/src/biometrics/mechanical/eligibility.ts new file mode 100644 index 000000000..bace3b9e9 --- /dev/null +++ b/src/biometrics/mechanical/eligibility.ts @@ -0,0 +1,22 @@ +import type Database from 'better-sqlite3' +import { parseVitalEvidence, VITAL_EVIDENCE_QUERY } from './vital-evidence' +import { readMechanicalSnapshot } from './snapshot' + +/** Only fresh, proven live rows in the current quiet interval may drive rules. + * Historical/legacy observations remain visible but never acquire new proof. */ +export function isMechanicallyQualifiedVital(database: Database.Database, id: number, now: number): boolean { + try { + return database.transaction(() => { + const snapshot = readMechanicalSnapshot(database, now) + const state = snapshot?.state + if (!state || state.reasons.length || !state.spanId || state.confirmedThroughMs === null + || now >= state.validUntilMs || state.confirmedThroughMs > now) return false + const { timestampMs, proof } = parseVitalEvidence(database.prepare(`${VITAL_EVIDENCE_QUERY} WHERE v.id=? LIMIT 1`).get(id)) + if (timestampMs > now || now - timestampMs > 300_000) return false + return proof.epoch === state.epoch && proof.spanId === state.spanId && proof.endMs <= state.confirmedThroughMs + && snapshot.spans.some(span => span.id === proof.spanId && span.epoch === proof.epoch + && span.startMs <= proof.startMs && span.endMs >= proof.endMs) + })() + } + catch { return false } +} diff --git a/src/biometrics/mechanical/instance.ts b/src/biometrics/mechanical/instance.ts new file mode 100644 index 000000000..386f9e30c --- /dev/null +++ b/src/biometrics/mechanical/instance.ts @@ -0,0 +1,62 @@ +import { db, biometricsDb } from '@/src/db' +import { deviceState } from '@/src/db/schema' +import { getDacMonitorIfRunning } from '@/src/hardware/dacMonitor.instance' +import { getBaseControllerIfRunning } from '@/src/hardware/base/instance' +import { MECHANICAL_FRESH_MS } from './context' +import { installMechanicalObserver } from './dispatch' +import { MechanicalStore } from './store' + +const globals = globalThis as typeof globalThis & { __sp_mechanicalRuntime?: { stop: () => void } } + +/** Observe existing hardware only. Missing telemetry is unknown, including an unconfigured base. */ +export function startMechanicalContext() { + if (globals.__sp_mechanicalRuntime) return + const store = new MechanicalStore(biometricsDb.$client) + let stopped = false, failed = false + const sample = () => { + const now = Date.now() + try { + const monitor = getDacMonitorIfRunning() + const status = monitor?.getFreshStatus(MECHANICAL_FRESH_MS) + const base = getBaseControllerIfRunning()?.status() + const states = db.select().from(deviceState).all() + const alarmKnown = states.length === 2 && ['left', 'right'].every(side => states.some(row => row.side === side)) + && states.every(row => typeof row.isAlarmVibrating === 'boolean') + store.observe({ now, deviceAt: status ? monitor?.getLastPollAt() ?? null : null, + priming: status?.isPriming ?? null, + // Firmware has no vibration sensor. This is command-derived evidence. + vibrationCommandActive: alarmKnown ? states.some(row => row.isAlarmVibrating) : null, + baseAt: base?.state === 'connected' && !base.stale ? base.lastUpdate : null, + baseMoving: base?.busy ? true : base?.moving ?? null }) + failed = false + } + catch { + store.invalidate(now) + if (!failed) console.warn('[biometrics] Mechanical context unavailable; withholding affected estimates.') + failed = true + } + } + installMechanicalObserver({ command(kind, durationMs) { + try { + if (stopped) throw new Error('Mechanical context has stopped') + store.command(kind, Date.now(), durationMs) + } + catch (error) { + store.invalidate(Date.now(), Math.max(durationMs, 180_000)) + throw error + } + } }) + sample() + const timer = setInterval(sample, 1000) + timer.unref?.() + globals.__sp_mechanicalRuntime = { stop() { + stopped = true + clearInterval(timer) + store.stop(Date.now()) + // Keep the stopped recorder installed: new interference fails closed. + } } +} + +export function stopMechanicalContext() { + globals.__sp_mechanicalRuntime?.stop() +} diff --git a/src/biometrics/mechanical/proof.ts b/src/biometrics/mechanical/proof.ts new file mode 100644 index 000000000..8b6d703b7 --- /dev/null +++ b/src/biometrics/mechanical/proof.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +const identity = z.string().uuid() +/** Historical rows without a receipt remain unqualified; never backfill proof. */ +export function mechanicalWindowProof(flags: unknown, row: { + timestampMs: number + heartRate: number | null + breathingRate: number | null + hrv: number | null +}) { + const values = z.array(z.string().max(512)).max(64).parse(flags) + const receipts = values.filter(value => value.startsWith('mechanical_context_v1:')) + if (receipts.length !== 1) throw new Error('Mechanical window receipt unavailable or ambiguous') + const [version, epoch, spanId, from, through, extra] = receipts[0].split(':') + identity.parse(epoch) + identity.parse(spanId) + const startMs = Number(from), endMs = Number(through) + const duration = Math.max(row.heartRate === null ? 0 : 30_000, + row.breathingRate === null ? 0 : 60_000, row.hrv === null ? 0 : 300_000) + if (version !== 'mechanical_context_v1' || extra !== undefined || duration === 0 + || !/^\d+$/.test(from) || !/^\d+$/.test(through) || !Number.isSafeInteger(startMs) || !Number.isSafeInteger(endMs) + || endMs < row.timestampMs + 1000 || endMs > row.timestampMs + 2000 + || endMs - startMs < duration + 2000 || endMs - startMs > duration + 2001) throw new Error('Invalid mechanical window receipt') + return { epoch, spanId, startMs, endMs } +} diff --git a/src/biometrics/mechanical/snapshot.ts b/src/biometrics/mechanical/snapshot.ts new file mode 100644 index 000000000..3800dd024 --- /dev/null +++ b/src/biometrics/mechanical/snapshot.ts @@ -0,0 +1,24 @@ +import type Database from 'better-sqlite3' +import { z } from 'zod' +import { MECHANICAL_FRESH_MS, mechanicalStateSchema } from './context' + +const time = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const mechanicalSpanSchema = z.object({ id: z.string().uuid(), epoch: z.string().uuid(), + startMs: time, endMs: time }).strict().refine(span => span.startMs <= span.endMs) +export const mechanicalSnapshotSchema = z.object({ state: mechanicalStateSchema, + spans: z.array(mechanicalSpanSchema).max(64) }).strict() + +/** Bounded read-only snapshot, shared by diagnostics and prospective capture. */ +export function readMechanicalSnapshot(database: Database.Database, now: number) { + try { + return database.transaction(() => { + const row = database.prepare('SELECT CASE WHEN length(CAST(payload AS BLOB)) <= 8192 THEN payload END AS payload FROM biometric_mechanical_state WHERE id=1').get() as { payload: string } | undefined + if (!row || !Number.isSafeInteger(now)) return null + const state = mechanicalStateSchema.parse(JSON.parse(row.payload)) + if (state.publishedAtMs > now || now - state.publishedAtMs >= MECHANICAL_FRESH_MS) return null + const spans = database.prepare('SELECT id, epoch, start_ms AS startMs, end_ms AS endMs FROM biometric_mechanical_spans WHERE end_ms >= ? AND end_ms <= ? ORDER BY end_ms DESC, id DESC LIMIT 64').all(now - 600_000, now) + return mechanicalSnapshotSchema.parse({ state, spans }) + })() + } + catch { return null } +} diff --git a/src/biometrics/mechanical/store.ts b/src/biometrics/mechanical/store.ts new file mode 100644 index 000000000..9627c9e68 --- /dev/null +++ b/src/biometrics/mechanical/store.ts @@ -0,0 +1,108 @@ +import type Database from 'better-sqlite3' +import { randomUUID } from 'node:crypto' +import { MECHANICAL_CONTEXT_VERSION, MECHANICAL_FRESH_MS, MECHANICAL_MAX_SPANS, MECHANICAL_RETENTION_MS, MECHANICAL_SETTLE_MS, mechanicalKindSchema, mechanicalObservation, mechanicalStateSchema } from './context' +import type { MechanicalKind, MechanicalObservation, MechanicalReason, MechanicalState } from './context' + +/** One controller owns this journal; Python readers never mutate it. No hardware access. */ +export class MechanicalStore { + private epoch = randomUUID() + private uncertainUntil = 0 + constructor(private readonly database: Database.Database) {} + + /** A busy biometric DB must not hold emergency cancellation for seconds. */ + private write(action: () => T): T { + const timeout = this.database.pragma('busy_timeout', { simple: true }) + if (typeof timeout !== 'number' || !Number.isSafeInteger(timeout)) throw new Error('Invalid database timeout') + this.database.pragma('busy_timeout = 25') + try { + return this.database.transaction(action).immediate() + } + finally { + this.database.pragma(`busy_timeout = ${timeout}`) + } + } + + /** A failed journal write cannot join the next observation to old evidence. */ + invalidate(now: number, durationMs = 180_000) { + this.epoch = randomUUID() + this.uncertainUntil = Math.max(this.uncertainUntil, now + durationMs + MECHANICAL_SETTLE_MS) + } + + read(): MechanicalState | null { + const row = this.database.prepare('SELECT payload FROM biometric_mechanical_state WHERE id=1').get() as { payload: string } | undefined + if (!row) return null + if (Buffer.byteLength(row.payload) > 8192) throw new Error('Mechanical context exceeds its bound') + return mechanicalStateSchema.parse(JSON.parse(row.payload)) + } + + private save(value: MechanicalState) { + const state = mechanicalStateSchema.parse(value) + this.database.prepare('INSERT INTO biometric_mechanical_state(id,payload) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload').run(JSON.stringify(state)) + return state + } + + private initial(now: number, reason: MechanicalReason): MechanicalState { + return { version: MECHANICAL_CONTEXT_VERSION, epoch: this.epoch, publishedAtMs: now, confirmedThroughMs: null, + validUntilMs: now, blockedUntilMs: now + MECHANICAL_SETTLE_MS, cleanSinceMs: null, spanId: null, + reasons: [reason], commandAtMs: null, commandUntilMs: 0 } + } + + observe(input: MechanicalObservation) { + const observation = mechanicalObservation(input) + return this.write(() => { + const prior = this.read() + const restarted = prior?.epoch !== this.epoch + const gap = prior !== null && observation.now - prior.publishedAtMs >= MECHANICAL_FRESH_MS + const regressed = prior !== null && (observation.now < prior.publishedAtMs + || (observation.observedAt !== null && prior.confirmedThroughMs !== null && observation.observedAt < prior.confirmedThroughMs)) + // Wall/source clock reversals make earlier time-based coverage ambiguous. + if (regressed) this.database.prepare('DELETE FROM biometric_mechanical_spans').run() + const state = !prior || restarted || gap || regressed + ? this.initial(observation.now, regressed ? 'clock_regression' : gap ? 'observation_gap' : 'controller_restart') + : { ...prior } + // Pending command windows survive process restarts and unknown outcomes. + state.commandUntilMs = Math.max(state.commandUntilMs, prior?.commandUntilMs ?? 0) + state.commandAtMs = prior?.commandAtMs ?? null + state.blockedUntilMs = Math.max(state.blockedUntilMs, state.commandUntilMs, this.uncertainUntil) + if (observation.reasons.length) state.blockedUntilMs = Math.max(state.blockedUntilMs, observation.now + MECHANICAL_SETTLE_MS) + const commandObserved = state.commandAtMs === null || (observation.observedAt !== null && observation.observedAt > state.commandAtMs) + const quiet = observation.observedAt !== null && observation.observedAt >= state.blockedUntilMs && commandObserved + state.reasons = observation.reasons.length ? observation.reasons : quiet ? [] : ['settling'] + state.publishedAtMs = observation.now + state.confirmedThroughMs = observation.observedAt + state.validUntilMs = observation.observedAt === null ? observation.now : observation.observedAt + MECHANICAL_FRESH_MS + if (quiet && observation.observedAt !== null) { + state.cleanSinceMs ??= Math.max(state.blockedUntilMs, observation.observedAt) + state.spanId ??= randomUUID() + this.database.prepare('INSERT INTO biometric_mechanical_spans(id,epoch,start_ms,end_ms) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET end_ms=MAX(end_ms,excluded.end_ms)') + .run(state.spanId, this.epoch, state.cleanSinceMs, observation.observedAt) + } + else { + state.cleanSinceMs = null + state.spanId = null + } + this.database.prepare('DELETE FROM biometric_mechanical_spans WHERE end_ms < ?').run(observation.now - MECHANICAL_RETENTION_MS) + this.database.prepare('DELETE FROM biometric_mechanical_spans WHERE id IN (SELECT id FROM biometric_mechanical_spans ORDER BY end_ms DESC, id DESC LIMIT -1 OFFSET ?)').run(MECHANICAL_MAX_SPANS) + return this.save(state) + }) + } + + /** Persist before dispatch. Clear/Stop never shorten a possible vibration window. */ + command(kind: MechanicalKind, now: number, durationMs = 0) { + mechanicalKindSchema.parse(kind) + if (!Number.isSafeInteger(now) || now < 0 || !Number.isSafeInteger(durationMs) || durationMs < 0 || durationMs > 180000) throw new Error('Invalid mechanical command interval') + return this.write(() => { + const previous = this.read() + const state = this.initial(now, kind === 'vibration' ? 'vibration_command' : kind === 'base' ? 'base_motion' : 'priming') + state.commandAtMs = now + state.commandUntilMs = Math.max(previous?.commandUntilMs ?? 0, now + durationMs + MECHANICAL_SETTLE_MS) + state.blockedUntilMs = state.commandUntilMs + return this.save(state) + }) + } + + stop(now: number) { + return this.write(() => this.save({ ...this.initial(now, 'controller_stopped'), + commandUntilMs: this.read()?.commandUntilMs ?? 0 })) + } +} diff --git a/src/biometrics/mechanical/tests/context.test.ts b/src/biometrics/mechanical/tests/context.test.ts new file mode 100644 index 000000000..c8bbf00f4 --- /dev/null +++ b/src/biometrics/mechanical/tests/context.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node +import Database from 'better-sqlite3' +import { readFileSync } from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' +import { mechanicalObservation } from '../context' +import { MechanicalStore } from '../store' +import { mechanicalStateSchema } from '../context' +import { readMechanicalSnapshot } from '../snapshot' +import { installMechanicalObserver, recordMechanicalCommand } from '../dispatch' +import { mechanicalWindowProof } from '../proof' + +const databases: Database.Database[] = [] +afterEach(() => { + databases.splice(0).forEach(database => database.close()) +}) +function setup() { + const database = new Database(':memory:') + databases.push(database) + database.exec(readFileSync(new URL('../../../db/biometrics-migrations/0032_mechanical_signal_context.sql', import.meta.url), 'utf8').replaceAll('--> statement-breakpoint', '')) + const store = new MechanicalStore(database) + const observe = (now: number, changes = {}) => store.observe({ now, deviceAt: now, baseAt: now, + priming: false, vibrationCommandActive: false, baseMoving: false, ...changes }) + const quiet = (from = 100000, to = 120000) => { + for (let now = from; now <= to; now += 1000) observe(now) + return mechanicalStateSchema.parse(store.read()) + } + return { database, store, observe, quiet } +} + +it('extends quiet coverage with actual source timestamps, never cached polling time', () => { + const { quiet, observe, database } = setup() + const state = quiet() + expect(state).toMatchObject({ cleanSinceMs: 110000, confirmedThroughMs: 120000, reasons: [] }) + const cached = observe(125000, { deviceAt: 120000, baseAt: 120000 }) + expect(cached.confirmedThroughMs).toBe(120000) + expect(readMechanicalSnapshot(database, 125000)?.spans[0]).toMatchObject({ startMs: 110000, endMs: 120000 }) + expect(observe(130000, { deviceAt: 120000, baseAt: 120000 }).cleanSinceMs).toBeNull() +}) + +it.each([ + { priming: true }, { baseMoving: true }, { vibrationCommandActive: true }, + { priming: null }, { baseMoving: null }, { vibrationCommandActive: null }, + { deviceAt: null }, { baseAt: null }, { deviceAt: 140000 }, { baseAt: 110000 }, + { priming: undefined }, { baseMoving: undefined }, { vibrationCommandActive: undefined }, +])('closes a span and requires a fresh settling window for %j', (changes) => { + const { quiet, observe, database } = setup() + const previous = quiet() + expect(observe(121000, changes).cleanSinceMs).toBeNull() + const recovered = quiet(122000, 132000) + expect(recovered.cleanSinceMs).toBeGreaterThanOrEqual(131000) + expect(recovered.spanId).not.toBe(previous.spanId) + expect(readMechanicalSnapshot(database, 132000)?.spans).toHaveLength(2) +}) + +it('preserves a pre-dispatch vibration exclusion through Stop and process restart', () => { + const { store, database, quiet } = setup() + quiet() + store.command('vibration', 121000, 180000) + store.command('vibration', 122000, 0) + const restarted = new MechanicalStore(database) + for (let now = 123000; now <= 310000; now += 1000) { + expect(restarted.observe({ now, deviceAt: now, baseAt: now, priming: false, vibrationCommandActive: false, baseMoving: false }).cleanSinceMs).toBeNull() + } + expect(restarted.observe({ now: 311000, deviceAt: 311000, baseAt: 311000, priming: false, vibrationCommandActive: false, baseMoving: false }).cleanSinceMs).toBe(311000) +}) + +it('does not join evidence over source clock reversal, observer gap or journal failure', () => { + const { quiet, observe, store, database } = setup() + quiet() + expect(observe(121000, { deviceAt: 119000 }).cleanSinceMs).toBeNull() + expect(readMechanicalSnapshot(database, 121000)?.spans).toEqual([]) + const next = quiet(122000, 132000) + expect(observe(142000).spanId).toBeNull() + expect(quiet(143000, 153000).spanId).not.toBe(next.spanId) + store.invalidate(154000) + expect(observe(155000).spanId).toBeNull() + expect(readMechanicalSnapshot(database, 165000)).toBeNull() +}) + +it('stops future quiet evidence and rejects malformed clock/command inputs', () => { + const { store, quiet } = setup() + quiet() + store.stop(121000) + expect(store.read()).toMatchObject({ spanId: null, reasons: ['controller_stopped'] }) + expect(() => store.command('base', NaN)).toThrow() + expect(() => store.command('vibration', 122000, 180001)).toThrow() + expect(() => mechanicalObservation({ now: NaN } as never)).toThrow() +}) + +it('restores the shared database timeout after success and failed persistence', () => { + const { database, quiet, store } = setup() + database.pragma('busy_timeout = 1234') + quiet() + expect(database.pragma('busy_timeout', { simple: true })).toBe(1234) + database.exec('CREATE TRIGGER reject_context BEFORE INSERT ON biometric_mechanical_state BEGIN SELECT RAISE(FAIL, \'storage unavailable\'); END') + expect(() => store.command('priming', 121000)).toThrow('storage unavailable') + expect(database.pragma('busy_timeout', { simple: true })).toBe(1234) +}) + +it('requires durable start records but keeps emergency cancellation available', () => { + const command = vi.fn(() => { + throw new Error('disk full') + }) + const uninstall = installMechanicalObserver({ command }) + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(() => recordMechanicalCommand('base')).toThrow('disk full') + expect(() => recordMechanicalCommand('vibration', 0, true)).not.toThrow() + expect(command).toHaveBeenCalledTimes(2) + } + finally { + uninstall() + warning.mockRestore() + } +}) + +it('requires one full measurement-window receipt, preserving firmware fractions', () => { + const { quiet } = setup(), state = quiet() + const row = { timestampMs: 500000, heartRate: 60, breathingRate: 12, hrv: null } + const proof = `mechanical_context_v1:${state.epoch}:${state.spanId}:439375:501375` + expect(mechanicalWindowProof([proof], row)).toMatchObject({ startMs: 439375, endMs: 501375 }) + expect(() => mechanicalWindowProof([], row)).toThrow() + expect(() => mechanicalWindowProof([proof, proof], row)).toThrow() + expect(() => mechanicalWindowProof([proof], { ...row, hrv: 30 })).toThrow() +}) diff --git a/src/biometrics/mechanical/tests/eligibility.test.ts b/src/biometrics/mechanical/tests/eligibility.test.ts new file mode 100644 index 000000000..28f7cb46e --- /dev/null +++ b/src/biometrics/mechanical/tests/eligibility.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment node +import Database from 'better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { fileURLToPath } from 'node:url' +import { randomUUID } from 'node:crypto' +import { expect, it } from 'vitest' +import { MechanicalStore } from '../store' +import { mechanicalStateSchema } from '../context' +import { isMechanicallyQualifiedVital } from '../eligibility' + +it('requires current quiet evidence and one compatible live receipt before automation uses a row', () => { + const database = new Database(':memory:') + try { + migrate(drizzle(database), { migrationsFolder: fileURLToPath(new URL('../../../db/biometrics-migrations/', import.meta.url)) }) + const store = new MechanicalStore(database), start = 1700000000000 + const observe = (now: number, moving = false) => store.observe({ now, deviceAt: now, baseAt: now, + priming: false, baseMoving: moving, vibrationCommandActive: false }) + for (let now = start; now <= start + 80000; now += 1000) observe(now) + const state = mechanicalStateSchema.parse(store.read()), run = randomUUID(), at = start + 75000 + database.prepare('INSERT INTO biometric_processing_runs (id, side, kind, bundle, config_hash, input_identity, started_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(run, 'left', 'live', '{}', 'a'.repeat(64), JSON.stringify({ sourceSide: 'left', timestampBasis: 'firmware_event_time_v1', mechanicalContext: 'mechanical-context-v1' }), start) + const id = Number(database.prepare('INSERT INTO vitals (side, timestamp, heart_rate, breathing_rate, processing_run_id) VALUES (?, ?, 60, 12, ?)').run('left', at / 1000, run).lastInsertRowid) + const flags = JSON.stringify([`mechanical_context_v1:${state.epoch}:${state.spanId}:${at - 61000}:${at + 1000}`]) + database.prepare('INSERT INTO vitals_quality (vitals_id, side, timestamp, quality_score, flags, created_at) VALUES (?, ?, ?, 0.9, ?, ?)').run(id, 'left', at / 1000, flags, at / 1000) + expect(isMechanicallyQualifiedVital(database, id, start + 80000)).toBe(true) + database.prepare('UPDATE vitals_quality SET flags=NULL').run() + expect(isMechanicallyQualifiedVital(database, id, start + 80000)).toBe(false) + database.prepare('UPDATE vitals_quality SET flags=?').run(flags) + expect(isMechanicallyQualifiedVital(database, id, start + 90000)).toBe(false) // Stale context. + observe(start + 81000, true) + expect(isMechanicallyQualifiedVital(database, id, start + 81000)).toBe(false) + for (let now = start + 82000; now <= start + 95000; now += 1000) observe(now) + expect(isMechanicallyQualifiedVital(database, id, start + 95000)).toBe(false) // Older quiet interval. + expect(database.prepare('SELECT COUNT(*) AS count FROM vitals').get()).toEqual({ count: 1 }) + } + finally { database.close() } +}) diff --git a/src/biometrics/mechanical/tests/fixtures.ts b/src/biometrics/mechanical/tests/fixtures.ts new file mode 100644 index 000000000..5303b1edf --- /dev/null +++ b/src/biometrics/mechanical/tests/fixtures.ts @@ -0,0 +1,9 @@ +/** Synthetic telemetry for worker protocol tests, never hardware evidence. */ +export function quietMechanicalSnapshot(now: number) { + const epoch = 'd669e2b4-0b92-4c92-9110-17a840e66c11', span = '154d214f-c095-4aa0-80d5-dc97665b39df' + const start = 1699999000000 + return { state: { version: 'mechanical-context-v1', epoch, publishedAtMs: now, + confirmedThroughMs: now, validUntilMs: now + 10000, blockedUntilMs: 0, + cleanSinceMs: start, spanId: span, reasons: [], commandAtMs: null, commandUntilMs: 0 }, + spans: [{ id: span, epoch, startMs: start, endMs: now }] } +} diff --git a/src/biometrics/mechanical/tests/instance.test.ts b/src/biometrics/mechanical/tests/instance.test.ts new file mode 100644 index 000000000..ca3160e08 --- /dev/null +++ b/src/biometrics/mechanical/tests/instance.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node +import Database from 'better-sqlite3' +import { readFileSync } from 'node:fs' +import { expect, it, vi } from 'vitest' +import { recordMechanicalCommand } from '../dispatch' +import { MechanicalStore } from '../store' + +const fixture = vi.hoisted(() => ({ database: null as Database.Database | null, + alarms: [{ side: 'left', isAlarmVibrating: false }, { side: 'right', isAlarmVibrating: false }], + deviceAvailable: true, baseAvailable: true })) +vi.mock('@/src/db', () => ({ biometricsDb: { get $client() { + return fixture.database +} }, +db: { select: () => ({ from: () => ({ all: () => fixture.alarms }) }) } })) +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ getDacMonitorIfRunning: () => ({ + getFreshStatus: () => fixture.deviceAvailable ? { isPriming: false } : null, + getLastPollAt: () => Date.now(), +}) })) +vi.mock('@/src/hardware/base/instance', () => ({ getBaseControllerIfRunning: () => ({ status: () => ({ + state: fixture.baseAvailable ? 'connected' : 'unconfigured', stale: !fixture.baseAvailable, + moving: fixture.baseAvailable ? false : null, busy: false, lastUpdate: Date.now(), +}) }) })) + +it('runs the production observer lifecycle with missing telemetry and command-derived alarm state', async () => { + vi.useFakeTimers() + vi.setSystemTime(1700000000000) + const database = new Database(':memory:') + database.exec(readFileSync(new URL('../../../db/biometrics-migrations/0032_mechanical_signal_context.sql', import.meta.url), 'utf8')) + fixture.database = database + const { startMechanicalContext, stopMechanicalContext } = await import('../instance') + const read = () => new MechanicalStore(database).read() + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + startMechanicalContext() + startMechanicalContext() + expect(vi.getTimerCount()).toBe(1) + vi.advanceTimersByTime(11000) + expect(read()?.reasons).toEqual([]) + fixture.baseAvailable = false + vi.advanceTimersByTime(1000) + expect(read()?.reasons).toContain('base_unavailable') + fixture.baseAvailable = true + fixture.alarms[0].isAlarmVibrating = true + vi.advanceTimersByTime(1000) + expect(read()?.reasons).toContain('vibration_command') + fixture.alarms[0].isAlarmVibrating = false + fixture.deviceAvailable = false + vi.advanceTimersByTime(1000) + expect(read()?.reasons).toContain('device_unavailable') + fixture.deviceAvailable = true + vi.advanceTimersByTime(11000) + expect(read()?.reasons).toEqual([]) + recordMechanicalCommand('vibration', 180000) + expect(read()?.spanId).toBeNull() + stopMechanicalContext() + expect(read()?.reasons).toEqual(['controller_stopped']) + expect(vi.getTimerCount()).toBe(0) + expect(() => recordMechanicalCommand('priming')).toThrow('stopped') + expect(() => recordMechanicalCommand('vibration', 0, true)).not.toThrow() + } + finally { + stopMechanicalContext() + const globals = globalThis as Record + delete globals.__sp_mechanicalRuntime + delete globals.__sp_mechanicalObserver + database.close() + warning.mockRestore() + vi.useRealTimers() + } +}) diff --git a/src/biometrics/mechanical/vital-evidence.ts b/src/biometrics/mechanical/vital-evidence.ts new file mode 100644 index 000000000..b51f1b544 --- /dev/null +++ b/src/biometrics/mechanical/vital-evidence.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import { MECHANICAL_CONTEXT_VERSION } from './context' +import { mechanicalWindowProof } from './proof' + +const inputSchema = z.object({ timestampBasis: z.literal('firmware_event_time_v1'), + mechanicalContext: z.literal(MECHANICAL_CONTEXT_VERSION), sourceSide: z.enum(['left', 'right']) }) +const vitalSchema = z.object({ side: z.enum(['left', 'right']), run_side: z.enum(['left', 'right']), + timestamp: z.number().int().positive(), heart_rate: z.number().finite().nullable(), + breathing_rate: z.number().finite().nullable(), hrv: z.number().finite().nullable(), + quality_score: z.number().min(0).max(1), quality_count: z.literal(1), + quality_side: z.enum(['left', 'right']), quality_timestamp: z.number().int(), + flags: z.string().max(8192), input_identity: z.string().max(4096), kind: z.literal('live') }) + +export const VITAL_EVIDENCE_QUERY = `SELECT v.*, r.side AS run_side, r.kind, + CASE WHEN length(CAST(r.input_identity AS BLOB)) <= 4096 THEN r.input_identity END AS input_identity, + (SELECT COUNT(*) FROM (SELECT 1 FROM vitals_quality quality WHERE quality.vitals_id=v.id LIMIT 2)) AS quality_count, + q.side AS quality_side, q.timestamp AS quality_timestamp, q.quality_score, + CASE WHEN length(CAST(q.flags AS BLOB)) <= 8192 THEN q.flags END AS flags + FROM vitals v JOIN biometric_processing_runs r ON r.id=v.processing_run_id + JOIN vitals_quality q ON q.vitals_id=v.id` + +/** Shared provenance validation; the caller supplies current or historical coverage. */ +export function parseVitalEvidence(raw: unknown) { + const row = vitalSchema.parse(raw) + const input = inputSchema.parse(JSON.parse(row.input_identity)) + const timestampMs = row.timestamp * 1000 + if (!Number.isSafeInteger(timestampMs) || row.side !== row.run_side || row.side !== row.quality_side + || row.timestamp !== row.quality_timestamp) throw new Error('Vital identity or timestamp mismatch') + const flags = z.array(z.string().max(512)).max(64).parse(JSON.parse(row.flags)) + const proof = mechanicalWindowProof(flags, { timestampMs, heartRate: row.heart_rate, breathingRate: row.breathing_rate, hrv: row.hrv }) + return { row, input, timestampMs, flags, proof } +} diff --git a/src/biometrics/references/legacy.ts b/src/biometrics/references/legacy.ts index 392c8a83a..76792ef8c 100644 --- a/src/biometrics/references/legacy.ts +++ b/src/biometrics/references/legacy.ts @@ -7,23 +7,26 @@ import { ReferenceStore } from './store' type LegacyVital = typeof schema.referenceVitals.$inferSelect type LegacyStage = typeof schema.referenceStages.$inferSelect +type LegacySample = typeof schema.referenceSamples.$inferSelect /** * Called inside the existing legacy endpoint's insertion transaction. Only * newly accepted rows get provenance; first-write-wins and stage-overlap * rejection retain their old semantics. Historical unlinked rows stay unknown. */ -export function recordLegacyReferenceImport(db: BetterSQLite3Database, kind: 'vitals' | 'stages', rows: Array): void { +export function recordLegacyReferenceImport(db: BetterSQLite3Database, kind: 'vitals' | 'stages' | 'samples', rows: Array): void { const first = rows[0] if (!first) return const annotation = 'Legacy Apple Watch API: explicit side mapping; sleeper identity and source timezone were not provided. Original timestamps use Unix seconds.' const input: ReferenceImportInput = { side: first.side, subjectId: `legacy_unknown:${first.side}`, source: first.source, rows: rows.map(row => 'heartRate' in row ? { recordKey: `apple-watch-hr:${row.timestamp.getTime()}`, metric: 'heart_rate', value: row.heartRate, unit: 'bpm', timestampMs: row.timestamp.getTime(), annotation } - : { recordKey: `apple-watch-stage:${row.start.getTime()}`, metric: 'sleep_stages', value: row.stage, unit: 'stage', startMs: row.start.getTime(), endMs: row.end.getTime(), annotation }), + : 'metric' in row + ? { recordKey: `apple-watch-${row.metric}:${row.timestamp.getTime()}`, metric: row.metric, value: row.value, unit: row.metric === 'breathing_rate' ? 'breaths/min' : 'ms', timestampMs: row.timestamp.getTime(), annotation } + : { recordKey: `apple-watch-stage:${row.start.getTime()}`, metric: 'sleep_stages', value: row.stage, unit: 'stage', startMs: row.start.getTime(), endMs: row.end.getTime(), annotation }), } const preview = previewReferenceImport(input) const result = new ReferenceStore(db).commit({ import: input, previewHash: preview.summary.contentHash }) - const table = kind === 'vitals' ? schema.referenceVitals : schema.referenceStages + const table = kind === 'vitals' ? schema.referenceVitals : kind === 'samples' ? schema.referenceSamples : schema.referenceStages for (let i = 0; i < rows.length; i += 100) db.update(table).set({ importId: result.id }).where(inArray(table.id, rows.slice(i, i + 100).map(row => row.id))).run() } diff --git a/src/biometrics/references/store.ts b/src/biometrics/references/store.ts index 16e0b8a0a..562a11557 100644 --- a/src/biometrics/references/store.ts +++ b/src/biometrics/references/store.ts @@ -100,6 +100,7 @@ export class ReferenceStore { tx.delete(measurements).where(eq(measurements.importId, id)).run() tx.delete(schema.referenceStages).where(eq(schema.referenceStages.importId, id)).run() tx.delete(schema.referenceVitals).where(eq(schema.referenceVitals.importId, id)).run() + tx.delete(schema.referenceSamples).where(eq(schema.referenceSamples.importId, id)).run() const deletedAtMs = this.now() tx.update(imports).set({ deletedAtMs }).where(eq(imports.id, id)).run() return { id, deletedAtMs } diff --git a/src/biometrics/shadow/active-reader.ts b/src/biometrics/shadow/active-reader.ts index c8084afcc..627a729af 100644 --- a/src/biometrics/shadow/active-reader.ts +++ b/src/biometrics/shadow/active-reader.ts @@ -3,6 +3,9 @@ import { z } from 'zod' import { biometricManifest, manifestHash, validateConfig } from '../algorithms/registry' import { configHash, dataHash } from '../algorithms/canonical' import { shadowActiveReadingSchema } from './worker-sink' +import { firmwareTimestampBasis } from './worker-protocol' +import { MECHANICAL_CONTEXT_VERSION } from '../mechanical/context' +import { mechanicalWindowProof } from '../mechanical/proof' const integer = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) const optionsSchema = z.object({ side: z.enum(['left', 'right']), startMs: integer, endMs: integer, @@ -12,11 +15,13 @@ const rowSchema = z.object({ id: integer.positive(), side: z.enum(['left', 'righ processing_run_id: z.string().uuid(), run_side: z.enum(['left', 'right']), kind: z.literal('live'), config_hash: z.string(), bundle: z.string(), input_identity: z.string(), started_at_ms: integer, ended_at_ms: integer.nullable(), quality_count: z.literal(1), quality_score: z.number().min(0).max(1), quality_side: z.enum(['left', 'right']), quality_timestamp: integer, + quality_flags: z.string().max(8192), }) const bundleSchema = z.object({ schemaVersion: z.literal(1), manifestHash: z.string(), resolved: z.unknown(), generation: integer, revisionId: z.string().uuid().nullable(), components: z.unknown(), sourceHashes: z.unknown() }).strict() -const inputSchema = z.object({ sourceSide: z.enum(['left', 'right']), timestampBasis: z.literal('processing_clock'), - rawIdentity: z.literal('not_retained'), sensorCalibration: z.literal('not_consumed') }).strict() +const inputSchema = z.object({ sourceSide: z.enum(['left', 'right']), timestampBasis: z.literal(firmwareTimestampBasis), + rawIdentity: z.literal('not_retained'), sensorCalibration: z.literal('not_consumed'), + mechanicalContext: z.literal(MECHANICAL_CONTEXT_VERSION) }).strict() /** Poll an already-open read-only connection. Only observations inserted after * construction are eligible; historical rows are never replayed as live output. @@ -61,7 +66,8 @@ export class ShadowActiveReader { (SELECT COUNT(*) FROM vitals_quality q WHERE q.vitals_id = v.id) AS quality_count, (SELECT quality_score FROM vitals_quality q WHERE q.vitals_id = v.id LIMIT 1) AS quality_score, (SELECT side FROM vitals_quality q WHERE q.vitals_id = v.id LIMIT 1) AS quality_side, - (SELECT timestamp FROM vitals_quality q WHERE q.vitals_id = v.id LIMIT 1) AS quality_timestamp + (SELECT timestamp FROM vitals_quality q WHERE q.vitals_id = v.id LIMIT 1) AS quality_timestamp, + (SELECT CASE WHEN length(CAST(flags AS BLOB)) <= 8192 THEN flags END FROM vitals_quality q WHERE q.vitals_id = v.id LIMIT 1) AS quality_flags FROM vitals v LEFT JOIN biometric_processing_runs r ON r.id = v.processing_run_id WHERE v.id > ? AND v.id <= ? AND v.side = ? AND v.timestamp >= ? AND v.timestamp < ? ORDER BY v.id LIMIT ?`).all(this.cursor, latest.id, side, Math.ceil(startMs / 1000), Math.ceil(endMs / 1000), maximumRows + 1) @@ -70,6 +76,7 @@ export class ShadowActiveReader { const readings = rows.map((raw) => { const row = rowSchema.parse(raw), timestampMs = row.timestamp * 1000 const bundle = bundleSchema.parse(JSON.parse(row.bundle)), input = inputSchema.parse(JSON.parse(row.input_identity)) + mechanicalWindowProof(JSON.parse(row.quality_flags), { timestampMs, heartRate: row.heart_rate, hrv: row.hrv, breathingRate: row.breathing_rate }) validateConfig(bundle.resolved) if (!Number.isSafeInteger(timestampMs) || timestampMs > at || timestampMs + 999 < row.started_at_ms || (row.ended_at_ms !== null && timestampMs >= row.ended_at_ms) @@ -78,7 +85,7 @@ export class ShadowActiveReader { || bundle.manifestHash !== manifestHash || configHash(bundle.components) !== configHash(biometricManifest.components) || configHash(bundle.sourceHashes) !== configHash(biometricManifest.sourceHashes)) throw new Error('Active observation provenance mismatch') return shadowActiveReadingSchema.parse({ side, timestampMs, configHash: activeConfigHash, inputHash: null, - provenance: { processingRunId: row.processing_run_id, sourceSide: input.sourceSide, generation: bundle.generation, manifestHash: bundle.manifestHash }, + provenance: { processingRunId: row.processing_run_id, sourceSide: input.sourceSide, generation: bundle.generation, manifestHash: bundle.manifestHash, timestampBasis: input.timestampBasis }, readings: [ { metric: 'heart_rate', unit: 'bpm', windowMs: null, value: row.heart_rate, quality: row.quality_score }, { metric: 'breathing_rate', unit: 'breaths/min', windowMs: null, value: row.breathing_rate, quality: row.quality_score }, diff --git a/src/biometrics/shadow/build.ts b/src/biometrics/shadow/build.ts index 351a86bfc..4acf6db90 100644 --- a/src/biometrics/shadow/build.ts +++ b/src/biometrics/shadow/build.ts @@ -10,7 +10,7 @@ const dependencyPaths = z.object({ record: z.string().min(1).max(4096), wheel: z export const shadowPythonBuildSchema = waveformPythonBuildSchema.extend({ dependencyFiles: z.object({ numpy: dependencyPaths, scipy: dependencyPaths }).strict(), identity: waveformPythonBuildSchema.shape.identity.extend({ version: z.literal('shadow-python-build-v1'), - }).refine(identity => ['shadow_build.py', 'shadow.py', 'shadow_worker.py', 'shadow_process.py'].every(name => + }).refine(identity => ['live_input.py', 'mechanical.py', 'shadow_build.py', 'shadow.py', 'shadow_worker.py', 'shadow_process.py'].every(name => identity.sources[`common/biometrics/${name}`] !== undefined), 'Shadow source identity incomplete') }) /** Fixed shipped probe, exact worker interpreter, bounded output/deadline. Never diff --git a/src/biometrics/shadow/frame-source.ts b/src/biometrics/shadow/frame-source.ts index c33a65c07..85a77db06 100644 --- a/src/biometrics/shadow/frame-source.ts +++ b/src/biometrics/shadow/frame-source.ts @@ -1,9 +1,11 @@ import { shadowFrameBudgetsSchema as budgetsSchema } from './execution-policy' import { z } from 'zod' import { subscribeProspectiveFrames } from '../../streaming/prospectiveFrameTap' +import { mechanicalSnapshotSchema } from '../mechanical/snapshot' const contextSchema = z.object({ activeSides: z.array(z.enum(['left', 'right'])).max(2).nullable(), leftOccupied: z.boolean().nullable(), rightOccupied: z.boolean().nullable(), pumpContextKnown: z.boolean(), + mechanical: mechanicalSnapshotSchema.nullable().optional(), }).strict().refine(value => value.activeSides === null || new Set(value.activeSides).size === value.activeSides.length, 'Duplicate active side') const pumpKeys = ['pumpRpm', 'pump_rpm', 'pumpRPM', 'rpm', 'pumpDuty', 'pump_duty', 'duty'] as const function pumpValue(value: unknown) { diff --git a/src/biometrics/shadow/planned-sources.ts b/src/biometrics/shadow/planned-sources.ts index 1f7083186..98a90bbd6 100644 --- a/src/biometrics/shadow/planned-sources.ts +++ b/src/biometrics/shadow/planned-sources.ts @@ -3,6 +3,7 @@ import { shadowPlanSchema } from './store' import { openShadowActiveDatabase } from './active-database' import { ShadowContextReader } from './context' import { openShadowLiveSources } from './live-sources' +import { readMechanicalSnapshot } from '../mechanical/snapshot' import type { ShadowAttemptHost, ShadowSources } from './attempt' /** Trusted installation paths only. This factory owns its read-only connections @@ -50,7 +51,7 @@ export function plannedShadowSourceFactory(rawPlan: unknown, locations: { live = openShadowLiveSources({ db: bio.db, side: plan.side, startMs: plan.startMs, endMs: plan.endMs, activeConfigHash: plan.activeConfigHash, maximumActiveRows: execution.sources.maximumActiveRows, maximumLatenessMs: joining.maximumLatenessMs, pollMs: execution.sources.pollMs, - frameBudgets: execution.sources.frames, context: (frame, at) => context.snapshot(frame, at), + frameBudgets: execution.sources.frames, context: (frame, at) => ({ ...context.snapshot(frame, at), mechanical: readMechanicalSnapshot(bio.db, at) }), assertDatabaseCurrent: assertCurrent, signal: input.signal, active: input.active, fail: () => { try { diff --git a/src/biometrics/shadow/receipt.ts b/src/biometrics/shadow/receipt.ts index b4a0e2f5d..0cf46cf20 100644 --- a/src/biometrics/shadow/receipt.ts +++ b/src/biometrics/shadow/receipt.ts @@ -13,7 +13,7 @@ export function validateShadowWorkerReceipt(raw: unknown, execution: ShadowExecu const receipt = shadowWorkerReceiptSchema.parse(raw) const h = receipt.health if (h.state !== 'completed' || h.reason !== 'window_complete' || h.records !== h.counts.records || h.outputs > h.counts.scored_outputs - || h.counts.accepted_frames + h.counts.empty_channel_frames + h.counts.pump_gated_frames > h.records + || h.counts.accepted_frames + h.counts.empty_channel_frames + h.counts.pump_gated_frames + (h.counts.timestamp_rejected_frames ?? 0) > h.records || h.counts.scored_outputs + h.counts.preroll_outputs > 2 * h.counts.accepted_frames || h.records > execution.maximumRecords || h.outputs > execution.maximumOutputs || h.cpu_ms > execution.maximumCpuMs || receipt.inputBytes > execution.maximumInputBytes || receipt.outputBytes > execution.maximumOutputBytes diff --git a/src/biometrics/shadow/store.ts b/src/biometrics/shadow/store.ts index aff965682..34f446295 100644 --- a/src/biometrics/shadow/store.ts +++ b/src/biometrics/shadow/store.ts @@ -4,6 +4,7 @@ export { shadowPlanSchema, shadowMonitoringSchema, shadowExpectedBindingsSchema import type Database from 'better-sqlite3' import { randomUUID } from 'node:crypto' import { z } from 'zod' +import { firmwareTimestampBasis } from './worker-protocol' import { canonicalJson, dataHash } from '../algorithms/canonical' const integer = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) @@ -12,14 +13,14 @@ const hash = z.string().regex(/^[a-f0-9]{64}$/) const metric = z.enum(['heart_rate', 'breathing_rate', 'window_ibi_variability_v1']) const reading = z.object({ value: z.number().finite().nullable(), quality: z.number().min(0).max(1) }).strict() export const shadowActiveProvenanceSchema = z.object({ processingRunId: z.string().uuid(), sourceSide: z.enum(['left', 'right']), - generation: integer, manifestHash: hash }).strict() + generation: integer, manifestHash: hash, timestampBasis: z.literal(firmwareTimestampBasis).optional() }).strict() export const shadowSampleSchema = z.object({ sequence: integer, side: z.enum(['left', 'right']), timestampMs: integer, inputHash: hash.nullable(), candidateConfigHash: hash, activeConfigHash: hash, readings: z.array(z.object({ metric, candidate: reading, active: reading }).strict()).min(1).max(3), pairing: z.object({ policy: z.literal('exact_timestamp_union_v1'), candidatePresent: z.boolean(), activePresent: z.boolean() }).strict().optional(), inputIdentityKind: z.enum(['candidate_prefix', 'active_provenance', 'unavailable']).optional(), activeProvenance: shadowActiveProvenanceSchema.optional(), - candidateProvenance: z.object({ sourceSide: z.enum(['left', 'right']), outputSequence: integer, + candidateProvenance: z.object({ timestampBasis: z.literal(firmwareTimestampBasis).optional(), sourceSide: z.enum(['left', 'right']), outputSequence: integer, flags: z.array(z.string().max(200)).max(32).nullable(), limitations: z.array(z.string().max(200)).max(33) }).strict().optional(), }).strict() export const shadowStoragePolicySchema = z.object({ maximumRuns: positive, maximumPoints: positive, maximumBytes: positive }).strict() diff --git a/src/biometrics/shadow/stream-join.ts b/src/biometrics/shadow/stream-join.ts index e1534295c..8c5ab7b21 100644 --- a/src/biometrics/shadow/stream-join.ts +++ b/src/biometrics/shadow/stream-join.ts @@ -85,7 +85,7 @@ export class ShadowStreamJoin { ...(active?.provenance ? { activeProvenance: active.provenance } : {}), pairing: { policy: 'exact_timestamp_union_v1', candidatePresent: Boolean(candidate), activePresent: Boolean(active) }, readings, ...(candidate - ? { candidateProvenance: { sourceSide: candidate.sourceSide, outputSequence: candidate.sequence, flags: candidate.flags, + ? { candidateProvenance: { timestampBasis: candidate.timestampBasis, sourceSide: candidate.sourceSide, outputSequence: candidate.sequence, flags: candidate.flags, limitations: active ? candidate.limitations : [...candidate.limitations, 'missing_contemporaneous_active_reading'] } } : {}) }) if (result.state === 'quota_reached') { diff --git a/src/biometrics/shadow/tests/active-reader.test.ts b/src/biometrics/shadow/tests/active-reader.test.ts index 1cd55b6e8..85e02c159 100644 --- a/src/biometrics/shadow/tests/active-reader.test.ts +++ b/src/biometrics/shadow/tests/active-reader.test.ts @@ -39,13 +39,13 @@ function setup(maximumRows = 10) { const bundle = { schemaVersion: 1, manifestHash, resolved: defaultConfig, generation: 0, revisionId: null, components: biometricManifest.components, sourceHashes: biometricManifest.sourceHashes } writer.prepare('INSERT INTO biometric_processing_runs (id, side, kind, bundle, config_hash, calibration_hash, input_identity, started_at_ms) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)') - .run(runId, 'left', 'live', JSON.stringify(bundle), hash, JSON.stringify({ sourceSide: 'right', timestampBasis: 'processing_clock', rawIdentity: 'not_retained', sensorCalibration: 'not_consumed' }), start) + .run(runId, 'left', 'live', JSON.stringify(bundle), hash, JSON.stringify({ sourceSide: 'right', timestampBasis: 'firmware_event_time_v1', rawIdentity: 'not_retained', sensorCalibration: 'not_consumed', mechanicalContext: 'mechanical-context-v1' }), start) const options = { side: 'left', startMs: start, endMs: start + 10000, activeConfigHash: hash, maximumRows } let now = start const reader = () => new ShadowActiveReader(readonly, options, () => now) const insert = (seconds = 0, side = 'left', provenance: string | null = runId) => { const id = Number(writer.prepare('INSERT INTO vitals (side,timestamp,heart_rate,hrv,breathing_rate,processing_run_id) VALUES (?,?,60,NULL,12,?)').run(side, start / 1000 + seconds, provenance).lastInsertRowid) - writer.prepare('INSERT INTO vitals_quality (vitals_id,side,timestamp,quality_score,created_at) VALUES (?,?,?,0.9,?)').run(id, side, start / 1000 + seconds, start / 1000 + seconds) + writer.prepare('INSERT INTO vitals_quality (vitals_id,side,timestamp,quality_score,created_at,flags) VALUES (?,?,?,0.9,?,?)').run(id, side, start / 1000 + seconds, start / 1000 + seconds, JSON.stringify([`mechanical_context_v1:${runId}:${runId}:${start + seconds * 1000 - 61000}:${start + seconds * 1000 + 1000}`])) return id } return { writer, readonly, path, runId, bundle, options, reader, insert, advance: (milliseconds: number) => { @@ -62,7 +62,7 @@ it('captures only newly inserted rows with original values, quality and verified const rows = capture.poll() expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ timestampMs: start + 1000, configHash: hash, inputHash: null, - provenance: { processingRunId: runId, sourceSide: 'right', generation: 0, manifestHash }, + provenance: { processingRunId: runId, sourceSide: 'right', generation: 0, manifestHash, timestampBasis: 'firmware_event_time_v1' }, readings: [{ metric: 'heart_rate', value: 60, quality: 0.9 }, { metric: 'breathing_rate', value: 12 }, { metric: 'window_ibi_variability_v1', value: null, windowMs: 300000 }] }) expect(capture.poll()).toEqual([]) }) @@ -74,6 +74,16 @@ it('requires read-only access and never writes through the capture connection', expect(capture.poll()).toHaveLength(1) expect(() => readonly.prepare('DELETE FROM vitals').run()).toThrow(/readonly/) }) +it('withholds legacy processing-clock rows from firmware-time comparisons', () => { + const { writer, reader, insert } = setup() + writer.prepare('UPDATE biometric_processing_runs SET input_identity=?').run(JSON.stringify({ + sourceSide: 'right', timestampBasis: 'processing_clock', rawIdentity: 'not_retained', sensorCalibration: 'not_consumed', + })) + const capture = reader() + insert() + expect(() => capture.poll()).toThrow() + expect(() => capture.poll()).toThrow() // A failed batch must not advance past the mismatch. +}) it('advances past other-side and out-of-window inserts without labelling them active', () => { const { reader, insert } = setup() const capture = reader() @@ -131,7 +141,7 @@ it('retains active run provenance when the stream join persists an active-only p stream.active(capture.poll()[0]) stream.advance(start + 1) expect(store.snapshot(run.id, { maximumPoints: 10, maximumBytes: 10000 }).samples[0]).toMatchObject({ - inputHash: null, inputIdentityKind: 'unavailable', activeProvenance: { processingRunId: runId, sourceSide: 'right' }, pairing: { activePresent: true, candidatePresent: false } }) + inputHash: null, inputIdentityKind: 'unavailable', activeProvenance: { processingRunId: runId, sourceSide: 'right', timestampBasis: 'firmware_event_time_v1' }, pairing: { activePresent: true, candidatePresent: false } }) }) it.each(['manifestHash', 'components', 'sourceHashes', 'resolved'])('rejects substituted %s in a live run bundle', (field) => { const { reader, insert, writer, bundle } = setup() @@ -288,7 +298,7 @@ it('applies the saved context and capture policy to real connections and subscri const source = open(), frames = source.frames[Symbol.asyncIterator]() const next = frames.next() publishProspectiveFrame({ type: 'piezo-dual', ts: start / 1000, freq: 500, left1: [1], right1: [2] }, false, start) - expect((await next).value.context).toEqual({ activeSides: ['left'], leftOccupied: true, rightOccupied: null, pumpContextKnown: false }) + expect((await next).value.context).toEqual({ activeSides: ['left'], leftOccupied: true, rightOccupied: null, pumpContextKnown: false, mechanical: null }) insert() source.flush?.() expect(active).toHaveBeenCalledOnce() diff --git a/src/biometrics/shadow/tests/attempt.test.ts b/src/biometrics/shadow/tests/attempt.test.ts index cb3e15e28..b164fdba8 100644 --- a/src/biometrics/shadow/tests/attempt.test.ts +++ b/src/biometrics/shadow/tests/attempt.test.ts @@ -21,15 +21,15 @@ vi.mock('../worker-child', () => ({ runShadowChild: async (_location: unknown, i for await (const frame of frames) { worker.frames++ if (worker.fail) throw new Error('processor_failed') - output({ side: init.side, sourceSide: init.side, timestampMs: frame.observedAtMs, configHash: init.configHashes[init.side], sequence: sequence++, + output({ side: init.side, sourceSide: init.side, timestampBasis: 'firmware_event_time_v1', timestampMs: frame.observedAtMs, configHash: init.configHashes[init.side], sequence: sequence++, inputHash: 'a'.repeat(64), flags: null, limitations: [], readings: [ { metric: 'heart_rate', unit: 'bpm', windowMs: null, value: 60, quality: 1 }, { metric: 'breathing_rate', unit: 'breaths/min', windowMs: null, value: 12, quality: 1 }, { metric: 'window_ibi_variability_v1', unit: 'ms', windowMs: 300000, value: null, quality: 0 }, ] }) } - result = { health: { state: 'completed', reason: 'window_complete', records: sequence, outputs: sequence, cpu_ms: 1, - input_hash: 'a'.repeat(64), counts: { records: sequence, accepted_frames: sequence, empty_channel_frames: 0, + result = { health: { timestampBasis: 'firmware_event_time_v1', state: 'completed', reason: 'window_complete', records: sequence, outputs: sequence, cpu_ms: 1, + input_hash: 'a'.repeat(64), counts: { mechanical_gated_frames: 0, timestamp_rejected_frames: 0, records: sequence, accepted_frames: sequence, empty_channel_frames: 0, pump_gated_frames: 0, preroll_outputs: 0, scored_outputs: sequence }, limitations: [] }, inputBytes: worker.corruptReceipt ? 100001 : 100, outputBytes: 100, peakObservedRssBytes: 1000, rssSamples: 2, maximumObservationGapMs: 100, budgets: options.budgets, memoryScope: 'sum_rss_of_shadow_process_group', exactKernelPeak: false } diff --git a/src/biometrics/shadow/tests/python-installation.test.ts b/src/biometrics/shadow/tests/python-installation.test.ts index 85c9b0200..55180bfa9 100644 --- a/src/biometrics/shadow/tests/python-installation.test.ts +++ b/src/biometrics/shadow/tests/python-installation.test.ts @@ -3,7 +3,7 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, renameSync import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { probeShadowBuild } from '../build' +import { probeShadowBuild, shadowPythonBuildSchema } from '../build' import { guardShadowPythonInstallation } from '../python-installation' const directories: string[] = [] afterEach(() => directories.splice(0).forEach(path => rmSync(path, { recursive: true, force: true }))) @@ -59,3 +59,9 @@ it('refuses malformed build hashes and non-absolute dependency paths', async () build.dependencyFiles.numpy.record = 'relative/RECORD' expect(() => guardShadowPythonInstallation(location, build)).toThrow(/absolute/) }) + +it('refuses a build identity without the shared live timestamp and continuity boundary', async () => { + const { build } = await setup() + delete build.identity.sources['common/biometrics/live_input.py'] + expect(() => shadowPythonBuildSchema.parse(build)).toThrow(/source identity incomplete/) +}) diff --git a/src/biometrics/shadow/tests/store.test.ts b/src/biometrics/shadow/tests/store.test.ts index 62b8d6cbd..e46faaca8 100644 --- a/src/biometrics/shadow/tests/store.test.ts +++ b/src/biometrics/shadow/tests/store.test.ts @@ -88,7 +88,7 @@ it('fails queue inspection on corrupted pending evidence', () => { expect(() => store.pending({ maximumRuns: 1 })).toThrow('integrity') }) function workerOutput(timestampMs: number, sequence = 0) { - return { sequence, inputHash: hash, side: 'left', sourceSide: 'left', timestampMs, configHash: hash, + return { sequence, inputHash: hash, side: 'left', sourceSide: 'left', timestampBasis: 'firmware_event_time_v1', timestampMs, configHash: hash, readings: [{ metric: 'heart_rate', unit: 'bpm', windowMs: null, value: 60, quality: 1 }, { metric: 'breathing_rate', unit: 'breaths/min', windowMs: null, value: 15, quality: 1 }, { metric: 'window_ibi_variability_v1', unit: 'ms', windowMs: 300000, value: null, quality: 0 }], flags: null, limitations: [] } @@ -255,7 +255,7 @@ describe('separate prospective shadow storage', () => { const { store, advance } = setup() const lease = store.claim(store.create(plan, 'first').id, 1000, () => undefined) const sink = new ShadowWorkerSink(store, lease) - const output = { sequence: 0, inputHash: hash, side: 'left', sourceSide: 'right', timestampMs: start, configHash: hash, + const output = { sequence: 0, inputHash: hash, side: 'left', sourceSide: 'right', timestampBasis: 'firmware_event_time_v1', timestampMs: start, configHash: hash, readings: [{ metric: 'heart_rate', unit: 'bpm', windowMs: null, value: 60, quality: 1 }, { metric: 'breathing_rate', unit: 'breaths/min', windowMs: null, value: 15, quality: 1 }, { metric: 'window_ibi_variability_v1', unit: 'ms', windowMs: 300000, value: null, quality: 0 }], @@ -518,7 +518,7 @@ describe('storage policy and integrity guards', () => { const active = { ...activeOutput(start + 1000), inputHash: hash, provenance: { processingRunId: '11111111-1111-4111-8111-111111111111', sourceSide: 'left', generation: 1, manifestHash: hash } } expect(sink.append(output, active)).toEqual({ state: 'recorded' }) expect(store.snapshot(run.id, { maximumPoints: 10, maximumBytes: 10000 }).samples[0]).toMatchObject({ activeProvenance: active.provenance, - readings: [{ active: { value: 60, quality: 1 } }], candidateProvenance: { limitations: [] } }) + readings: [{ active: { value: 60, quality: 1 } }], candidateProvenance: { timestampBasis: 'firmware_event_time_v1', limitations: [] } }) const join = new ShadowStreamJoin(store, lease) expect(() => join.active({ ...activeOutput(start + 2000), side: 'right' })).toThrow('join_identity_mismatch') expect(store.get(run.id).state).toBe('failed') diff --git a/src/biometrics/shadow/tests/worker-child.test.ts b/src/biometrics/shadow/tests/worker-child.test.ts index f85a976fa..ae34340c1 100644 --- a/src/biometrics/shadow/tests/worker-child.test.ts +++ b/src/biometrics/shadow/tests/worker-child.test.ts @@ -21,7 +21,7 @@ const readline = require('node:readline'); const mode = ${JSON.stringify(state.mode)}; let init, records = 0, outputs = 0; const health = state => ({state, reason:null, records, outputs, cpu_ms:1, input_hash:'a'.repeat(64), limitations:[], - counts:{records,accepted_frames:records,empty_channel_frames:0,pump_gated_frames:0,preroll_outputs:0,scored_outputs:outputs}}); + timestampBasis:"firmware_event_time_v1",counts:{mechanical_gated_frames:0,timestamp_rejected_frames:0,records,accepted_frames:records,empty_channel_frames:0,pump_gated_frames:0,preroll_outputs:0,scored_outputs:outputs}}); function emit(row) { process.stdout.write(JSON.stringify({runId:init.runId,attemptId:init.attemptId,...row})+'\\n'); } if(mode==='ignore') { setInterval(()=>{},1000); } else readline.createInterface({input:process.stdin}).on('line', line => { @@ -40,7 +40,7 @@ else readline.createInterface({input:process.stdin}).on('line', line => { } if(message.type==='frame') { records++; - const batch = mode==='output' ? [{sequence:outputs++,inputHash:'a'.repeat(64),side:init.side,sourceSide:init.side, + const batch = mode==='output' ? [{sequence:outputs++,inputHash:'a'.repeat(64),side:init.side,sourceSide:init.side,timestampBasis:"firmware_event_time_v1", timestampMs:init.startMs+records*1000,configHash:init.configHashes[init.side],flags:null,limitations:[],readings:[ {metric:'heart_rate',unit:'bpm',windowMs:null,value:60,quality:1}, {metric:'breathing_rate',unit:'breaths/min',windowMs:null,value:12,quality:1}, diff --git a/src/biometrics/shadow/tests/worker-protocol.test.ts b/src/biometrics/shadow/tests/worker-protocol.test.ts index 4284e7a36..8bf278b5b 100644 --- a/src/biometrics/shadow/tests/worker-protocol.test.ts +++ b/src/biometrics/shadow/tests/worker-protocol.test.ts @@ -14,13 +14,13 @@ const expected = { runId: randomUUID(), attemptId: randomUUID(), side: 'left', s function fixture() { const protocol = new ShadowWorkerProtocol(expected) const ready = { type: 'ready', runId: expected.runId, attemptId: expected.attemptId, configHashes: expected.configHashes } - const output = { sequence: 0, inputHash: hash, side: 'left', sourceSide: 'right', timestampMs: start, configHash: hash, + const output = { sequence: 0, inputHash: hash, side: 'left', sourceSide: 'right', timestampBasis: 'firmware_event_time_v1', timestampMs: start, configHash: hash, readings: [{ metric: 'heart_rate', unit: 'bpm', windowMs: null, value: 60, quality: 1 }, { metric: 'breathing_rate', unit: 'breaths/min', windowMs: null, value: null, quality: 0 }, { metric: 'window_ibi_variability_v1', unit: 'ms', windowMs: 300000, value: null, quality: 0 }], flags: null, limitations: ['cold_start_without_serialized_state'] } const frame = { type: 'frame_result', runId: expected.runId, attemptId: expected.attemptId, sequence: 0, outputs: [output], - health: { state: 'running', reason: null, records: 1, outputs: 1, cpu_ms: 1, input_hash: hash, limitations: [], - counts: { records: 1, accepted_frames: 1, empty_channel_frames: 0, pump_gated_frames: 0, preroll_outputs: 0, scored_outputs: 1 } } } + health: { timestampBasis: 'firmware_event_time_v1', state: 'running', reason: null, records: 1, outputs: 1, cpu_ms: 1, input_hash: hash, limitations: [], + counts: { mechanical_gated_frames: 0, timestamp_rejected_frames: 0, records: 1, accepted_frames: 1, empty_channel_frames: 0, pump_gated_frames: 0, preroll_outputs: 0, scored_outputs: 1 } } } return { protocol, ready, frame } } describe('shadow worker response validation', () => { @@ -35,10 +35,13 @@ describe('shadow worker response validation', () => { const { protocol, ready, frame } = fixture() protocol.accept(ready, 'ready') for (const changed of [{ ...frame, attemptId: randomUUID() }, { ...frame, sequence: 1 }, + { ...frame, health: { ...frame.health, timestampBasis: undefined } }, + { ...frame, health: { ...frame.health, counts: { ...frame.health.counts, timestamp_rejected_frames: undefined } } }, + { ...frame, health: { ...frame.health, counts: { ...frame.health.counts, timestamp_rejected_frames: 1 } } }, { ...frame, health: { ...frame.health, outputs: 2 } }, { ...frame, health: { ...frame.health, cpu_ms: 30001 } }]) { expect(() => protocol.accept(changed, 'frame_result')).toThrow() } - for (const changes of [{ side: 'right' }, { configHash: 'b'.repeat(64) }, { timestampMs: start - 1 }, { timestampMs: expected.endMs }, + for (const changes of [{ timestampBasis: 'processing_clock' }, { timestampBasis: undefined }, { side: 'right' }, { configHash: 'b'.repeat(64) }, { timestampMs: start - 1 }, { timestampMs: expected.endMs }, { readings: frame.outputs[0].readings.map(row => row.metric === 'window_ibi_variability_v1' ? { ...row, metric: 'hrv_rmssd' } : row) }]) { expect(() => protocol.accept({ ...frame, outputs: [{ ...frame.outputs[0], ...changes }] }, 'frame_result')).toThrow() } @@ -65,8 +68,9 @@ it.skipIf(!existsSync(python))('validates actual locked-Python worker output acr const left = Buffer.alloc(2000) for (let index = 0; index < 500; index++) left.writeInt32LE(Math.trunc(Math.sin((second * 500 + index) / 500 * 2 * Math.PI * 1.2) * 1000000), index * 4) messages.push({ type: 'frame', sequence: second, observedAtMs: start + second * 1000, - record: { type: 'piezo-dual', left1: left.toString('base64'), right1: Buffer.alloc(2000).toString('base64') }, - context: { activeSides: ['left'], leftOccupied: true, rightOccupied: false, pumpContextKnown: true } }) + record: { type: 'piezo-dual', ts: start / 1000 + second, left1: left.toString('base64'), right1: Buffer.alloc(2000).toString('base64') }, + context: { activeSides: ['left'], leftOccupied: true, rightOccupied: false, pumpContextKnown: true, + mechanical: quietMechanicalSnapshot(start + second * 1000) } }) } messages.push({ type: 'finish' }) const result = spawnSync(python, ['-m', 'common.biometrics.shadow_worker'], { env: { ...process.env, PYTHONPATH: resolve('modules') }, @@ -82,3 +86,4 @@ it.skipIf(!existsSync(python))('validates actual locked-Python worker output acr expect(replies).toHaveLength(152) expect(outputs).toBeGreaterThan(0) }, 30000) +import { quietMechanicalSnapshot } from '../../mechanical/tests/fixtures' diff --git a/src/biometrics/shadow/worker-protocol.ts b/src/biometrics/shadow/worker-protocol.ts index fb8fc6887..4b96bddb6 100644 --- a/src/biometrics/shadow/worker-protocol.ts +++ b/src/biometrics/shadow/worker-protocol.ts @@ -1,6 +1,8 @@ import { z } from 'zod' import { configHash } from '../algorithms/canonical' +export const firmwareTimestampBasis = 'firmware_event_time_v1' as const + const integer = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) const hash = z.string().regex(/^[a-f0-9]{64}$/) const side = z.enum(['left', 'right']) @@ -11,12 +13,13 @@ const metricReading = z.discriminatedUnion('metric', [ z.object({ metric: z.literal('window_ibi_variability_v1'), unit: z.literal('ms'), windowMs: z.literal(300000), ...reading }).strict(), ]) export const shadowWorkerOutputSchema = z.object({ sequence: integer, inputHash: hash, side, sourceSide: side, - timestampMs: integer, configHash: hash, readings: z.array(metricReading).length(3), + timestampMs: integer, timestampBasis: z.literal(firmwareTimestampBasis), configHash: hash, readings: z.array(metricReading).length(3), flags: z.array(z.string().max(200)).max(32).nullable(), limitations: z.array(z.string().max(200)).max(32), }).strict().refine(output => new Set(output.readings.map(row => row.metric)).size === 3, 'All typed metrics required') -export const shadowWorkerHealthSchema = z.object({ state: z.enum(['running', 'completed', 'stopped']), reason: z.string().max(500).nullable(), +// New context fields are optional only for historical receipts; current replies require them. +export const shadowWorkerHealthSchema = z.object({ timestampBasis: z.literal(firmwareTimestampBasis).optional(), state: z.enum(['running', 'completed', 'stopped']), reason: z.string().max(500).nullable(), records: integer, outputs: integer, cpu_ms: z.number().finite().nonnegative(), input_hash: hash, - counts: z.object({ records: integer, accepted_frames: integer, empty_channel_frames: integer, + counts: z.object({ mechanical_gated_frames: integer.optional(), timestamp_rejected_frames: integer.optional(), records: integer, accepted_frames: integer, empty_channel_frames: integer, pump_gated_frames: integer, preroll_outputs: integer, scored_outputs: integer }).strict(), limitations: z.array(z.string().max(200)).max(32), }).strict() @@ -71,9 +74,10 @@ export class ShadowWorkerProtocol { } const health = response.health const state = response.type === 'frame_result' ? 'running' : response.type === 'finished' ? 'completed' : 'stopped' - if (health.state !== state || health.records !== records || health.counts.records !== records || health.outputs !== outputCount + if (health.timestampBasis !== firmwareTimestampBasis || health.counts.timestamp_rejected_frames === undefined || health.counts.mechanical_gated_frames === undefined + || health.state !== state || health.records !== records || health.counts.records !== records || health.outputs !== outputCount || health.cpu_ms < this.cpuMs || health.cpu_ms > expected.maximumCpuMs - || health.counts.accepted_frames + health.counts.empty_channel_frames + health.counts.pump_gated_frames > records + || health.counts.accepted_frames + health.counts.empty_channel_frames + health.counts.pump_gated_frames + (health.counts.timestamp_rejected_frames ?? 0) + (health.counts.mechanical_gated_frames ?? 0) > records || health.counts.scored_outputs < outputCount || health.counts.scored_outputs + health.counts.preroll_outputs > 2 * health.counts.accepted_frames || batch.some(output => output.inputHash !== health.input_hash)) throw new Error('Shadow health receipt inconsistent') diff --git a/src/biometrics/shadow/worker-sink.ts b/src/biometrics/shadow/worker-sink.ts index 25a36a2ac..a43e15f65 100644 --- a/src/biometrics/shadow/worker-sink.ts +++ b/src/biometrics/shadow/worker-sink.ts @@ -40,7 +40,7 @@ export class ShadowWorkerSink { const result = this.store.append(this.lease, { sequence: this.baseSequence + output.sequence, side: output.side, timestampMs: output.timestampMs, inputHash: output.inputHash, candidateConfigHash: output.configHash, activeConfigHash: this.plan.activeConfigHash, readings, ...(active?.provenance ? { activeProvenance: active.provenance } : {}), - candidateProvenance: { sourceSide: output.sourceSide, outputSequence: output.sequence, flags: output.flags, + candidateProvenance: { timestampBasis: output.timestampBasis, sourceSide: output.sourceSide, outputSequence: output.sequence, flags: output.flags, limitations: active ? output.limitations : [...output.limitations, 'missing_contemporaneous_active_reading'] } }) if (result.state !== 'quota_reached') this.nextOutput = Math.max(this.nextOutput, output.sequence + 1) return result diff --git a/src/biometrics/stage-model-store.ts b/src/biometrics/stage-model-store.ts new file mode 100644 index 000000000..a7601f34d --- /dev/null +++ b/src/biometrics/stage-model-store.ts @@ -0,0 +1,132 @@ +import { and, desc, eq, getTableColumns, sql } from 'drizzle-orm' +import { TRPCError } from '@trpc/server' +import { db } from '@/src/db' +import { sideStageModels, stageModelLibrary } from '@/src/db/schema' +import { dataHash } from '@/src/biometrics/algorithms/canonical' +import { failingTestVectors, MAX_STAGE_MODEL_BYTES, stageModelContract, stageModelSchema, type StageModel } from '@/src/lib/personal-stage-model' + +// Read raw JSON so one malformed stored document cannot break rules fallback or the library. +const selectionColumns = { ...getTableColumns(sideStageModels), model: sql`${sideStageModels.model}` } +const libraryColumns = { ...getTableColumns(stageModelLibrary), model: sql`${stageModelLibrary.model}` } + +export function modelDigest(model: StageModel) { + return dataHash(Object.fromEntries(Object.entries(model).filter(([, value]) => value !== undefined))) +} + +type Side = 'left' | 'right' +type Transaction = Pick + +/** Test vectors verify converter compatibility, not sleep accuracy or clinical validity. */ +export function validateModel(model: StageModel) { + if (!stageModelContract(model)) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown model feature contract' }) + if (failingTestVectors(model).length) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Model fails its test vectors' }) +} + +/** A stored document is untrusted after disk edits, schema changes or partial restoration. */ +function validStoredModel(row: { model: unknown, modelHash: string } | undefined): StageModel | null { + if (!row) return null + let document = row.model + if (typeof document === 'string') { + if (Buffer.byteLength(document) > MAX_STAGE_MODEL_BYTES) return null + try { + document = JSON.parse(document) + } + catch { return null } + } + const parsed = stageModelSchema.safeParse(document) + if (!parsed.success) return null + try { + validateModel(parsed.data) + return modelDigest(parsed.data) === row.modelHash ? parsed.data : null + } + catch { return null } +} + +function saveLibraryModel(transaction: Transaction, side: Side, model: StageModel, uploadedAt: Date) { + const modelHash = modelDigest(model) + const existing = transaction.select({ id: stageModelLibrary.id }).from(stageModelLibrary) + .where(and(eq(stageModelLibrary.side, side), eq(stageModelLibrary.modelHash, modelHash))).get() + if (existing) return existing.id + return transaction.insert(stageModelLibrary).values({ side, model, modelHash, version: model.version, uploadedAt }) + .returning({ id: stageModelLibrary.id }).get().id +} + +export function getStoredStageModel(side: Side) { + const row = db.select(selectionColumns).from(sideStageModels).where(eq(sideStageModels.side, side)).get() + const model = validStoredModel(row) + return { model, uploadedAt: model && row ? row.uploadedAt.getTime() : null, active: !!model && !!row?.active, + modelHash: model && row ? row.modelHash : null } +} + +export function loadActiveStageModel(side: Side): StageModel | null { + const stored = getStoredStageModel(side) + return stored.active ? stored.model : null +} + +export function listStageModels(side: Side) { + const selected = getStoredStageModel(side) + return db.select(libraryColumns).from(stageModelLibrary).where(eq(stageModelLibrary.side, side)) + .orderBy(desc(stageModelLibrary.id)).all().flatMap((row) => { + const model = validStoredModel(row) + if (!model) return [] + const isSelected = row.modelHash === selected.modelHash + return [{ id: row.id, version: model.version, kind: model.kind, uploadedAt: row.uploadedAt.getTime(), + modelHash: row.modelHash, metrics: model.metrics, selected: isSelected, active: isSelected && selected.active }] + }) +} + +/** Resolve a library candidate for preview without changing the selected model. */ +export function getLibraryStageModel(side: Side, id: number) { + const row = db.select(libraryColumns).from(stageModelLibrary) + .where(and(eq(stageModelLibrary.id, id), eq(stageModelLibrary.side, side))).get() + const model = validStoredModel(row) + if (!model) throw new TRPCError({ code: 'NOT_FOUND', message: 'Valid model not found for this side' }) + return model +} + +export function uploadStageModel(side: Side, model: StageModel) { + validateModel(model) + const id = db.transaction(transaction => saveLibraryModel(transaction, side, model, new Date()), { behavior: 'immediate' }) + return { id, version: model.version } +} + +/** Selection is atomic and side-scoped. Activation is always an explicit input. */ +export function selectStageModel(side: Side, id: number, active: boolean) { + return db.transaction((transaction) => { + const row = transaction.select(libraryColumns).from(stageModelLibrary) + .where(and(eq(stageModelLibrary.id, id), eq(stageModelLibrary.side, side))).get() + const model = validStoredModel(row) + if (!row || !model) throw new TRPCError({ code: 'NOT_FOUND', message: 'Valid model not found for this side' }) + const values = { model, modelHash: row.modelHash, version: model.version, uploadedAt: row.uploadedAt, active } + transaction.insert(sideStageModels).values({ side, ...values }) + .onConflictDoUpdate({ target: sideStageModels.side, set: values }).run() + return { version: model.version, active } + }, { behavior: 'immediate' }) +} + +export function activateStageModel(side: Side, active: boolean) { + return db.transaction((transaction) => { + const row = transaction.select(selectionColumns).from(sideStageModels).where(eq(sideStageModels.side, side)).get() + const model = validStoredModel(row) + if (active && !model) throw new TRPCError({ code: 'NOT_FOUND', message: `No valid personal stage model stored for ${side}` }) + // Disabling also clears a corrupt row's persisted flag. + transaction.update(sideStageModels).set({ active }).where(eq(sideStageModels.side, side)).run() + return { active, version: model?.version ?? null } + }, { behavior: 'immediate' }) +} + +/** Legacy PUT selects an upload in preview only, even if the old selection was active. */ +export function setStageModel(side: Side, model: StageModel | null) { + if (model) validateModel(model) + return db.transaction((transaction) => { + if (!model) { + transaction.delete(sideStageModels).where(eq(sideStageModels.side, side)).run() + return { stored: false, version: null } + } + saveLibraryModel(transaction, side, model, new Date()) + const values = { model, modelHash: modelDigest(model), version: model.version, uploadedAt: new Date(), active: false } + transaction.insert(sideStageModels).values({ side, ...values }) + .onConflictDoUpdate({ target: sideStageModels.side, set: values }).run() + return { stored: true, version: model.version } + }, { behavior: 'immediate' }) +} diff --git a/src/biometrics/stage-night.ts b/src/biometrics/stage-night.ts new file mode 100644 index 000000000..7b297777d --- /dev/null +++ b/src/biometrics/stage-night.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' +import { stagesWithinWindow } from '@/src/lib/stage-coverage' +import { classifySleepStages, type SleepEpoch } from '@/src/lib/sleep-stages' +import { classifyWithStageModel, type StageModel } from '@/src/lib/personal-stage-model' +import type { StageRuleset } from '@/src/lib/sleep-stage-rules' +import { dataHash } from './algorithms/canonical' + +export const stagedBySchema = z.object({ + engine: z.enum(['rules', 'model']), version: z.string().nullable(), modelHash: z.string().nullable(), + reason: z.enum(['active_model', 'rules_selected', 'no_model_epochs', 'model_evaluation_failed']), +}) +export type StagedBy = z.infer + +/** Reclassify a view of a night; keep source identity separate from sensor/profile provenance. */ +export function stageNight( + vitalsData: Parameters[0], movementData: Parameters[1], + calibrationQuality: number, ruleset: StageRuleset, model: StageModel | null, window: { start: Date, end: Date }, +): { epochs: SleepEpoch[], stagedBy: StagedBy } { + let reason: StagedBy['reason'] = 'rules_selected' + if (model) { + try { + const epochs = stagesWithinWindow(classifyWithStageModel(model, window.start, window.end, vitalsData, movementData, ruleset), window) + if (epochs.length > 0) return { epochs, stagedBy: { engine: 'model', version: model.version, modelHash: dataHash(Object.fromEntries(Object.entries(model).filter(([, value]) => value !== undefined))), reason: 'active_model' } } + reason = 'no_model_epochs' + } + catch { reason = 'model_evaluation_failed' } + } + return { epochs: stagesWithinWindow(classifySleepStages(vitalsData, movementData, calibrationQuality, ruleset), window), + stagedBy: { engine: 'rules', version: null, modelHash: null, reason } } +} diff --git a/src/biometrics/tests/stage-coverage.test.ts b/src/biometrics/tests/stage-coverage.test.ts new file mode 100644 index 000000000..23cf13b8a --- /dev/null +++ b/src/biometrics/tests/stage-coverage.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { stageNight } from '../stage-night' +import { stagesWithinWindow } from '@/src/lib/stage-coverage' +import { classifyNights, mergeIntoBlocks, type SleepEpoch } from '@/src/lib/sleep-stages' +import { DEFAULT_STAGE_RULESET } from '@/src/lib/sleep-stage-rules' + +const epoch = (start: number, duration: number, stage: SleepEpoch['stage'] = 'light'): SleepEpoch => + ({ start, duration, stage, heartRate: 60, hrv: 30, breathingRate: 14, movement: null }) +const window = { start: new Date(100_000), end: new Date(600_000) } + +describe('recording stage coverage', () => { + it('clips both ends, removes invalid epochs and prevents duplicate or overlapping time', () => { + const result = stagesWithinWindow([ + epoch(0, 200_000), epoch(200_000, 300_000), epoch(300_000, 300_000), + epoch(300_000, 200_000), epoch(550_000, 300_000), epoch(700_000, 100), epoch(NaN, 100), + ], window) + expect(result.map(({ start, duration }) => [start, duration])).toEqual([ + [100_000, 100_000], [200_000, 100_000], [300_000, 200_000], [550_000, 50_000], + ]) + expect(result.reduce((total, entry) => total + entry.duration, 0)).toBe(450_000) + }) + it('does not turn a sensor outage into an uninterrupted stage block', () => { + const result = mergeIntoBlocks([epoch(0, 60_000), epoch(60_000, 60_000), epoch(600_000, 60_000)]) + expect(result).toEqual([{ start: 0, end: 120_000, stage: 'light' }, { start: 600_000, end: 660_000, stage: 'light' }]) + }) + it('clips the real rules classifier final five-minute estimate to the requested end', () => { + const result = stageNight([{ timestamp: new Date(550_000), heartRate: 60, + hrv: 30, breathingRate: 14 }], [], 1, DEFAULT_STAGE_RULESET, null, window) + expect(result.epochs).toHaveLength(1) + expect(result.epochs[0]).toMatchObject({ start: 550_000, duration: 50_000 }) + expect(result.stagedBy.engine).toBe('rules') + }) + it('uses the same recording boundary for rule previews and agreement', () => { + const result = classifyNights([{ window, vitalsData: [{ timestamp: new Date(550_000), heartRate: 60, hrv: 30, breathingRate: 14 }], + movementData: [], calibrationQuality: 1, reference: [] }], DEFAULT_STAGE_RULESET) + expect(result?.latestEpochs[0].duration).toBe(50_000) + expect(result?.summary.totalSleepMs).toBe(50_000) + expect(result?.summary.blocks[0].end).toBe(600_000) + }) + it('rejects reversed and invalid windows', () => { + expect(stagesWithinWindow([epoch(0, 300_000)], { start: new Date(5), end: new Date(4) })).toEqual([]) + expect(stagesWithinWindow([epoch(0, 300_000)], { start: new Date(NaN), end: new Date(4) })).toEqual([]) + }) +}) diff --git a/src/capabilities/contract.ts b/src/capabilities/contract.ts new file mode 100644 index 000000000..be0c2f630 --- /dev/null +++ b/src/capabilities/contract.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' +import type { BaseStatus } from '@/src/hardware/base/types' +import type { DeviceStatus } from '@/src/hardware/types' + +export const DEVICE_FRESHNESS_MS = 15_000 +export const BASE_FRESHNESS_MS = 10_000 + +const observationSchema = z.object({ + freshness: z.enum(['fresh', 'stale', 'unavailable']), + observedAt: z.iso.datetime().nullable(), + expiresAt: z.iso.datetime().nullable(), +}) +const featureSchema = z.object({ + implemented: z.boolean(), + runtime: z.enum(['ready', 'not_ready', 'unknown']), + reason: z.string(), + physicalVerification: z.enum(['unverified', 'not_applicable']), +}) +type Feature = z.infer + +export const capabilitiesSchema = z.object({ + apiVersion: z.literal(1), + generatedAt: z.iso.datetime(), + controller: z.object({ + id: z.literal('sleepypod-core'), + upstreamBaseline: z.literal('3.4.0'), + revision: z.string().nullable(), + }), + endpoints: z.object({ + rest: z.literal('/api'), + trpc: z.literal('/api/trpc'), + openapi: z.literal('/api/openapi.json'), + nativeCompatibility: z.string().startsWith('/api/').nullable(), + controls: z.literal('/api/v1/controls'), + importedHistory: z.literal('/api/v1/history'), + journal: z.literal('/api/v1/journal'), + nextAlarm: z.literal('/api/v1/alarm/next'), + alarmOverride: z.literal('/api/v1/alarm/override'), + temperatureApplications: z.literal('/api/v1/device/temperature/applications').optional(), + }), + authentication: z.object({ required: z.literal(true), bearer: z.literal(true), browserSession: z.literal(true) }), + observations: z.object({ device: observationSchema, base: observationSchema }), + features: z.object({ + deviceTelemetry: featureSchema, + temperatureControl: featureSchema, + temperatureApplicationHistory: featureSchema.optional(), + basePosition: featureSchema, + baseMovement: featureSchema, + baseStop: featureSchema, + wakeVibration: featureSchema, + alarmSnooze: featureSchema, + curveLibrary: featureSchema, + personalStageModels: featureSchema, + personalComfort: featureSchema, + personalComfortPreview: featureSchema, + personalComfortOwnerReview: featureSchema.optional(), + musicVibration: featureSchema, + mqtt: featureSchema, + homeContext: featureSchema, + nativeCompatibility: featureSchema, + importedHistory: featureSchema, + morningJournal: featureSchema, + nextAlarmOverride: featureSchema, + householdPreparation: featureSchema.optional(), + }), + baseScope: z.literal('whole_bed'), + controlSemantics: z.object({ + acknowledgment: z.literal('accepted_not_physically_verified'), + durableCommandReceipts: z.boolean(), + commandExpiry: z.boolean(), + receiptScope: z.literal('versioned_controls_only'), + maxLifetimeMs: z.literal(60000), + }), +}) +export type ControllerCapabilities = z.infer + +export interface CapabilitySnapshot { + now: number + revision: string | null + device: { status: DeviceStatus | null, observedAt: number | null, current: boolean } + base: BaseStatus | null + mqttConnected: boolean + comfortPreviewRunning?: boolean + comfortActive?: boolean + roomContextFresh?: boolean + householdPreparationEnabled?: boolean +} + +/** Preserve a valid old timestamp, but never label malformed or future data fresh. */ +function observation(at: number | null, maxAgeMs: number, now: number, present: boolean, current: boolean): z.infer { + const valid = present && at !== null && Number.isSafeInteger(at) && at > 0 && at <= now + if (!valid) return { freshness: 'unavailable', observedAt: null, expiresAt: null } + return { + freshness: current && now - at < maxAgeMs ? 'fresh' : 'stale', + observedAt: new Date(at).toISOString(), + expiresAt: new Date(at + maxAgeMs).toISOString(), + } +} + +function feature(runtime: Feature['runtime'], reason: string, physical = false, implemented = true): Feature { + return { implemented, runtime, reason, physicalVerification: physical ? 'unverified' : 'not_applicable' } +} + +/** Capabilities describe evidence and implementation; they never authorize a command. */ +export function buildCapabilities(snapshot: CapabilitySnapshot): ControllerCapabilities { + const { now, device, base } = snapshot + const deviceObservation = observation(device.observedAt, DEVICE_FRESHNESS_MS, now, device.status !== null, device.current) + const baseObservation = observation(base?.lastUpdate ?? null, BASE_FRESHNESS_MS, now, base?.position != null, base?.state === 'connected' && !base.stale) + const deviceFresh = deviceObservation.freshness === 'fresh' + const baseFresh = baseObservation.freshness === 'fresh' + const thermalReady = deviceFresh && device.status?.waterLevel === 'ok' && !device.status.isPriming + const thermalReason = !deviceFresh ? 'device_report_unavailable' : device.status?.isPriming ? 'priming' : device.status?.waterLevel !== 'ok' ? 'water_not_ready' : 'fresh_device_report' + const baseReady = baseFresh && base?.moving === false && !base.busy + const baseReason = !baseFresh ? 'base_report_unavailable' : base?.busy || base?.moving ? 'base_busy' : base?.moving === null ? 'base_motion_unknown' : 'fresh_base_report' + return { + apiVersion: 1, + generatedAt: new Date(now).toISOString(), + controller: { id: 'sleepypod-core', upstreamBaseline: '3.4.0', revision: snapshot.revision }, + endpoints: { rest: '/api', trpc: '/api/trpc', openapi: '/api/openapi.json', nativeCompatibility: null, controls: '/api/v1/controls', importedHistory: '/api/v1/history', journal: '/api/v1/journal', nextAlarm: '/api/v1/alarm/next', alarmOverride: '/api/v1/alarm/override', temperatureApplications: '/api/v1/device/temperature/applications' }, + authentication: { required: true, bearer: true, browserSession: true }, + observations: { device: deviceObservation, base: baseObservation }, + features: { + deviceTelemetry: feature(deviceFresh ? 'ready' : 'not_ready', deviceFresh ? 'fresh_device_report' : 'device_report_unavailable'), + temperatureControl: feature(thermalReady ? 'ready' : 'not_ready', thermalReason, true), + temperatureApplicationHistory: feature('ready', 'read_only_attempt_history_check_storage_availability'), + basePosition: feature(baseFresh ? 'ready' : 'not_ready', baseFresh ? 'fresh_base_report' : 'base_report_unavailable', true), + baseMovement: feature(baseReady ? 'ready' : 'not_ready', baseReason, true), + // Stop must remain visible and callable even when position is stale or motion is unknown. + baseStop: feature(base?.state === 'connected' ? 'ready' : 'not_ready', base?.state === 'connected' ? 'base_connected' : 'base_disconnected', true), + wakeVibration: feature(deviceFresh && !device.status?.isPriming ? 'ready' : 'not_ready', !deviceFresh ? 'device_report_unavailable' : device.status?.isPriming ? 'priming' : 'fresh_device_report', true), + alarmSnooze: feature('ready', 'per_alarm_snooze_length_and_taper'), + curveLibrary: feature('ready', 'calendar_curves_with_bedtime_ownership_guard'), + personalStageModels: feature('ready', 'validated_library_explicit_selection_rules_fallback'), + personalComfort: feature(snapshot.comfortActive ? 'ready' : 'not_ready', snapshot.comfortActive ? 'owner_activation_current_live_guards_required' : 'personal_comfort_activation_unqualified', true), + personalComfortOwnerReview: feature('ready', 'source_bound_qualification_and_seven_preview_nights'), + personalComfortPreview: feature(snapshot.comfortPreviewRunning ? 'ready' : 'not_ready', snapshot.comfortPreviewRunning ? 'preview_only' : 'preview_runtime_stopped'), + musicVibration: feature('not_ready', 'hardware_protocol_unqualified', true, false), + mqtt: feature(snapshot.mqttConnected ? 'ready' : 'not_ready', snapshot.mqttConnected ? 'broker_connected' : 'broker_disconnected'), + homeContext: feature(snapshot.roomContextFresh ? 'ready' : 'not_ready', snapshot.roomContextFresh ? 'fresh_mapped_room_measurements' : 'room_measurements_unavailable'), + nativeCompatibility: feature('not_ready', 'compatibility_api_pending', false, false), + importedHistory: feature('ready', 'read_only_history_api_check_snapshot_availability'), + morningJournal: feature('ready', 'local_revision_checked_journal'), + nextAlarmOverride: feature('ready', 'persisted_single_occurrence_skip_time_restore', true), + householdPreparation: feature(snapshot.householdPreparationEnabled ? 'ready' : 'not_ready', snapshot.householdPreparationEnabled ? 'personal_workspace_drafts_only' : 'household_preparation_disabled'), + }, + baseScope: 'whole_bed', + controlSemantics: { acknowledgment: 'accepted_not_physically_verified', durableCommandReceipts: true, commandExpiry: true, receiptScope: 'versioned_controls_only', maxLifetimeMs: 60000 }, + } +} diff --git a/src/capabilities/read.ts b/src/capabilities/read.ts new file mode 100644 index 000000000..2832e4fa1 --- /dev/null +++ b/src/capabilities/read.ts @@ -0,0 +1,29 @@ +import { readControllerRevision } from '@/src/server/controllerRevision' +import { RoomSignalReader } from '@/src/home-context/instance' +import { getDacMonitorIfRunning } from '@/src/hardware/dacMonitor.instance' +import { getBaseControllerIfRunning } from '@/src/hardware/base/instance' +import { getBridgeStatus } from '@/src/streaming/mqttBridge' +import { getComfortRuntimeIfRunning } from '@/src/comfort/instance' +import { buildCapabilities, DEVICE_FRESHNESS_MS } from './contract' + +/** Reads only existing runtime state; never starts DAC, Bluetooth, MQTT or a policy. */ +export async function readCapabilities() { + const revision = await readControllerRevision() + const monitor = getDacMonitorIfRunning() + const base = getBaseControllerIfRunning() + return buildCapabilities({ + now: Date.now(), + revision, + device: { + status: monitor?.getLastStatus() ?? null, + observedAt: monitor?.getLastPollAt() ?? null, + current: monitor?.getFreshStatus(DEVICE_FRESHNESS_MS) != null, + }, + base: base?.status() ?? null, + mqttConnected: getBridgeStatus().connected, + comfortActive: ['left', 'right'].some(side => getComfortRuntimeIfRunning()?.status(side as 'left' | 'right').activation.current === true), + comfortPreviewRunning: getComfortRuntimeIfRunning()?.isRunning() ?? false, + roomContextFresh: Object.keys(new RoomSignalReader().read()).length > 0, + householdPreparationEnabled: process.env.SLEEPYPOD_HOUSEHOLD_PREPARATION === '1', + }) +} diff --git a/src/capabilities/tests/contract.test.ts b/src/capabilities/tests/contract.test.ts new file mode 100644 index 000000000..b91e4f8e2 --- /dev/null +++ b/src/capabilities/tests/contract.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { parseDeviceStatus } from '@/src/hardware/responseParser' +import { DEVICE_STATUS_POD4 } from '@/src/hardware/tests/fixtures' +import type { BaseStatus } from '@/src/hardware/base/types' +import type { DeviceStatus } from '@/src/hardware/types' +import { BASE_FRESHNESS_MS, DEVICE_FRESHNESS_MS, buildCapabilities, capabilitiesSchema, type CapabilitySnapshot } from '../contract' + +const now = Date.parse('2026-10-25T20:00:00Z') +type PopulatedSnapshot = CapabilitySnapshot & { device: { status: DeviceStatus }, base: BaseStatus } +function snapshot(): PopulatedSnapshot { + return { + now, revision: 'abcdef0', mqttConnected: false, + device: { status: parseDeviceStatus(DEVICE_STATUS_POD4), observedAt: now - 1000, current: true }, + base: { + state: 'connected', independentControl: false, movingBySide: { left: false, right: false }, + splitBase: false, position: { left: { head: 0, feet: 0 }, right: { head: 0, feet: 0 } }, + lastUpdate: now - 1000, stale: false, moving: false, busy: false, error: null, + }, + } +} + +describe('capability evidence contract', () => { + it('reports fresh runtime evidence without promoting it to physical qualification', () => { + const result = buildCapabilities(snapshot()) + expect(capabilitiesSchema.parse(result)).toEqual(result) + expect(result.features.temperatureControl).toMatchObject({ implemented: true, runtime: 'ready', physicalVerification: 'unverified' }) + expect(result.features.baseMovement).toMatchObject({ runtime: 'ready', physicalVerification: 'unverified' }) + expect(result.features.wakeVibration.physicalVerification).toBe('unverified') + expect(result.observations.device).toEqual({ freshness: 'fresh', observedAt: new Date(now - 1000).toISOString(), expiresAt: new Date(now - 1000 + DEVICE_FRESHNESS_MS).toISOString() }) + expect(result.baseScope).toBe('whole_bed') + expect(result.features.alarmSnooze).toMatchObject({ implemented: true, runtime: 'ready', physicalVerification: 'not_applicable' }) + expect(result.features.curveLibrary).toMatchObject({ implemented: true, runtime: 'ready', reason: 'calendar_curves_with_bedtime_ownership_guard' }) + expect(result.features.personalStageModels).toMatchObject({ implemented: true, runtime: 'ready', physicalVerification: 'not_applicable' }) + expect(result.features.importedHistory).toMatchObject({ implemented: true, runtime: 'ready', reason: 'read_only_history_api_check_snapshot_availability' }) + expect(result.endpoints.importedHistory).toBe('/api/v1/history') + expect(result.endpoints.temperatureApplications).toBe('/api/v1/device/temperature/applications') + expect(result.features.temperatureApplicationHistory).toMatchObject({ implemented: true, runtime: 'ready', physicalVerification: 'not_applicable' }) + expect(result.controlSemantics).toEqual({ acknowledgment: 'accepted_not_physically_verified', durableCommandReceipts: true, commandExpiry: true, receiptScope: 'versioned_controls_only', maxLifetimeMs: 60000 }) + }) + + it.each([null, Number.NaN, Number.POSITIVE_INFINITY, -1, 0, now + 1, now - 0.5])('rejects invalid or future observation time %s', (timestamp) => { + const input = snapshot() + input.device.observedAt = timestamp + input.base.lastUpdate = timestamp + const result = buildCapabilities(input) + expect(result.observations.device.freshness).toBe('unavailable') + expect(result.observations.base.freshness).toBe('unavailable') + expect(result.features.temperatureControl.runtime).toBe('not_ready') + expect(result.features.baseMovement.runtime).toBe('not_ready') + expect(result.features.baseStop.runtime).toBe('ready') + }) + + it('expires observations exactly at the freshness boundary', () => { + const input = snapshot() + input.device.observedAt = now - DEVICE_FRESHNESS_MS + input.base.lastUpdate = now - BASE_FRESHNESS_MS + const result = buildCapabilities(input) + expect(result.observations.device.freshness).toBe('stale') + expect(result.observations.base.freshness).toBe('stale') + expect(result.features.wakeVibration.runtime).toBe('not_ready') + expect(result.features.baseStop.runtime).toBe('ready') + }) + + it('withdraws readiness after disconnect or an invalidated DAC observation', () => { + const input = snapshot() + input.device.current = false + input.base.state = 'disconnected' + const result = buildCapabilities(input) + expect(result.observations.device.freshness).toBe('stale') + expect(result.observations.base.freshness).toBe('stale') + expect(result.features.baseStop.runtime).toBe('not_ready') + }) + + it.each>([{ busy: true }, { moving: true }, { moving: null }, { stale: true }, { position: null }])('keeps Stop available when movement cannot be offered: %s', (changes) => { + const input = snapshot() + Object.assign(input.base, changes) + const result = buildCapabilities(input) + expect(result.features.baseMovement.runtime).toBe('not_ready') + expect(result.features.baseStop.runtime).toBe('ready') + }) + + it('withholds thermal readiness for low water and priming', () => { + const input = snapshot() + input.device.status.waterLevel = 'low' + expect(buildCapabilities(input).features.temperatureControl.reason).toBe('water_not_ready') + input.device.status.isPriming = true + const result = buildCapabilities(input) + expect(result.features.temperatureControl.reason).toBe('priming') + expect(result.features.wakeVibration.runtime).toBe('not_ready') + expect(result.features.deviceTelemetry.runtime).toBe('ready') + }) + + it('reports absent hardware and unimplemented integrations honestly', () => { + const input: CapabilitySnapshot = snapshot() + input.device.status = null + input.base = null + const result = buildCapabilities(input) + expect(result.features.deviceTelemetry.runtime).toBe('not_ready') + expect(result.features.basePosition.runtime).toBe('not_ready') + for (const key of ['nativeCompatibility', 'musicVibration'] as const) { + expect(result.features[key]).toMatchObject({ implemented: false, runtime: 'not_ready' }) + } + expect(result.endpoints.nativeCompatibility).toBeNull() + expect(result.features.personalComfort).toMatchObject({ implemented: true, runtime: 'not_ready', physicalVerification: 'unverified' }) + expect(result.features.personalComfortOwnerReview?.implemented).toBe(true) + expect(result.features.mqtt.runtime).toBe('not_ready') + input.mqttConnected = true + expect(buildCapabilities(input).features.mqtt.runtime).toBe('ready') + expect(buildCapabilities(input).features.homeContext.runtime).toBe('not_ready') + expect(result.features.homeContext.implemented).toBe(true) + input.roomContextFresh = true + expect(buildCapabilities(input).features.homeContext).toMatchObject({ runtime: 'ready', reason: 'fresh_mapped_room_measurements' }) + }) +}) diff --git a/src/capabilities/tests/read.test.ts b/src/capabilities/tests/read.test.ts new file mode 100644 index 000000000..e8627d2a3 --- /dev/null +++ b/src/capabilities/tests/read.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const runtime = vi.hoisted(() => ({ readFile: vi.fn(), monitor: vi.fn(), base: vi.fn(), mqtt: vi.fn() })) +vi.mock('node:fs/promises', () => ({ readFile: runtime.readFile, default: { readFile: runtime.readFile } })) +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ getDacMonitorIfRunning: runtime.monitor })) +vi.mock('@/src/hardware/base/instance', () => ({ getBaseControllerIfRunning: runtime.base })) +vi.mock('@/src/streaming/mqttBridge', () => ({ getBridgeStatus: runtime.mqtt })) +vi.mock('@/src/comfort/instance', () => ({ getComfortRuntimeIfRunning: () => undefined })) +const { readCapabilities } = await import('../read') + +beforeEach(() => { + vi.resetAllMocks() + runtime.readFile.mockResolvedValue('{"commitHash":"abcdef123"}') + runtime.monitor.mockReturnValue(null) + runtime.base.mockReturnValue(null) + runtime.mqtt.mockReturnValue({ connected: false }) +}) +afterEach(() => vi.useRealTimers()) + +describe('passive capability reader', () => { + it('does not need hardware initialization to describe an unstarted controller', async () => { + const result = await readCapabilities() + expect(result.controller.revision).toBe('abcdef123') + expect(result.features.deviceTelemetry.runtime).toBe('not_ready') + expect(runtime.readFile).toHaveBeenCalledWith('.git-info', 'utf-8') + }) + + it.each(['null', '{}', '{"commitHash":12}', '{"commitHash":"unknown"}', '{"commitHash":"not a revision"}', 'invalid-json'])('uses unknown revision for invalid build metadata: %s', async (manifest) => { + runtime.readFile.mockResolvedValue(manifest) + expect((await readCapabilities()).controller.revision).toBeNull() + }) + + it('survives a missing build manifest', async () => { + runtime.readFile.mockRejectedValue(new Error('ENOENT')) + expect((await readCapabilities()).controller.revision).toBeNull() + }) + + it('uses monitor invalidation and existing base reports without control methods', async () => { + vi.useFakeTimers().setSystemTime(new Date('2026-10-25T20:00:00Z')) + const { parseDeviceStatus } = await import('@/src/hardware/responseParser') + const { DEVICE_STATUS_POD4 } = await import('@/src/hardware/tests/fixtures') + const fresh = vi.fn().mockReturnValue(null) + runtime.monitor.mockReturnValue({ getLastStatus: () => parseDeviceStatus(DEVICE_STATUS_POD4), getLastPollAt: () => Date.now() - 500, getFreshStatus: fresh }) + const result = await readCapabilities() + expect(result.observations.device.freshness).toBe('stale') + expect(fresh).toHaveBeenCalledWith(15_000) + }) +}) diff --git a/src/comfort/application-contract.ts b/src/comfort/application-contract.ts new file mode 100644 index 000000000..2aac77652 --- /dev/null +++ b/src/comfort/application-contract.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +const timestamp = z.number().int().nonnegative().max(8_640_000_000_000_000) + +/** A durable reservation survives interruption; it is never interpreted as successful delivery. */ +export const comfortApplicationSchema = z.object({ + requestId: z.string().uuid(), segment: z.string().min(1).max(1000), + targetF: z.number().min(55).max(110), previousF: z.number().min(55).max(110), + createdAt: timestamp, reservedAt: timestamp, finishedAt: timestamp.nullable(), + outcome: z.enum(['attempting', 'acknowledged', 'unknown']), + receiptId: z.string().uuid().nullable(), +}).strict().refine(value => value.createdAt <= value.reservedAt && (value.finishedAt === null || value.finishedAt >= value.reservedAt), 'Invalid comfort application clock') + .refine(value => value.outcome !== 'acknowledged' || (value.finishedAt !== null && value.receiptId !== null), 'Missing temperature receipt') + +export type ComfortApplication = z.infer diff --git a/src/comfort/application.ts b/src/comfort/application.ts new file mode 100644 index 000000000..c1a8320ae --- /dev/null +++ b/src/comfort/application.ts @@ -0,0 +1,244 @@ +import { randomUUID } from 'node:crypto' +import type { Side } from '@/src/hardware/types' +import { ControlGuardError } from '@/src/controls/context' +import type { PolicyApplication, TemperatureController, TemperatureRequest } from '@/src/temperature/controller' +import type { ComfortApplication } from './application-contract' +import { comfortEvent, type ComfortStore } from './store' +import { decideComfort, MANUAL_PAUSE_MS, type ComfortContext, type ComfortState } from './policy' + +const PRODUCER = 'personal-comfort' +const LEASE_MS = 10_000 + +/** Supplied only by an owner-activation service that has verified qualification and night evidence. */ +export interface ComfortAdmission { + revision: string + issuedAt: number + expiresAt: number +} + +export interface ComfortApplicationDeps { + now(): number + monotonicNow(): number + store: ComfortStore + authority: Pick + withSideLock(side: Side, work: () => Promise): Promise + autopilotEnabled(): boolean + admission(side: Side, state: ComfortState): ComfortAdmission | null + pendingPause(side: Side): number | null + /** Must not call authority.status(): this reader is also called from selection/dispatch guards. */ + readContext(side: Side, state: ComfortState, now: number): ComfortContext +} + +interface Candidate { + id: string + side: Side + generation: number + at: number + createdAt: number + expiresAt: number + monotonicStart: number + monotonicDeadline: number + admissionRevision: string + config: string + offsetF: number + segment: string + targetF: number + previousF: number + applying: boolean +} + +function admitted(admission: ComfortAdmission | null, now: number): admission is ComfortAdmission { + return admission !== null && admission.revision.length > 0 + && Number.isSafeInteger(admission.issuedAt) && admission.issuedAt > 0 && admission.issuedAt <= now + && Number.isSafeInteger(admission.expiresAt) && now < admission.expiresAt +} + +function awaitingTarget(state: ComfortState, context: ComfortContext): boolean { + const application = state.application + return application?.outcome === 'acknowledged' && application.finishedAt !== null + && context.now >= application.finishedAt && context.now < application.finishedAt + LEASE_MS + && application.segment === context.window?.segment + && context.device.observedAt !== null && context.device.observedAt < application.finishedAt + && context.device.targetF === application.previousF +} + +/** + * The owner activation service must verify qualification and night evidence before admission. + * Proposals enter the shared authority; this class never calls a hardware client or powers a side on. + */ +export class ComfortApplicationAdapter { + private generations: Record = { left: 0, right: 0 } + private unexpectedTargets: Partial> = {} + private published = new Set() + + constructor(private readonly deps: ComfortApplicationDeps) {} + + /** Invalidate immediately; ordinary authority reconciliation restores the remaining owner. */ + revoke(side: Side): void { + this.generations[side]++ + } + + async reconcile(side: Side): Promise { + const generation = ++this.generations[side] + const candidate = await this.deps.withSideLock(side, async () => this.candidate(side, generation)) + // An inert Preview/withheld adapter must not trigger baseline reconciliation. + if (!candidate && !this.published.has(side)) return + // A newer evaluation or stop must never be overwritten by work queued before it. + const current = () => this.generations[side] === generation + const request: TemperatureRequest[] = candidate + ? [{ + id: candidate.id, source: 'autopilot', temperature: candidate.targetF, + startsAt: candidate.at, expiresAt: candidate.expiresAt, createdAt: candidate.createdAt, + priority: Number.MIN_SAFE_INTEGER, + }] + : [] + this.published.add(side) + await this.deps.authority.replacePolicy(side, PRODUCER, request, + () => candidate !== null && this.eligible(candidate), current, + candidate ? this.application(candidate) : undefined) + if (!candidate && current()) this.published.delete(side) + } + + private candidate(side: Side, generation: number): Candidate | null { + const now = this.deps.now() + let state = this.deps.store.read(side) + const pending = this.unexpectedTargets[side] + if (pending !== undefined) { + state = { ...state, pausedUntil: Math.max(state.pausedUntil, pending + MANUAL_PAUSE_MS) } + this.deps.store.commit(side, state, + comfortEvent(pending, 'manual_pause', 'An unexpected target requires a six-hour adaptive pause.'), true) + this.unexpectedTargets[side] = undefined + } + const admission = this.deps.admission(side, state) + if (state.config.mode !== 'active' || !this.deps.autopilotEnabled() || !admitted(admission, now) + || this.deps.pendingPause(side) !== null || state.config.followBedtime) return null + // Phase shifting remains Preview-only until its active mapping is independently qualified. + const context = this.deps.readContext(side, state, now) + const monotonic = this.deps.monotonicNow() + if (context.now !== now || !Number.isFinite(monotonic) || monotonic < 0) return null + const decision = decideComfort(state, context) + if (decision.pauseRequired) { + this.deps.store.commit(side, { ...state, pausedUntil: Math.max(state.pausedUntil, now + MANUAL_PAUSE_MS) }, + comfortEvent(now, 'manual_pause', decision.reason), true) + } + const waitingForReport = awaitingTarget(state, context) + && decideComfort({ ...state, lastAppliedAt: 0 }, context).eligible + const targetF = decision.retainPreviousLease || waitingForReport ? state.lastTargetF : decision.eligible ? decision.proposedF : null + if (targetF === null || !context.window || context.device.targetF === null) return null + if (targetF === context.device.targetF && (state.lastTargetF !== targetF || state.lastSegment !== context.window.segment)) return null + const same = state.application?.outcome === 'acknowledged' && state.application.targetF === targetF + && state.application.segment === context.window.segment + return { id: same && state.application ? state.application.requestId : randomUUID(), side, generation, + createdAt: same && state.application ? state.application.createdAt : now, + at: now, expiresAt: Math.min(now + LEASE_MS, admission.expiresAt, context.window.end, + state.config.roomAware ? context.bedroom?.expiresAt ?? now : Infinity, + waitingForReport ? (state.application?.finishedAt ?? now) + LEASE_MS : Infinity), + monotonicStart: monotonic, monotonicDeadline: monotonic + LEASE_MS, + admissionRevision: admission.revision, config: JSON.stringify(state.config), offsetF: state.offsetF, + segment: context.window.segment, targetF, previousF: context.device.targetF, applying: false } + } + + /** Re-read consent, persisted state and live inputs after both side-lock and DAC-queue waits. */ + private eligible(candidate: Candidate): boolean { + return this.leaseCurrent(candidate) && this.inputsEligible(candidate) && this.leaseCurrent(candidate) + } + + /** Input/database checks can block; recheck cheap clocks after them, immediately before dispatch. */ + private leaseCurrent(candidate: Candidate): boolean { + const now = this.deps.now() + const monotonic = this.deps.monotonicNow() + return this.generations[candidate.side] === candidate.generation && now >= candidate.at && now < candidate.expiresAt + && Number.isFinite(monotonic) && monotonic >= candidate.monotonicStart && monotonic < candidate.monotonicDeadline + } + + private inputsEligible(candidate: Candidate): boolean { + const { side } = candidate + const now = this.deps.now() + if (!this.deps.autopilotEnabled() + || this.deps.pendingPause(side) !== null || this.unexpectedTargets[side] !== undefined) return false + const state = this.deps.store.read(side) + const admission = this.deps.admission(side, state) + if (!admitted(admission, now) || admission.revision !== candidate.admissionRevision + || JSON.stringify(state.config) !== candidate.config || state.offsetF !== candidate.offsetF || state.lastAppliedAt > now) return false + const own = state.application?.requestId === candidate.id + if (state.application && state.application.outcome !== 'acknowledged' && !(own && candidate.applying)) return false + const input = this.deps.readContext(side, state, now) + const context = { ...input, device: { ...input.device } } + if (context.now !== now || context.window?.segment !== candidate.segment) return false + const application = state.application + // Firmware polling can still describe the pre-write target. This bounded + // lease can wait for a newer report; it never labels that old reading verified. + const awaitingReport = own && awaitingTarget(state, context) + if (candidate.applying || awaitingReport) { + if (context.device.targetF !== candidate.previousF && context.device.targetF !== candidate.targetF) return false + // Only this in-flight one-degree target is known; every other safety input stays current. + context.targetKnown = context.targetKnown || context.device.targetF === candidate.targetF + context.device.targetF = candidate.previousF + } + const decision = decideComfort({ ...state, application: candidate.applying ? undefined : application }, context) + if (decision.pauseRequired) { + // Selection also runs on status reads. Latch without writing outside the + // side lock; the next evaluation persists it before admitting any proposal. + this.unexpectedTargets[side] = now + return false + } + if (own && application?.outcome === 'acknowledged') { + // Revalidate every guard without consuming another correction interval for a keepalive. + const guarded = decideComfort({ ...state, lastAppliedAt: 0 }, context) + return guarded.eligible && (context.device.targetF === candidate.targetF || awaitingReport) + } + return (decision.eligible && decision.proposedF === candidate.targetF) + || (decision.retainPreviousLease && state.lastTargetF === candidate.targetF) + } + + private application(candidate: Candidate): PolicyApplication { + return async (input, execute) => { + if (!this.eligible(candidate) || input.targetF !== candidate.targetF + || input.requestId !== `policy:${PRODUCER}:${candidate.id}`) throw new ControlGuardError() + const { side } = candidate + const state = this.deps.store.read(side) + const context = this.deps.readContext(side, state, this.deps.now()) + if (awaitingTarget(state, context)) throw new ControlGuardError() + const previousF = context.device.targetF + if (previousF === null) throw new ControlGuardError() + const application: ComfortApplication = { requestId: candidate.id, segment: candidate.segment, + targetF: candidate.targetF, previousF, createdAt: candidate.createdAt, + reservedAt: this.deps.now(), finishedAt: null, receiptId: null, outcome: 'attempting' } + // Reserve before the shared attempt journal. A crash in either commit stays withheld after restart. + this.deps.store.commit(side, { ...state, application }, undefined, true) + candidate.applying = true + try { + const receipt = await execute() + const current = this.deps.store.read(side) + if (current.application?.requestId !== candidate.id) throw new Error('Comfort application reservation changed') + const acknowledged = receipt?.outcome === 'acknowledged' && receipt.finishedAt !== null + && receipt.attemptedAt >= application.reservedAt && receipt.finishedAt >= receipt.attemptedAt && receipt.finishedAt <= this.deps.now() + const finishedAt = receipt?.finishedAt !== null && receipt?.finishedAt !== undefined + && receipt.finishedAt >= application.reservedAt && receipt.finishedAt <= this.deps.now() + ? receipt.finishedAt + : null + // Keepalives retain the original correction clock; only a new target consumes a new step. + const changed = state.lastTargetF !== candidate.targetF || state.lastSegment !== candidate.segment + this.deps.store.commit(side, { ...current, + ...(acknowledged + ? { lastAppliedAt: changed ? receipt.finishedAt as number : state.lastAppliedAt, + lastTargetF: candidate.targetF, lastSegment: candidate.segment } + : {}), + application: { ...application, outcome: acknowledged ? 'acknowledged' : 'unknown', + receiptId: receipt?.attemptId ?? null, finishedAt } }, undefined, true) + } + catch (error) { + // Preserve the original failure. If this commit also fails, attempting is equally withholding. + try { + const current = this.deps.store.read(side) + if (current.application?.requestId === candidate.id) { + this.deps.store.commit(side, { ...current, application: { ...application, outcome: 'unknown' } }, undefined, true) + } + } + catch { /* The already durable reservation must not be cleared. */ } + throw error + } + finally { candidate.applying = false } + } + } +} diff --git a/src/comfort/bedtime.ts b/src/comfort/bedtime.ts new file mode 100644 index 000000000..5c8943ac4 --- /dev/null +++ b/src/comfort/bedtime.ts @@ -0,0 +1,120 @@ +import { z } from 'zod' +import type { ComfortContext, ComfortState, RoutineWindow } from './policy' + +const MINUTE = 60_000 +export const ENTRY_STABILITY_MS = 10 * MINUTE +export const ENTRY_MAX_GAP_MS = 90_000 +const timestamp = z.number().int().nonnegative().max(8_640_000_000_000_000) +const digest = z.string().regex(/^[a-f0-9]{64}$/) + +export const bedtimeSelectionSchema = z.object({ + session: z.string().min(1).max(500), revision: digest, sourceKey: digest, + entryAt: timestamp, confirmedAt: timestamp, + shiftMinutes: z.number().int().min(-30).max(30), +}).strict().refine(value => value.confirmedAt - value.entryAt >= ENTRY_STABILITY_MS, 'Bed entry requires ten observed minutes') +export type BedtimeSelection = z.infer + +export const bedtimeStatusSchema = z.object({ + state: z.enum(['off', 'waiting', 'ready', 'withheld']), + reason: z.string(), selection: bedtimeSelectionSchema.nullable(), + scheduledBaselineF: z.number().nullable(), phaseBaselineF: z.number().nullable(), +}) +export type BedtimeStatus = z.infer +export interface BedEntrySample { measuredAt: number, sourceKey: string, state: 'absent' | 'present' | 'unknown' } + +/** Observe an actual transition; repeated frames and time spent offline prove nothing. */ +export class BedEntryTracker { + private previous?: BedEntrySample + private checkedAt?: number + private entryAt?: number + + reset(): void { + this.previous = undefined + this.checkedAt = undefined + this.entryAt = undefined + } + + observe(sample: BedEntrySample | undefined, now: number): { entryAt: number, confirmedAt: number, sourceKey: string } | null { + if (!sample || !Number.isSafeInteger(now) || !Number.isSafeInteger(sample.measuredAt) + || sample.measuredAt > now || now - sample.measuredAt > ENTRY_MAX_GAP_MS + || !digest.safeParse(sample.sourceKey).success || sample.state === 'unknown') { + this.reset() + return null + } + if (this.checkedAt !== undefined && (now < this.checkedAt || now - this.checkedAt > ENTRY_MAX_GAP_MS)) this.reset() + this.checkedAt = now + const previous = this.previous + if (previous && (previous.sourceKey !== sample.sourceKey || sample.measuredAt < previous.measuredAt + || sample.measuredAt - previous.measuredAt > ENTRY_MAX_GAP_MS + || (sample.measuredAt === previous.measuredAt && sample.state !== previous.state))) { + this.reset() + // This frame becomes a new baseline, never an inferred entry. + this.checkedAt = now + } + if (sample.state === 'absent') this.entryAt = undefined + else if (this.previous?.state === 'absent' && sample.measuredAt > this.previous.measuredAt) this.entryAt = sample.measuredAt + this.previous = sample + return this.entryAt !== undefined && sample.measuredAt - this.entryAt >= ENTRY_STABILITY_MS + ? { entryAt: this.entryAt, confirmedAt: sample.measuredAt, sourceKey: sample.sourceKey } + : null + } +} + +/** Keep every intermediate phase inside its original power window, including short routines. */ +function shiftFor(window: RoutineWindow, entryAt: number): number | null { + const changes = window.phases?.changes + if (!changes?.length || Math.abs(entryAt - window.start) > 90 * MINUTE) return null + const earliest = Math.min(...changes.map(change => change.at)) + const latest = Math.max(...changes.map(change => change.at)) + const minimum = Math.max(-30, Math.ceil((window.start + 1 - earliest) / MINUTE)) + const maximum = Math.min(30, Math.floor((window.end - 1 - latest) / MINUTE)) + return minimum <= maximum ? Math.max(minimum, Math.min(maximum, Math.round((entryAt - window.start) / MINUTE))) : null +} + +/** Select once after guarded observation, then map only the routine’s intermediate phases. */ +export function bedtimePreview(state: ComfortState, context: ComfortContext, tracker: BedEntryTracker, maySelect: boolean): { + selection: BedtimeSelection | undefined + window: RoutineWindow | null + status: BedtimeStatus +} { + const { window, now } = context + const status = (phase: BedtimeStatus['state'], reason: string, selection: BedtimeSelection | null = null): BedtimeStatus => ({ + state: phase, reason, selection, scheduledBaselineF: window?.baselineF ?? null, phaseBaselineF: null, + }) + const result = (value: BedtimeStatus) => ({ selection: undefined, window, status: value }) + if (state.config.mode !== 'preview' || !state.config.followBedtime || !state.config.sensorAware) { + tracker.reset() + return result(status('off', 'Bed-entry timing Preview is off.')) + } + const mechanical = context.device.priming !== false || context.device.vibrating !== false || context.device.baseMoving !== false + const sample = mechanical || !context.presence.available ? undefined : context.presence.entry + const entry = tracker.observe(sample, now) + if (!window?.phases?.changes.length) return result(status('waiting', 'Waiting for a routine with intermediate temperature phases.')) + let selection = state.bedtime?.session === window.session ? state.bedtime : undefined + if (selection && (selection.revision !== window.phases.revision || (sample && selection.sourceKey !== sample.sourceKey))) { + return result(status('withheld', 'The routine or calibrated sensor changed after this night’s timing was selected. Keeping the original routine.', selection)) + } + if (!selection) { + if (!entry) return result(status('waiting', 'Waiting for observed empty-to-occupied entry followed by ten minutes of fresh presence.')) + const shiftMinutes = shiftFor(window, entry.entryAt) + if (shiftMinutes === null) return result(status('withheld', 'Observed bed entry is outside the 90-minute routine window. Keeping the original routine.')) + if (!maySelect) return result(status('waiting', 'Bed entry observed; waiting for all comfort and sensor guards before selecting timing.')) + selection = { ...entry, shiftMinutes, session: window.session, revision: window.phases.revision } + } + if (!bedtimeSelectionSchema.safeParse(selection).success || selection.confirmedAt > now + || shiftFor(window, selection.entryAt) !== selection.shiftMinutes) { + return result(status('withheld', 'Stored bed-entry timing is not valid for the current routine or clock.')) + } + if (!sample || !Number.isSafeInteger(sample.measuredAt) || sample.state !== 'present' || sample.measuredAt > now || now - sample.measuredAt > ENTRY_MAX_GAP_MS) { + return result(status('waiting', 'Waiting for fresh calibrated presence before using this night’s timing.', selection)) + } + const phase = window.phases.changes.filter(change => change.at + selection.shiftMinutes * MINUTE <= now) + .sort((first, second) => second.at - first.at || first.id.localeCompare(second.id))[0] + const baselineF = phase?.temperatureF ?? window.phases.startF + const mapped = { ...window, baselineF, scheduledBaselineF: window.baselineF, + segment: `${window.session}:bed-entry:${selection.shiftMinutes}:${phase?.id ?? 'start'}:${phase?.at ?? window.start}` } + return { + selection, window: mapped, + status: { ...status('ready', 'Preview shifts intermediate temperature phases only. Power and alarm times stay fixed.', selection), phaseBaselineF: baselineF }, + } +} diff --git a/src/comfort/contract.ts b/src/comfort/contract.ts new file mode 100644 index 000000000..a77ee0f4c --- /dev/null +++ b/src/comfort/contract.ts @@ -0,0 +1,97 @@ +import { previewEvidenceSchema } from './report-contract' +import { bedtimeSelectionSchema, bedtimeStatusSchema } from './bedtime' +import { z } from 'zod' +import { comfortApplicationSchema } from './application-contract' +import { comfortSignalsSchema } from './signals' +import { bedroomGuidanceSchema } from '@/src/bedroom/contract' +import { thermalContextSchema, thermalLearningSchema } from './thermal-learning' +import { thermalObservationSchema } from './thermal-observation' + +export const COMFORT_ACTIVATION_REASON = 'Hardware qualification and reviewed Preview nights are pending.' as const + +const timestamp = z.number().int().nonnegative().max(8_640_000_000_000_000) +const localDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(value => + Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value, +) + +export const comfortConfigSchema = z.object({ + mode: z.enum(['off', 'preview', 'active']), + minimumF: z.number().int().min(55).max(110), + maximumF: z.number().int().min(55).max(110), + sensorAware: z.boolean().optional(), + followBedtime: z.boolean().optional(), + roomAware: z.boolean().optional(), +}).strict().refine(config => config.minimumF <= config.maximumF, 'Minimum must not exceed maximum') + +export const comfortStateSchema = z.object({ + config: comfortConfigSchema, + offsetF: z.number().int().min(-3).max(3), + feedbackThrough: z.union([z.literal(''), localDate]), + learnedSession: z.string().max(500), + pausedUntil: timestamp, + lastAppliedAt: timestamp, + lastTargetF: z.number().min(55).max(110).nullable(), + lastSegment: z.string().max(1000), + bedtime: bedtimeSelectionSchema.optional(), + application: comfortApplicationSchema.optional(), +}).strict() + +export const comfortFeedbackSchema = z.object({ + date: localDate, + comfort: z.enum(['warm', 'cold', 'comfortable']), +}).strict() + +export const comfortDecisionSchema = z.object({ + eligible: z.boolean(), + baselineF: z.number().nullable(), + proposedF: z.number().nullable(), + reason: z.string(), + retainPreviousLease: z.boolean(), + pauseRequired: z.boolean(), +}) + +export const comfortEventSchema = z.object({ + at: timestamp, + kind: z.enum(['preview', 'active', 'configuration', 'feedback', 'manual_pause']), + session: z.string().nullable(), + message: z.string(), + proposedF: z.number().nullable(), + reportedF: z.number().nullable(), + eligible: z.boolean(), + deviceObservedAt: timestamp.nullable(), + presenceObservedAt: timestamp.nullable(), + owner: z.enum(['manual', 'run-once', 'autopilot', 'schedule']).nullable(), + signals: comfortSignalsSchema.nullable().optional(), + bedroom: bedroomGuidanceSchema.nullable().optional(), + timing: bedtimeStatusSchema.nullable().optional(), + preview: previewEvidenceSchema.optional(), + thermalContext: thermalContextSchema.nullable().optional(), + thermalObservation: thermalObservationSchema.optional(), +}) +export type ComfortEvent = z.infer + +export const comfortStatusSchema = z.object({ + apiVersion: z.literal(1), + generatedAt: timestamp, + side: z.enum(['left', 'right']), + timezone: z.string().nullable(), + deviceDate: localDate.nullable(), + state: comfortStateSchema.nullable(), + evaluatedAt: timestamp.nullable(), + decision: comfortDecisionSchema.nullable(), + fault: z.string().nullable(), + history: z.array(comfortEventSchema), + runtime: z.enum(['running', 'stopped']), + activation: z.object({ + // Legacy native clients require false: direct mode/configure activation stays unavailable. + available: z.literal(false), + current: z.boolean().optional(), + reason: z.string(), + }), + features: z.object({ roomAware: z.boolean().optional(), sensorAware: z.boolean(), followBedtime: z.boolean(), nightReports: z.boolean().optional(), modeOnly: z.boolean().optional(), ownerActivation: z.boolean().optional(), modelTraining: z.boolean().optional() }), + signals: comfortSignalsSchema.nullable().optional(), + bedroom: bedroomGuidanceSchema.nullable().optional(), + timing: bedtimeStatusSchema.nullable().optional(), + learning: thermalLearningSchema.nullable().optional(), +}) +export type ComfortStatus = z.infer diff --git a/src/comfort/event.ts b/src/comfort/event.ts new file mode 100644 index 000000000..9816122ae --- /dev/null +++ b/src/comfort/event.ts @@ -0,0 +1,6 @@ +import type { ComfortEvent } from './contract' + +export function comfortEvent(at: number, kind: ComfortEvent['kind'], message: string): ComfortEvent { + return { at, kind, message, session: null, proposedF: null, reportedF: null, eligible: false, + deviceObservedAt: null, presenceObservedAt: null, owner: null } +} diff --git a/src/comfort/hardware-identity.ts b/src/comfort/hardware-identity.ts new file mode 100644 index 000000000..d5050a466 --- /dev/null +++ b/src/comfort/hardware-identity.ts @@ -0,0 +1,16 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' + +/** Hash the embedded storage identity; development hosts never receive a random qualification identity. */ +export function readComfortHardwareIdentity(): string | null { + for (const path of ['/sys/block/mmcblk0/device/cid', '/sys/block/mmcblk0/device/serial']) { + try { + const value = readFileSync(/* turbopackIgnore: true */ path, 'utf8').trim() + if (/^(?:0x)?[a-f0-9]+$/i.test(value) && !/^(?:0x)?(?:0+|f+)$/i.test(value)) { + return createHash('sha256').update(JSON.stringify(['comfort-pod-v1', path, value])).digest('hex') + } + } + catch { /* Unavailable hardware cannot qualify. */ } + } + return null +} diff --git a/src/comfort/instance.ts b/src/comfort/instance.ts new file mode 100644 index 000000000..35465100d --- /dev/null +++ b/src/comfort/instance.ts @@ -0,0 +1,250 @@ +import { ComfortApplicationAdapter } from './application' +import { createComfortOwner } from './owner' +import { readComfortHardwareIdentity } from './hardware-identity' +import { ownerIdentitySchema } from './owner-contract' +import { createPreviewReports } from './reports' +import { readFileSync } from 'node:fs' +import type { PreviewOrigin } from './report-evidence' +import { and, eq } from 'drizzle-orm' +import { biometricsDb, db, sqlite } from '@/src/db' +import { alarmSchedules, deviceSettings, deviceState, powerSchedules, sideSettings, temperatureSchedules } from '@/src/db/schema' +import { getDacMonitorIfRunning } from '@/src/hardware/dacMonitor.instance' +import { getBaseControllerIfRunning } from '@/src/hardware/base/instance' +import { hasFirmwareSynced } from '@/src/hardware/sideMutations' +import { withSideLock } from '@/src/hardware/sideLock' +import type { Side } from '@/src/hardware/types' +import { getOccupancy, type OccupancyResult } from '@/src/lib/occupancy' +import { scheduleSourceSide } from '@/src/lib/singleSleeper' +import { readSleeperPresence } from '@/src/lib/sleeperPresence' +import { getTemperatureControllerIfRunning } from '@/src/temperature/instance' +import { readAlarmContext } from '@/src/alarms/store' +import { effectiveAlarmTargets, validAlarmOverride } from '@/src/alarms/model' +import { DEVICE_MAX_AGE_MS, type ComfortContext, type ComfortState, type RoutineWindow } from './policy' +import { acknowledgeManualPause, pendingManualPause } from './manual' +import { comfortRoutineAt, comfortScheduleRevision } from './routines' +import { ComfortRuntime } from './runtime' +import { createComfortStore } from './store' +import { ComfortSignalReader } from './signal-reader' +import { getBedroom } from '@/src/bedroom/instance' +import { thermalContextSchema } from './thermal-learning' +import { createThermalModels } from './thermal-model-store' +import { capturePreviewEvidence } from './report-evidence' +import { thermalObservation } from './thermal-observation' +import { thermalConfiguration } from './thermal-dataset' +import { createThermalForecasts } from './thermal-forecast-store' +import { previousForecastInput } from './thermal-forecast-input' + +export function getThermalModels() { + return createThermalModels(sqlite) +} + +export function getThermalForecasts() { + return createThermalForecasts(sqlite) +} + +/** Read a guarded current observation and its actual five-minute predecessor; never send a command. */ +export function previewWaterForecast(side: Side) { + const state = createComfortStore(sqlite).read(side), now = Date.now() + const { context, timezone } = readComfortContext(side, state, now, false) + const evidence = capturePreviewEvidence(state, context, context.window?.baselineF ?? null, timezone, + '00000000-0000-4000-8000-000000000001', previewOrigin()) + const receipt = thermalObservation(state, context, evidence) + if (!evidence || !receipt || !context.thermalContext) return { predictions: [], reason: 'Waiting for fresh, occupied, quiet pre-alarm bed and room readings.' } + const configuration = thermalConfiguration(evidence, receipt.bedroomConfiguration) + const row = sqlite.prepare(`SELECT observed_at AS at, event FROM comfort_events WHERE side = ? AND observed_at >= ? + AND observed_at <= ? AND json_extract(event, '$.thermalContext') IS NOT NULL ORDER BY observed_at DESC LIMIT 1`) + .get(side, now - 330_000, now - 270_000) as { at: number, event: string } | undefined + let previous: unknown = null + try { + previous = row ? JSON.parse(row.event) : null + } + catch { /* A malformed source cannot provide a preceding measurement. */ } + const input = previousForecastInput(previous, { at: now, session: evidence.session, configuration, context: context.thermalContext }, row?.at ?? null) + if (!input) return { predictions: [], reason: 'Waiting for an actual five-minute predecessor from this same quiet session and target.' } + return getThermalForecasts().preview(side, input, configuration) +} + +/** A model preview uses the same fresh, quiet, occupied thermal observations as collection. */ +export function previewThermalModel(side: Side) { + const state = createComfortStore(sqlite).read(side) + const now = Date.now() + const { context, timezone } = readComfortContext(side, state, now, false) + const evidence = capturePreviewEvidence(state, context, context.window?.baselineF ?? null, timezone, + '00000000-0000-4000-8000-000000000001', previewOrigin()) + const receipt = thermalObservation(state, context, evidence) + const configuration = evidence && receipt ? thermalConfiguration(evidence, receipt.bedroomConfiguration) : null + return getThermalModels().preview(side, receipt ? context.thermalContext ?? null : null, configuration) +} + +const globals = globalThis as typeof globalThis & { __sp_comfortRuntime?: ComfortRuntime, __sp_comfortSignals?: ComfortSignalReader, __sp_comfortOwner?: ReturnType, __sp_comfortApplication?: ComfortApplicationAdapter } +function signalsReader() { + return globals.__sp_comfortSignals ??= new ComfortSignalReader(biometricsDb.$client) +} + +const routineCache: Partial> = {} + +function observedTimestamp(value: number | null | undefined, now: number): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 && value <= now ? value : null +} + +function readComfortSchedules(side: Side) { + const settings = db.select().from(deviceSettings).get() + if (!settings) throw new Error('Device settings unavailable') + const sides = db.select().from(sideSettings).all() + const source = scheduleSourceSide(side, { ...settings, ...Object.fromEntries(sides.map(row => [row.side, row])) }) + const powers = source ? db.select().from(powerSchedules).where(and(eq(powerSchedules.side, source), eq(powerSchedules.enabled, true))).all() : [] + const targets = source ? db.select().from(temperatureSchedules).where(and(eq(temperatureSchedules.side, source), eq(temperatureSchedules.enabled, true))).all() : [] + const alarms = source ? db.select().from(alarmSchedules).where(and(eq(alarmSchedules.side, source), eq(alarmSchedules.enabled, true))).all() : [] + const timezone = settings.timezone + const schedules = { powers, targets, alarms, timezone, source: JSON.stringify([source, settings.bedMode, settings.unusedZoneMode]) } + return { settings, sides, source, timezone, schedules } +} + +/** Read existing sensors and arbitration only; status evaluation never opens a hardware connection. */ +export function readComfortContext(side: Side, state: ComfortState, now: number, includeArbitration = true): { context: ComfortContext, timezone: string } { + const { settings, sides, source, timezone, schedules } = readComfortSchedules(side) + const key = JSON.stringify([Math.floor(now / 60_000), schedules]) + const cached = routineCache[side] + const window = cached?.key === key ? cached.window : comfortRoutineAt(schedules, now) + routineCache[side] = { key, window } + const control = includeArbitration ? getTemperatureControllerIfRunning()?.status(side) : undefined + const monitor = getDacMonitorIfRunning() + const status = monitor?.getFreshStatus(DEVICE_MAX_AGE_MS) + const reported = side === 'left' ? status?.leftSide : status?.rightSide + const deviceObservedAt = status ? observedTimestamp(monitor?.getLastPollAt(), now) : null + const zones: Partial> = {} + const readZone = (zone: Side) => zones[zone] ??= getOccupancy(zone) + const presence = readSleeperPresence(side, { ...settings, ...Object.fromEntries(sides.map(row => [row.side, row])) }, now, readZone) + const states = db.select().from(deviceState).all() + const mirror = states.find(row => row.side === side) + const base = getBaseControllerIfRunning()?.status() + const baseFresh = base?.state === 'connected' && !base.stale && base.lastUpdate !== null + && observedTimestamp(base.lastUpdate, now) !== null && now - base.lastUpdate <= 10_000 + const alarmContext = source ? readAlarmContext(sqlite, source) : null + // The learner's weekly phases must not compete with a changed wake time on + // the affected night. A skip next week does not suppress this week's Preview. + const alarmChange = alarmContext ? validAlarmOverride(alarmContext, now) : null + const affectedRoutine = alarmChange && window && [alarmChange.originalAt, alarmChange.at].some(at => at !== null && at >= window.start && at <= window.end) + const alarmOverride = Boolean(affectedRoutine) || (alarmContext !== null && effectiveAlarmTargets(alarmContext, now).length > 0) + const targetF = typeof reported?.targetTemperature === 'number' && Number.isFinite(reported.targetTemperature) ? reported.targetTemperature : null + const room = state.config.roomAware ? getBedroom().status().aggregates : [] + const thermal = thermalContextSchema.safeParse({ targetF, waterF: reported?.currentTemperature, + roomF: room.find(metric => metric.metric === 'temperature' && metric.status === 'ready')?.value, + humidity: room.find(metric => metric.metric === 'humidity' && metric.status === 'ready')?.value }) + // Solo presence can come from the other half; that does not qualify this half's water reading as the sleeper's thermal exposure. + const thermalZone = state.config.roomAware ? readZone(side) : undefined + const thermalSampleAt = observedTimestamp(thermalZone?.level.sampleAt, now) + const thermalZoneOccupied = thermalZone?.available === true && thermalZone.classification === 'person' + && thermalZone.level.active === true && thermalSampleAt !== null && now - thermalSampleAt <= 30_000 + && /^[a-f0-9]{64}$/.test(thermalZone.level.sourceKey ?? '') + return { + timezone, + context: { + now, window, + device: { + observedAt: deviceObservedAt, powered: hasFirmwareSynced() && mirror?.isPowered === true && (reported?.heatingDuration ?? 0) > 0, + offConfirmed: hasFirmwareSynced() && mirror?.isPowered === false && reported?.heatingDuration === 0, + waterAdequate: status?.waterLevel === 'ok' ? true : status?.waterLevel === 'low' ? false : null, + priming: status?.isPriming !== false, + vibrating: states.length !== 2 || states.some(row => row.isAlarmVibrating), + baseMoving: baseFresh && typeof base?.moving === 'boolean' ? base.moving || base.busy : null, + targetF, + }, + presence, + eitherAway: sides.length !== 2 || sides.some(row => row.awayMode), + routinesPaused: source === null, + alarmOverride, + owner: control?.source ?? null, + targetKnown: targetF !== null && (targetF === window?.baselineF || targetF === control?.targetTemperature + || (state.lastSegment === window?.segment && state.lastTargetF === targetF && state.lastAppliedAt > 0 && state.lastAppliedAt <= now)), + signals: state.config.sensorAware ? signalsReader().read(side, timezone, now) : undefined, + bedroom: state.config.roomAware ? getBedroom().guidance() : undefined, + thermalContext: thermal.success && thermalZoneOccupied ? thermal.data : null, + }, + } +} + +function previewOrigin(): PreviewOrigin { + let buildRevision: string | null = null + try { + const manifest: unknown = JSON.parse(readFileSync('.git-info', 'utf-8')) + if (manifest && typeof manifest === 'object' && 'commitHash' in manifest + && typeof manifest.commitHash === 'string' && /^[a-f0-9]{7,40}$/.test(manifest.commitHash)) buildRevision = manifest.commitHash + } + catch { /* Missing build identity stays visible in the report. */ } + const environment = process.env.CI || process.env.NODE_ENV === 'test' + ? 'test' + : process.env.NODE_ENV === 'production' ? 'production' : 'development' + return { environment, buildRevision, hardwareIdentity: readComfortHardwareIdentity() } +} + +export function getComfortRuntime(): ComfortRuntime { + return globals.__sp_comfortRuntime ??= new ComfortRuntime({ + now: Date.now, origin: previewOrigin(), store: createComfortStore(sqlite), withSideLock, readContext: readComfortContext, + timezone: () => { + const settings = db.select({ timezone: deviceSettings.timezone }).from(deviceSettings).get() + if (!settings) throw new Error('Device timezone unavailable') + return settings.timezone + }, + prepareSignals: (side, now) => signalsReader().prepare(side, db.select().from(deviceSettings).get()?.timezone ?? 'UTC', now), + pendingPause: pendingManualPause, acknowledgePause: acknowledgeManualPause, + activation: { + admitted: (side, state) => getComfortOwner().admission(side, state) !== null, + revoke: (side, reason) => getComfortOwner().revoke(side, reason), + revokeLease: side => globals.__sp_comfortApplication?.revoke(side), + reconcile: async (side) => { + if (createComfortStore(sqlite).read(side).config.mode === 'active' || globals.__sp_comfortApplication) await getComfortApplication()?.reconcile(side) + }, + }, + }) +} + +export function getComfortRuntimeIfRunning(): ComfortRuntime | undefined { + return globals.__sp_comfortRuntime +} +export async function startComfortPreview(): Promise { + await getComfortRuntime().start() +} +export async function stopComfortPreview(): Promise { + await globals.__sp_comfortRuntime?.stop() +} + +/** Read/review endpoints do not start the Preview runtime or any hardware service. */ +export function getPreviewReports() { + return createPreviewReports(sqlite) +} + +function autopilotEnabled(): boolean { + return db.select({ enabled: deviceSettings.autopilotEnabled }).from(deviceSettings).get()?.enabled === true +} + +/** These readers use existing services and never open a hardware connection. */ +export function getComfortOwner() { + return globals.__sp_comfortOwner ??= createComfortOwner({ + database: sqlite, store: createComfortStore(sqlite), now: Date.now, withSideLock, autopilotEnabled, + running: () => globals.__sp_comfortRuntime?.isRunning() === true, + revokeLease: side => globals.__sp_comfortApplication?.revoke(side), + context: (side, state, now) => readComfortContext(side, state, now, false).context, + identity: (side, state) => { + const origin = previewOrigin() + const { settings, sides, schedules } = readComfortSchedules(side) + const calibration = readSleeperPresence(side, { ...settings, ...Object.fromEntries(sides.map(row => [row.side, row])) }, Date.now(), getOccupancy).entry?.sourceKey + const schedule = comfortScheduleRevision(schedules) + if (origin.environment !== 'production' || !origin.hardwareIdentity || !origin.buildRevision || !calibration || !schedule) return null + return ownerIdentitySchema.parse({ hardware: origin.hardwareIdentity, build: origin.buildRevision, calibration, schedule, + policy: { minimumF: state.config.minimumF, maximumF: state.config.maximumF, + sensorAware: state.config.sensorAware === true, followBedtime: state.config.followBedtime === true, roomAware: state.config.roomAware === true }, + bedroomConfiguration: state.config.roomAware ? getBedroom().configurationDigest() : null }) + }, + }) +} + +function getComfortApplication(): ComfortApplicationAdapter | undefined { + const authority = getTemperatureControllerIfRunning() + if (!authority) return undefined + return globals.__sp_comfortApplication ??= new ComfortApplicationAdapter({ + now: Date.now, monotonicNow: () => performance.now(), store: createComfortStore(sqlite), authority, withSideLock, + autopilotEnabled, admission: (side, state) => getComfortOwner().admission(side, state), pendingPause: pendingManualPause, + readContext: (side, state, now) => readComfortContext(side, state, now, false).context, + }) +} diff --git a/src/comfort/manual.ts b/src/comfort/manual.ts new file mode 100644 index 000000000..1cfb61573 --- /dev/null +++ b/src/comfort/manual.ts @@ -0,0 +1,31 @@ +import { sqlite } from '@/src/db' +import type { Side } from '@/src/hardware/types' +import { MANUAL_PAUSE_MS } from './policy' +import { comfortEvent, createComfortStore } from './store' + +const state = globalThis as typeof globalThis & { __sp_comfortPauseFaults?: Partial> } +const faults = state.__sp_comfortPauseFaults ??= {} + +/** Caller holds the side lock. A failed journal must never prevent manual shutdown. */ +export function pauseComfortAfterManual(side: Side): void { + const now = Date.now() + try { + const store = createComfortStore(sqlite) + const current = store.read(side) + store.commit(side, { ...current, pausedUntil: Math.max(current.pausedUntil, now + MANUAL_PAUSE_MS) }, + comfortEvent(now, 'manual_pause', 'Personal comfort paused for six hours after a manual control.'), true) + faults[side] = undefined + } + catch { + // Keep the latest failed intent in memory until it is durably recorded. + if (!faults[side]) console.warn(`[comfort] ${side} manual pause could not be persisted; personal comfort is withheld`) + faults[side] = now + } +} + +export function pendingManualPause(side: Side): number | null { + return faults[side] ?? null +} +export function acknowledgeManualPause(side: Side, at: number): void { + if (faults[side] === at) faults[side] = undefined +} diff --git a/src/comfort/owner-contract.ts b/src/comfort/owner-contract.ts new file mode 100644 index 000000000..fbebd26a3 --- /dev/null +++ b/src/comfort/owner-contract.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import { comfortApplicationSchema } from './application-contract' +import { previewSummarySchema, reportIdSchema } from './report-contract' + +const timestamp = z.number().int().positive().max(8_640_000_000_000_000) +export const qualificationKindSchema = z.enum(['presence_empty', 'presence_occupied', 'cooling', 'warming', 'manual_stop', 'offline_routine']) +export type QualificationKind = z.infer +export const qualificationLabels: Record = { + presence_empty: 'Empty-side presence', presence_occupied: 'Occupied-side presence', + cooling: 'Cooling response', warming: 'Warming response', manual_stop: 'Manual stop', offline_routine: 'Offline routine and wake alarm', +} +export const qualificationInstructions: Record = { + presence_empty: 'Leave this side empty. Confirm the calibrated reading agrees with what you see.', + presence_occupied: 'Occupy this side. Confirm the calibrated reading agrees with what you see; this does not establish sleep.', + cooling: 'Use the normal temperature controls, wait at least a minute, and confirm this side feels cooler without leaks or unusual noise. The warming test must use a target at least 2°F higher.', + warming: 'Use the normal temperature controls, wait at least a minute, and confirm this side feels warmer without leaks or unusual noise. Use a target at least 2°F above the cooling test.', + manual_stop: 'After testing both temperatures, switch this side off using the normal controls. Confirm heating/cooling stopped. The app also checks a fresh off report and the manual pause.', + offline_routine: 'Attend a scheduled temperature change and wake alarm with internet, the phone and home server unavailable. Confirm the routine, alarm and cancellation worked. The journal proves acknowledgments; only your observation confirms the outage and physical behavior.', +} + +export const ownerIdentitySchema = z.object({ + hardware: reportIdSchema, calibration: reportIdSchema, schedule: reportIdSchema, + build: z.string().regex(/^[a-f0-9]{7,40}$/), + bedroomConfiguration: reportIdSchema.nullable().optional(), + policy: z.object({ minimumF: z.number().int(), maximumF: z.number().int(), sensorAware: z.boolean(), followBedtime: z.boolean(), roomAware: z.boolean().optional() }).strict(), +}).strict() +export type OwnerIdentity = z.infer + +export const qualificationEvidenceSchema = z.object({ + identity: ownerIdentitySchema, observedAt: timestamp, presenceAt: timestamp.nullable(), + presence: z.enum(['occupied', 'empty', 'unavailable']), powered: z.boolean(), offConfirmed: z.boolean(), targetF: z.number().nullable(), + applicationId: z.string().uuid().nullable(), applicationAt: timestamp.nullable(), + alarmAt: timestamp.nullable(), pausedUntil: z.number().int().nonnegative(), +}).strict() +export const qualificationSchema = z.object({ + id: z.string().uuid(), kind: qualificationKindSchema, at: timestamp, + outcome: z.enum(['confirmed', 'needs_retest']), note: z.string().max(2000), evidence: qualificationEvidenceSchema, +}) +export type Qualification = z.infer +export const nightProofSchema = z.object({ summary: previewSummarySchema, reviewId: z.number().int().positive(), lastObservationId: z.number().int().positive(), eligibleMs: z.number().int().nonnegative() }) +export type NightProof = z.infer + +export const ownerSnapshotSchema = z.object({ + identity: ownerIdentitySchema, note: z.string().max(2000).optional(), + qualifications: z.array(qualificationSchema), nights: z.array(nightProofSchema), + evidence: qualificationEvidenceSchema.nullable(), application: comfortApplicationSchema.nullable(), +}).strict() +export type OwnerSnapshot = z.infer +export const ownerReviewSchema = z.object({ + id: z.string().uuid(), side: z.enum(['left', 'right']), at: timestamp, expiresAt: timestamp, + purpose: z.enum(['qualification', 'activation', 'resolution']), kind: qualificationKindSchema.nullable(), + snapshot: ownerSnapshotSchema, +}) +export type OwnerReview = z.infer +export const ownerStatusSchema = z.object({ + apiVersion: z.literal(1), generatedAt: timestamp, side: z.enum(['left', 'right']), + identity: ownerIdentitySchema.nullable(), qualifications: z.array(qualificationSchema), + nights: z.array(nightProofSchema), reasons: z.array(z.string()), + active: z.boolean(), canActivate: z.boolean(), application: comfortApplicationSchema.nullable(), +}) +export const ownerPrepareSchema = z.object({ + side: z.enum(['left', 'right']), purpose: z.enum(['qualification', 'activation', 'resolution']), + kind: qualificationKindSchema.nullable(), +}).strict() +export const ownerConfirmSchema = z.object({ + side: z.enum(['left', 'right']), id: z.string().uuid(), confirmed: z.literal(true), note: z.string().trim().max(2000), +}).strict() diff --git a/src/comfort/owner-evidence.ts b/src/comfort/owner-evidence.ts new file mode 100644 index 000000000..a16acd029 --- /dev/null +++ b/src/comfort/owner-evidence.ts @@ -0,0 +1,75 @@ +import type Database from 'better-sqlite3' +import type { Side } from '@/src/hardware/types' +import { temperatureApplicationSchema } from '@/src/temperature/applications' +import { DEVICE_MAX_AGE_MS, type ComfortContext, type ComfortState } from './policy' +import { PreviewReportError } from './report-summary' +import { qualificationEvidenceSchema, type OwnerIdentity, type Qualification, type QualificationKind } from './owner-contract' + +const DAY_MS = 86_400_000 +export function requireOwner(condition: unknown, message: string): asserts condition { + if (!condition) throw new PreviewReportError(message, 'PRECONDITION_FAILED') +} +function recent(value: number | null | undefined, now: number, age: number) { + return value !== null && value !== undefined && Number.isSafeInteger(value) && value > 0 && value <= now && now - value <= age +} + +/** Reads only. An acknowledgment is never translated into a claim that the owner felt the bed change. */ +export function readQualificationEvidence(database: Database.Database, side: Side, identity: OwnerIdentity, + state: ComfortState, context: ComfortContext, kind: QualificationKind | null, previous: Qualification[]) { + const { now, device, presence } = context + requireOwner(recent(device.observedAt, now, DEVICE_MAX_AGE_MS), 'A fresh firmware status is required.') + requireOwner(device.waterAdequate === true && !device.priming && !device.vibrating && device.baseMoving === false, + 'Wait for adequate water and confirmed idle priming, vibration and base telemetry.') + const calibrated = presence.available && recent(presence.entry?.measuredAt, now, 30_000) + && recent(presence.observedAt, now, 30_000) && presence.entry?.sourceKey === identity.calibration + const occupied = calibrated && presence.occupied && presence.entry?.state === 'present' + const empty = calibrated && !presence.occupied && presence.entry?.state === 'absent' + if (kind === 'presence_empty') requireOwner(empty, 'Waiting for a fresh calibrated empty-side reading.') + if (kind === 'presence_occupied') requireOwner(occupied, 'Waiting for a fresh calibrated occupied-side reading.') + let applicationId: string | null = null + let applicationAt: number | null = null + let alarmAt: number | null = null + if (kind === 'cooling' || kind === 'warming' || kind === 'offline_routine') { + const rows = database.prepare(`SELECT id, attempt_id AS attemptId, attempted_at AS attemptedAt, finished_at AS finishedAt, + outcome, request FROM temperature_applications WHERE side = ? AND attempted_at >= ? ORDER BY id DESC LIMIT 100`) + .all(side, now - DAY_MS) as { id: number, attemptId: string, attemptedAt: number, finishedAt: number | null, outcome: string, request: string }[] + const matching = rows.map(({ request, ...row }) => temperatureApplicationSchema.parse({ ...JSON.parse(request), ...row, + acknowledgment: row.outcome === 'acknowledged' ? 'accepted_not_physically_verified' : 'unconfirmed' })) + .find(row => row.outcome === 'acknowledged' && recent(row.finishedAt, now, kind === 'offline_routine' ? DAY_MS : 30 * 60_000) + && row.side === side && (kind === 'offline_routine' + ? row.source === 'schedule' + : row.source === 'manual' && row.targetF === device.targetF && now - (row.finishedAt ?? now) >= 60_000)) + requireOwner(matching, kind === 'offline_routine' + ? 'No acknowledged scheduled temperature application in the last 24 hours.' + : 'Use a manual temperature target, then wait at least a minute. Its acknowledgment must be within 30 minutes and match the fresh firmware target.') + applicationId = matching.attemptId + applicationAt = matching.finishedAt + if (kind !== 'offline_routine') { + requireOwner(device.powered && device.targetF !== null && device.targetF >= state.config.minimumF && device.targetF <= state.config.maximumF, + 'The powered side must report a target inside the reviewed comfort range.') + const other = previous.find(value => value.kind === (kind === 'warming' ? 'cooling' : 'warming') && value.outcome === 'confirmed') + if (other) requireOwner(other.evidence.targetF !== null && (kind === 'warming' + ? device.targetF >= other.evidence.targetF + 2 + : device.targetF <= other.evidence.targetF - 2), 'Use distinct cooling and warming targets at least 2°F apart.') + } + else { + const alarm = database.prepare(`SELECT attempted_at AS at FROM alarm_occurrences WHERE side = ? AND outcome = 'acknowledged' + AND attempted_at >= ? AND attempted_at <= ? ORDER BY attempted_at DESC LIMIT 1`).get(side, now - DAY_MS, now) as { at: number } | undefined + requireOwner(alarm, 'No acknowledged local wake alarm in the last 24 hours. Attend the offline wake/cancellation test first.') + alarmAt = alarm.at + } + } + if (kind === 'manual_stop') { + requireOwner(!device.powered && device.offConfirmed === true && state.pausedUntil > now, 'Switch this side off using the normal manual control and wait for its fresh firmware report and six-hour pause.') + requireOwner(['cooling', 'warming'].every(value => previous.some(record => record.kind === value && record.outcome === 'confirmed' + && recent(record.at, now, DAY_MS))), 'Complete both attended temperature checks within the last 24 hours before recording Stop.') + } + return qualificationEvidenceSchema.parse({ identity, observedAt: device.observedAt, presenceAt: presence.entry?.measuredAt ?? null, + presence: occupied ? 'occupied' : empty ? 'empty' : 'unavailable', powered: device.powered, offConfirmed: device.offConfirmed === true, targetF: device.targetF, + applicationId, applicationAt, alarmAt, pausedUntil: state.pausedUntil }) +} + +/** Poll times may advance during review; the actual evidence references and meaningful state may not. */ +export function evidenceMeaning(value: ReturnType) { + return JSON.stringify({ ...value, observedAt: undefined, presenceAt: undefined }) +} diff --git a/src/comfort/owner.ts b/src/comfort/owner.ts new file mode 100644 index 000000000..6f4b6335f --- /dev/null +++ b/src/comfort/owner.ts @@ -0,0 +1,221 @@ +import { randomUUID } from 'node:crypto' +import type Database from 'better-sqlite3' +import type { Side } from '@/src/hardware/types' +import { durableTransaction } from '@/src/db/durable' +import type { ComfortAdmission } from './application' +import type { ComfortContext, ComfortState } from './policy' +import { MANUAL_PAUSE_MS } from './policy' +import { comfortEvent, type ComfortStore } from './store' +import { createPreviewReports } from './reports' +import { evidenceMeaning, readQualificationEvidence, requireOwner } from './owner-evidence' +import { + ownerConfirmSchema, ownerIdentitySchema, ownerPrepareSchema, ownerReviewSchema, ownerSnapshotSchema, ownerStatusSchema, + qualificationKindSchema, qualificationLabels, qualificationSchema, + type OwnerIdentity, type OwnerSnapshot, type Qualification, type QualificationKind, +} from './owner-contract' +import type { z } from 'zod' + +export interface ComfortOwnerDeps { + database: Database.Database + store: ComfortStore + now(): number + identity(side: Side, state: ComfortState): OwnerIdentity | null + context(side: Side, state: ComfortState, now: number): ComfortContext + autopilotEnabled(): boolean + running(): boolean + withSideLock(side: Side, operation: () => Promise): Promise + revokeLease(side: Side): void +} +const same = (first: unknown, second: unknown) => JSON.stringify(first) === JSON.stringify(second) +interface GrantRow { id: string, at: number, snapshot: string } + +/** Only authenticated owner routes call this service. Client input cannot supply evidence or a night count. */ +export function createComfortOwner(deps: ComfortOwnerDeps) { + const { database, store } = deps + const reports = createPreviewReports(database, deps.now) + function now() { + const value = deps.now() + requireOwner(Number.isSafeInteger(value) && value > 0, 'The controller clock is unavailable.') + return value + } + function identity(side: Side, state: ComfortState) { + const value = deps.identity(side, state) + return value ? ownerIdentitySchema.parse(value) : null + } + function qualifications(side: Side): Qualification[] { + return qualificationKindSchema.options.flatMap((kind) => { + const row = database.prepare('SELECT record FROM comfort_qualifications WHERE side = ? AND kind = ? ORDER BY sequence DESC LIMIT 1') + .get(side, kind) as { record: string } | undefined + return row ? [qualificationSchema.parse(JSON.parse(row.record))] : [] + }) + } + function matchingQualifications(side: Side, current: OwnerIdentity) { + return qualifications(side).filter(record => record.at <= now() && same(record.evidence.identity, current)) + } + function revoke(side: Side, reason: string) { + deps.revokeLease(side) + durableTransaction(database, () => { + database.prepare('UPDATE comfort_activations SET revoked_at = ?, reason = ? WHERE side = ? AND revoked_at IS NULL').run(now(), reason, side) + }) + } + function assertReferences(side: Side, snapshot: OwnerSnapshot, source: boolean) { + const current = matchingQualifications(side, snapshot.identity) + requireOwner(snapshot.qualifications.length === qualificationKindSchema.options.length + && qualificationKindSchema.options.every(kind => snapshot.qualifications.some(record => record.kind === kind + && record.outcome === 'confirmed' && current.some(value => value.id === record.id && value.outcome === 'confirmed'))), + 'Attended qualification changed. Repeat the affected check before activation.') + const cooling = snapshot.qualifications.find(record => record.kind === 'cooling')?.evidence.targetF + const warming = snapshot.qualifications.find(record => record.kind === 'warming')?.evidence.targetF + requireOwner(cooling !== null && cooling !== undefined && warming !== null && warming !== undefined && warming >= cooling + 2, + 'Cooling and warming qualifications need distinct targets at least 2°F apart.') + requireOwner(snapshot.nights.length === 7, 'Seven matching, source-backed, owner-reviewed Preview nights are required.') + reports.assertProofsCurrent(side, snapshot.nights, source) + } + function validGrant(side: Side, state: ComfortState) { + try { + const at = now() + if (state.config.mode !== 'active' || !deps.running() || !deps.autopilotEnabled()) return null + const row = database.prepare(`SELECT id, created_at AS at, snapshot FROM comfort_activations + WHERE side = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(side) as GrantRow | undefined + if (!row || row.at > at) return null + const snapshot = ownerSnapshotSchema.parse(JSON.parse(row.snapshot)) + if (!same(identity(side, state), snapshot.identity) || snapshot.identity.policy.followBedtime) return null + assertReferences(side, snapshot, false) + return { row, snapshot, at } + } + catch { return null } + } + function admission(side: Side, state: ComfortState): ComfortAdmission | null { + const grant = validGrant(side, state) + return grant ? { revision: grant.row.id, issuedAt: grant.row.at, expiresAt: grant.at + 10_000 } : null + } + async function status(side: Side) { + const at = now() + const state = store.read(side) + const current = identity(side, state) + const recorded = qualifications(side) + const matching = current ? matchingQualifications(side, current) : [] + const grant = validGrant(side, state) + const nights = grant?.snapshot.nights ?? (current ? await reports.activationProofs(side, current) : []) + const reasons: string[] = [] + if (!current) reasons.push('Production Pod identity, calibrated presence, build and enabled routine identity are required.') + if (!deps.running()) reasons.push('The personal comfort service is stopped.') + if (!deps.autopilotEnabled()) reasons.push('Global Autopilot is off. Review its existing rules before enabling it; activation will not change that switch.') + if (state.config.followBedtime) reasons.push('Bed-entry phase shifting remains Preview-only. Disable it and collect matching nights before thermal activation.') + if (state.config.mode !== 'preview' && state.config.mode !== 'active') reasons.push('Start Preview before preparing activation.') + for (const kind of current ? qualificationKindSchema.options : []) { + if (!matching.some(record => record.kind === kind && record.outcome === 'confirmed')) reasons.push(`${qualificationLabels[kind]} needs attended confirmation for this configuration.`) + } + if (nights.length < 7) reasons.push(`${nights.length} of 7 matching reviewed nights. Each requires four hours of eligible observation intervals on this Pod, build, calibration, routine and policy.`) + if (state.application && state.application.outcome !== 'acknowledged') reasons.push('An unconfirmed command needs owner review before further adjustment.') + const active = admission(side, state) !== null + return ownerStatusSchema.parse({ apiVersion: 1, generatedAt: at, side, identity: current, qualifications: recorded, + nights, reasons, active, canActivate: !active && state.config.mode === 'preview' && reasons.length === 0, application: state.application ?? null }) + } + async function prepare(input: z.infer) { + const request = ownerPrepareSchema.parse(input) + // Night parsing yields; no side lock is held while inspecting a month of history. + const inspection = request.purpose === 'activation' ? await status(request.side) : null + if (inspection) requireOwner(inspection.canActivate, inspection.reasons.join(' ') || 'Activation is not available.') + return deps.withSideLock(request.side, async () => { + const at = now() + const state = store.read(request.side) + const current = identity(request.side, state) + requireOwner(current, 'Production Pod identity, calibrated presence, build and routine are required.') + requireOwner(deps.running(), 'The personal comfort service is stopped.') + const recorded = matchingQualifications(request.side, current) + requireOwner((request.purpose === 'qualification') === (request.kind !== null), 'Select a check only for qualification reviews.') + requireOwner(request.purpose !== 'qualification' || state.config.mode !== 'active', 'Return to Preview before changing qualification.') + requireOwner(request.purpose !== 'resolution' || (state.application && state.application.outcome !== 'acknowledged'), 'There is no unconfirmed comfort application to resolve.') + const evidence = request.purpose !== 'activation' + ? readQualificationEvidence(database, request.side, current, state, deps.context(request.side, state, at), request.kind, recorded) + : null + const snapshot: OwnerSnapshot = { identity: current, qualifications: recorded, nights: inspection?.nights ?? [], evidence, application: state.application ?? null } + if (inspection) { + requireOwner(same(current, inspection.identity) && state.config.mode === 'preview' && deps.autopilotEnabled(), 'Settings changed during inspection.') + assertReferences(request.side, snapshot, true) + } + const review = ownerReviewSchema.parse({ id: randomUUID(), side: request.side, at, expiresAt: at + 60_000, + purpose: request.purpose, kind: request.kind, snapshot }) + database.transaction(() => { + database.prepare('DELETE FROM comfort_owner_reviews WHERE expires_at < ?').run(at - 86_400_000) + database.prepare('INSERT INTO comfort_owner_reviews (id, side, created_at, expires_at, review) VALUES (?, ?, ?, ?, ?)') + .run(review.id, request.side, at, review.expiresAt, JSON.stringify(review)) + })() + return review + }) + } + async function confirm(input: z.infer) { + const request = ownerConfirmSchema.parse(input) + return deps.withSideLock(request.side, async () => { + // Invalidating the current generation cannot issue a command or clear an uncertain result. + deps.revokeLease(request.side) + durableTransaction(database, () => { + const at = now() + const row = database.prepare('SELECT review, consumed_at AS consumedAt FROM comfort_owner_reviews WHERE id = ? AND side = ?') + .get(request.id, request.side) as { review: string, consumedAt: number | null } | undefined + requireOwner(row, 'Review not found for this side. Prepare a fresh review.') + if (row.consumedAt !== null) return // Lost response retry cannot create another grant or clear a later command. + const review = ownerReviewSchema.parse(JSON.parse(row.review)) + requireOwner(at >= review.at && at < review.expiresAt, 'Review expired or the clock changed. Refresh its evidence.') + const state = store.read(request.side) + requireOwner(same(identity(request.side, state), review.snapshot.identity) && deps.running(), 'Controller identity, settings or runtime changed during review.') + if (review.purpose === 'activation') { + requireOwner(state.config.mode === 'preview' && deps.autopilotEnabled() && !state.config.followBedtime, + 'Activation requires Preview, global consent and an unshifted routine.') + requireOwner(!state.application || state.application.outcome === 'acknowledged', 'Resolve the unconfirmed application first.') + assertReferences(request.side, review.snapshot, true) + database.prepare('UPDATE comfort_activations SET revoked_at = ?, reason = ? WHERE side = ? AND revoked_at IS NULL') + .run(at, 'Replaced by owner review', request.side) + database.prepare('INSERT INTO comfort_activations (id, side, created_at, snapshot) VALUES (?, ?, ?, ?)') + .run(review.id, request.side, at, JSON.stringify({ ...review.snapshot, note: request.note })) + store.commit(request.side, { ...state, config: { ...state.config, mode: 'active' } }, + comfortEvent(at, 'configuration', 'Owner activated bounded thermal comfort after qualification and seven reviewed Preview nights.')) + } + else { + const current = readQualificationEvidence(database, request.side, review.snapshot.identity, state, + deps.context(request.side, state, at), review.kind, matchingQualifications(request.side, review.snapshot.identity)) + requireOwner(review.snapshot.evidence && evidenceMeaning(current) === evidenceMeaning(review.snapshot.evidence), + 'The reported state or command evidence changed. Prepare a new review.') + if (review.purpose === 'qualification') { + requireOwner(state.config.mode !== 'active' && review.kind, 'Return to Preview before qualifying hardware.') + const record = qualificationSchema.parse({ id: review.id, kind: review.kind, at, + outcome: 'confirmed', note: request.note, evidence: review.snapshot.evidence }) + database.prepare('INSERT INTO comfort_qualifications (id, side, kind, record) VALUES (?, ?, ?, ?)') + .run(record.id, request.side, record.kind, JSON.stringify(record)) + } + else { + requireOwner(state.application && state.application.outcome !== 'acknowledged' && same(state.application, review.snapshot.application), + 'The unconfirmed application changed. Review it again.') + database.prepare('INSERT INTO comfort_resolutions (id, side, created_at, snapshot, note) VALUES (?, ?, ?, ?, ?)') + .run(review.id, request.side, at, JSON.stringify(review.snapshot), request.note) + store.commit(request.side, { ...state, application: undefined, config: { ...state.config, mode: 'preview' }, + pausedUntil: Math.max(state.pausedUntil, at + MANUAL_PAUSE_MS), lastAppliedAt: 0, lastTargetF: null, lastSegment: '' }, + comfortEvent(at, 'manual_pause', 'Owner archived an unconfirmed application. Returned to Preview with a six-hour pause; the command was not replayed or marked successful.')) + } + database.prepare('UPDATE comfort_activations SET revoked_at = ?, reason = ? WHERE side = ? AND revoked_at IS NULL') + .run(at, 'Qualification or uncertainty review changed', request.side) + } + database.prepare('UPDATE comfort_owner_reviews SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL').run(at, review.id) + }) + return { saved: true as const } + }) + } + async function retest(side: Side, kind: QualificationKind, expectedId: string) { + return deps.withSideLock(side, async () => { + deps.revokeLease(side) + durableTransaction(database, () => { + const previous = qualifications(side).find(record => record.kind === kind) + requireOwner(previous?.id === expectedId, 'Qualification changed. Refresh before marking a retest.') + const record: Qualification = { ...previous, id: randomUUID(), at: now(), outcome: 'needs_retest' } + database.prepare('INSERT INTO comfort_qualifications (id, side, kind, record) VALUES (?, ?, ?, ?)').run(record.id, side, kind, JSON.stringify(record)) + database.prepare('UPDATE comfort_activations SET revoked_at = ?, reason = ? WHERE side = ? AND revoked_at IS NULL').run(now(), 'Owner requested qualification retest', side) + const state = store.read(side) + if (state.config.mode === 'active') store.commit(side, { ...state, config: { ...state.config, mode: 'preview' } }, + comfortEvent(now(), 'configuration', 'Returned to Preview for an attended qualification retest.')) + }) + return { saved: true as const } + }) + } + return { status, prepare, confirm, retest, admission, revoke } +} diff --git a/src/comfort/policy.ts b/src/comfort/policy.ts new file mode 100644 index 000000000..ae4973c81 --- /dev/null +++ b/src/comfort/policy.ts @@ -0,0 +1,235 @@ +import type { BedEntrySample, BedtimeSelection, BedtimeStatus } from './bedtime' +import type { ComfortSignals } from './signals' +import type { ComfortApplication } from './application-contract' +import type { BedroomComfortGuidance } from '@/src/bedroom/contract' +import { currentBedroomGuidance } from '@/src/bedroom/fusion' +import type { ThermalContext } from './thermal-learning' + +/** Personal comfort policy ported from the owner's Free Sleep thermal/policy.ts. + * Pure decisions only: persistence and hardware belong to the shared controller. + */ +export const MANUAL_PAUSE_MS = 6 * 60 * 60_000 +export const ADJUSTMENT_INTERVAL_MS = 30 * 60_000 +export const DEVICE_MAX_AGE_MS = 15_000 +export const PRESENCE_MAX_AGE_MS = 90_000 +export const VITAL_MAX_AGE_MS = 360_000 + +export interface ComfortConfig { + mode: 'off' | 'preview' | 'active' + minimumF: number + maximumF: number + sensorAware?: boolean + followBedtime?: boolean + roomAware?: boolean +} + +/** Field names preserve the Free Sleep learner's migration cursors. Times are epoch milliseconds. */ +export interface ComfortState { + config: ComfortConfig + offsetF: number + feedbackThrough: string + learnedSession: string + pausedUntil: number + lastAppliedAt: number + lastTargetF: number | null + lastSegment: string + bedtime?: BedtimeSelection + application?: ComfortApplication +} + +/** The schedule adapter must resolve one unambiguous, timezone-aware power window. */ +export interface RoutineWindow { + session: string + segment: string + start: number + end: number + baselineF: number + alarmAt: number | null + scheduledBaselineF?: number + phases?: { revision: string, startF: number, changes: { id: string, at: number, temperatureF: number }[] } +} + +export interface ComfortFeedback { + date: string + comfort: 'warm' | 'cold' | 'comfortable' +} + +export interface ComfortContext { + now: number + window: RoutineWindow | null + device: { + observedAt: number | null + powered: boolean + offConfirmed?: boolean + waterAdequate: boolean | null + priming: boolean + vibrating: boolean + baseMoving: boolean | null + targetF: number | null + } + presence: { observedAt: number | null, available: boolean, occupied: boolean, entry?: BedEntrySample } + eitherAway: boolean + routinesPaused: boolean + alarmOverride: boolean + owner: 'manual' | 'run-once' | 'autopilot' | 'schedule' | null + /** True only after comparing reported target with the routine or our own recorded acknowledgment. */ + targetKnown: boolean + signals?: ComfortSignals + bedroom?: BedroomComfortGuidance + timing?: BedtimeStatus + thermalContext?: ThermalContext | null +} + +export interface ComfortDecision { + eligible: boolean + baselineF: number | null + proposedF: number | null + reason: string + /** Only cooldown may retain the last guarded target; missing input must revoke it. */ + retainPreviousLease: boolean + /** The runtime must persist the six-hour pause before publishing another proposal. */ + pauseRequired: boolean +} + +export function defaultComfortState(): ComfortState { + return { + config: { mode: 'off', minimumF: 70, maximumF: 95 }, + offsetF: 0, feedbackThrough: '', learnedSession: '', pausedUntil: 0, + lastAppliedAt: 0, lastTargetF: null, lastSegment: '', + } +} + +function fresh(observedAt: number | null, now: number, maximumAge: number): boolean { + return observedAt !== null && Number.isFinite(observedAt) + && now >= observedAt && now - observedAt <= maximumAge +} + +function validState(state: ComfortState): boolean { + const { config } = state + return ['off', 'preview', 'active'].includes(config.mode) + && (config.sensorAware === undefined || typeof config.sensorAware === 'boolean') + && (config.followBedtime === undefined || typeof config.followBedtime === 'boolean') + && (config.roomAware === undefined || typeof config.roomAware === 'boolean') + && Number.isInteger(config.minimumF) && Number.isInteger(config.maximumF) + && config.minimumF >= 55 && config.maximumF <= 110 && config.minimumF <= config.maximumF + && Number.isInteger(state.offsetF) && Math.abs(state.offsetF) <= 3 + && Number.isFinite(state.pausedUntil) && state.pausedUntil >= 0 + && Number.isFinite(state.lastAppliedAt) && state.lastAppliedAt >= 0 +} + +/** All inputs are observations, never instructions from an LLM or a room sensor. */ +export function decideComfort(state: ComfortState, context: ComfortContext): ComfortDecision { + const { now, window, device, presence } = context + const baselineF = window && Number.isFinite(window.baselineF) ? window.baselineF : null + const stop = (reason: string, pauseRequired = false): ComfortDecision => ({ + eligible: false, baselineF, proposedF: null, reason, retainPreviousLease: false, pauseRequired, + }) + if (!Number.isFinite(now) || !validState(state)) return stop('Comfort settings or clock are invalid.') + if (state.config.mode === 'off') return stop('Adaptive comfort is off.') + if (state.config.mode === 'active' && state.application && state.application.outcome !== 'acknowledged') { + return stop('A previous comfort command is unconfirmed. Review its application evidence before enabling further adjustments.') + } + if (state.pausedUntil > now) return stop('Paused for six hours after a manual control. Your routine continues.') + if (context.owner === 'manual' || context.owner === 'run-once') return stop('Your manual control or run-once routine has priority.') + if (context.eitherAway !== false) return stop('Paused while either side is in away mode.') + if (!window || !Number.isFinite(window.start) || !Number.isFinite(window.end) + || window.start > now || window.end <= now || baselineF === null) { + return stop('Waiting for one enabled routine with a clear start and end.') + } + if (baselineF < state.config.minimumF || baselineF > state.config.maximumF) return stop('Routine target is outside your comfort limits.') + if (context.routinesPaused !== false) return stop('Temperature routines are paused.') + if (context.alarmOverride !== false) return stop('An alarm override is active.') + if (window.alarmAt !== null && (!Number.isFinite(window.alarmAt) || now >= window.alarmAt - 30 * 60_000)) { + return stop('Leaving the alarm window to your routine.') + } + if (!fresh(device.observedAt, now, DEVICE_MAX_AGE_MS)) return stop('Waiting for fresh Pod status.') + if (device.baseMoving === null) return stop('Waiting for confirmed idle base telemetry. Unconfigured does not mean absent.') + if (device.priming !== false || device.vibrating !== false || device.baseMoving !== false) { + return stop('Paused during priming, vibration or base movement.') + } + if (device.waterAdequate !== true) return stop('Waiting for a confirmed adequate water level.') + if (device.powered !== true) return stop('Your side is off. Adaptive comfort never turns it on.') + if (presence.available !== true || !fresh(presence.observedAt, now, PRESENCE_MAX_AGE_MS)) return stop('Waiting for fresh calibrated presence readings.') + if (presence.occupied !== true) return stop('Waiting until your side is occupied.') + if (device.targetF === null || !Number.isFinite(device.targetF) || device.targetF < 55 || device.targetF > 110) return stop('Waiting for a reported temperature target.') + if (context.targetKnown !== true) return stop('An unexpected target change requires a six-hour adaptive pause.', true) + if (state.config.sensorAware) { + const signals = context.signals + if (!signals || signals.state !== 'ready' || !fresh(signals.sampleAt, now, VITAL_MAX_AGE_MS) || signals.expiresAt === null || !Number.isSafeInteger(signals.expiresAt) || now >= signals.expiresAt) { + return stop(signals?.state === 'ready' + ? 'Pod vital estimates expired before this check.' + : signals?.reason ?? 'Waiting for Pod vital estimates and a personal baseline.') + } + } + if (state.config.followBedtime && (!state.config.sensorAware || context.timing?.state !== 'ready')) { + return stop(context.timing?.reason ?? 'Waiting for qualified bed-entry timing.') + } + if (state.config.roomAware && !currentBedroomGuidance(context.bedroom, now)) { + return stop(context.bedroom?.state === 'ready' + ? 'Bedroom readings expired before this check.' + : context.bedroom?.reason ?? 'Waiting for qualified bedroom temperature and humidity.') + } + const scheduledBaselineF = window.scheduledBaselineF ?? baselineF + if (!Number.isFinite(scheduledBaselineF) || scheduledBaselineF < state.config.minimumF || scheduledBaselineF > state.config.maximumF) return stop('Original routine target is outside your comfort limits.') + // Room context shares the learner's total three-degree budget and existing command authority. + const offsetF = Math.max(-3, Math.min(3, state.offsetF + (state.config.roomAware ? context.bedroom?.offsetF ?? 0 : 0))) + const desiredF = Math.max(state.config.minimumF, Math.min(state.config.maximumF, baselineF + offsetF)) + const proposedF = Math.max(device.targetF - 1, Math.min(device.targetF + 1, desiredF)) + if (proposedF < state.config.minimumF || proposedF > state.config.maximumF || Math.abs(proposedF - scheduledBaselineF) > 3) { + return stop('Waiting for the routine target to enter your Autopilot limits.') + } + if (state.lastAppliedAt && (state.lastAppliedAt > now || now - state.lastAppliedAt < ADJUSTMENT_INTERVAL_MS)) { + return { + ...stop('Waiting between adjustments (30 minutes).'), proposedF, + retainPreviousLease: state.config.mode === 'active' && state.lastAppliedAt <= now + && state.lastSegment === window.segment && state.lastTargetF === device.targetF, + } + } + return { + eligible: true, baselineF, proposedF, retainPreviousLease: false, pauseRequired: false, + reason: state.config.mode === 'preview' + ? 'Preview only. Personal comfort will not change temperatures.' + : state.config.roomAware + ? `Following your routine and learned comfort. ${context.bedroom?.reason ?? ''}` + : 'Following your routine with the learned comfort adjustment.', + } +} + +function validDate(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value) + && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value +} + +export function comfortLocalDate(now: number, timezone: string): string { + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now) + const values = Object.fromEntries(parts.map(part => [part.type, part.value])) + return `${values.year}-${values.month}-${values.day}` +} + +/** Consume each local-date check-in once, with at most one degree of learning per routine. */ +export function learnComfort(state: ComfortState, feedback: ComfortFeedback[], session: string, timezone: string, now: number) { + if (!validState(state) || !Number.isFinite(now) || !session || state.config.mode === 'off' + || state.learnedSession === session || (state.feedbackThrough !== '' && !validDate(state.feedbackThrough))) { + return { state, message: null } + } + let today: string + try { + today = comfortLocalDate(now, timezone) + } + catch { return { state, message: null } } + const since = new Date(Date.parse(today) - 14 * 86_400_000).toISOString().slice(0, 10) + const reports = [...new Map(feedback.filter(report => validDate(report.date) + && report.date > state.feedbackThrough && report.date >= since && report.date <= today + && ['warm', 'cold', 'comfortable'].includes(report.comfort)).map(report => [report.date, report])).values()] + .sort((first, second) => first.date.localeCompare(second.date)) + const next = { ...state, learnedSession: session } + const latestReport = reports.at(-1) + if (reports.length < 3 || !latestReport) return { state: next, message: null } + const threshold = Math.ceil(reports.length * 2 / 3) + const warm = reports.filter(report => report.comfort === 'warm').length + const cold = reports.filter(report => report.comfort === 'cold').length + const change = warm >= threshold ? -1 : cold >= threshold ? 1 : 0 + next.offsetF = Math.max(-3, Math.min(3, state.offsetF + change)) + next.feedbackThrough = latestReport.date + return { state: next, message: `Reviewed ${reports.length} new check-ins: ${warm} warm, ${cold} cold. Learned adjustment ${next.offsetF > 0 ? '+' : ''}${next.offsetF}°F.` } +} diff --git a/src/comfort/report-contract.ts b/src/comfort/report-contract.ts new file mode 100644 index 000000000..a47224ea0 --- /dev/null +++ b/src/comfort/report-contract.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' +import { bedroomGuidanceSchema } from '@/src/bedroom/contract' + +export const reportIdSchema = z.string().regex(/^[a-f0-9]{64}$/) +const timestamp = z.number().int().nonnegative().max(8_640_000_000_000_000) +const timezone = z.string().max(100).refine((value) => { + try { + new Intl.DateTimeFormat('en', { timeZone: value }) + return true + } + catch { return false } +}, 'Unknown timezone') + +/** Captured by the runtime, never accepted as a client-supplied observation. */ +export const previewEvidenceSchema = z.object({ + format: z.literal(1), runId: z.string().uuid(), + environment: z.enum(['production', 'test', 'development', 'unknown']), + buildRevision: z.string().regex(/^[a-f0-9]{7,40}$/).nullable(), + hardwareIdentity: reportIdSchema.nullable().optional(), + session: z.string().min(1).max(500), start: timestamp, end: timestamp, timezone, + scheduleRevision: reportIdSchema, + bedroom: bedroomGuidanceSchema.nullable().optional(), + policy: z.object({ minimumF: z.number().min(55).max(110), maximumF: z.number().min(55).max(110), + sensorAware: z.boolean(), followBedtime: z.boolean(), roomAware: z.boolean().optional(), offsetF: z.number().int().min(-3).max(3) }).strict(), + scheduledBaselineF: z.number().min(55).max(110), + decisionBaselineF: z.number().min(55).max(110).nullable(), + presence: z.enum(['occupied', 'empty', 'unavailable']), + presenceSourceKey: reportIdSchema.nullable(), mechanicallyQuiet: z.boolean(), + deviceFresh: z.boolean(), sensorReady: z.boolean(), manualPaused: z.boolean(), +}).strict().refine(value => value.end > value.start && value.end - value.start <= 26 * 3_600_000, 'Invalid routine window') +export type PreviewEvidence = z.infer + +const metadataSchema = z.object({ session: previewEvidenceSchema.shape.session, start: timestamp, end: timestamp, timezone }) + .refine(value => value.end > value.start && value.end - value.start <= 26 * 3_600_000, 'Invalid routine window') +const count = z.number().int().nonnegative() +export const previewSummarySchema = z.object({ + id: reportIdSchema, digest: reportIdSchema, metadata: metadataSchema, + localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + state: z.enum(['collecting', 'closed', 'source_expired']), + firstAt: timestamp, lastAt: timestamp, observationCount: count, + occupiedObservationMs: count, minimumOccupiedObservationMs: z.literal(14_400_000), + occupiedCount: count, eligibleCount: count, sensorReadyCount: count, deviceFreshCount: count, manualPauseCount: count, + proposedRangeF: z.object({ minimum: z.number(), maximum: z.number() }).nullable(), + reportedRangeF: z.object({ minimum: z.number(), maximum: z.number() }).nullable(), + reasons: z.array(z.object({ message: z.string(), count })), + gaps: z.array(z.object({ from: timestamp, to: timestamp })), gapCount: count, + configurations: count, runs: count, builds: z.array(z.string()), + issues: z.array(z.string()), reviewable: z.boolean(), +}) +export type PreviewSummary = z.infer + +export const previewReviewInputSchema = z.object({ + side: z.enum(['left', 'right']), id: reportIdSchema, digest: reportIdSchema, + expectedReviewId: z.number().int().positive().nullable(), + outcome: z.enum(['reviewed', 'needs_retest']), confirmedActualNight: z.boolean(), + note: z.string().trim().max(2000), +}).strict().refine(value => value.outcome !== 'reviewed' || value.confirmedActualNight, 'Confirm this was an actual overnight Preview session') +export type PreviewReviewInput = z.infer +export const previewReviewSchema = z.object({ + id: z.number().int().positive(), at: timestamp, digest: reportIdSchema, + outcome: z.enum(['reviewed', 'needs_retest']), confirmedActualNight: z.boolean(), note: z.string(), +}) +export type PreviewReview = z.infer +export const previewReportItemSchema = z.object({ + summary: previewSummarySchema, review: previewReviewSchema.nullable(), + reviewCurrent: z.boolean(), countsAsOwnerConfirmedNight: z.boolean(), +}) +export type PreviewReportItem = z.infer +export const previewReportListSchema = z.object({ + apiVersion: z.literal(1), generatedAt: timestamp, side: z.enum(['left', 'right']), + items: z.array(previewReportItemSchema), targetNights: z.literal(7), ownerConfirmedNights: count, + activationAvailable: z.literal(false), +}) +export const previewObservationSchema = z.object({ + id: z.number().int().positive(), at: timestamp, eligible: z.boolean(), + proposedF: z.number().nullable(), reportedF: z.number().nullable(), message: z.string(), + evidence: previewEvidenceSchema, +}) +export const previewReportDetailSchema = previewReportItemSchema.extend({ + observations: z.array(previewObservationSchema), nextAfterId: z.number().int().positive().nullable(), +}) diff --git a/src/comfort/report-evidence.ts b/src/comfort/report-evidence.ts new file mode 100644 index 000000000..18e6cc514 --- /dev/null +++ b/src/comfort/report-evidence.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto' +import type { ComfortContext, ComfortState } from './policy' +import { DEVICE_MAX_AGE_MS, PRESENCE_MAX_AGE_MS, VITAL_MAX_AGE_MS } from './policy' +import { previewEvidenceSchema, type PreviewEvidence } from './report-contract' + +export type PreviewOrigin = Pick +const CALIBRATED_FRAME_MAX_AGE_MS = 30_000 + +/** Side is selected by the authenticated route/store; session identity contains no credentials. */ +export function previewReportId(evidence: Pick): string { + return createHash('sha256').update(JSON.stringify([evidence.session, evidence.start, evidence.end, evidence.timezone])).digest('hex') +} + +function fresh(observedAt: number | null, now: number, maximumAge: number): boolean { + return observedAt !== null && Number.isSafeInteger(observedAt) && observedAt <= now && now - observedAt <= maximumAge +} + +/** Preserve what this evaluation actually saw; no elapsed sleep or physical success is inferred. */ +export function capturePreviewEvidence(state: ComfortState, raw: ComfortContext, decisionBaselineF: number | null, + timezone: string, runId: string, origin: PreviewOrigin): PreviewEvidence | undefined { + const { window, now, presence, signals } = raw + if (state.config.mode !== 'preview' || !window?.phases || window.start > now || window.end <= now) return undefined + const calibrated = presence.available && fresh(presence.observedAt, now, PRESENCE_MAX_AGE_MS) + const entry = presence.entry + const sourceMatches = calibrated && entry && fresh(entry.measuredAt, now, CALIBRATED_FRAME_MAX_AGE_MS) + && entry.state === (presence.occupied ? 'present' : 'absent') + const parsed = previewEvidenceSchema.safeParse({ + format: 1, runId, ...origin, session: window.session, start: window.start, end: window.end, timezone, + scheduleRevision: window.phases.revision, + policy: { minimumF: state.config.minimumF, maximumF: state.config.maximumF, sensorAware: state.config.sensorAware === true, + followBedtime: state.config.followBedtime === true, roomAware: state.config.roomAware === true, offsetF: state.offsetF }, + bedroom: state.config.roomAware ? raw.bedroom ?? null : null, + scheduledBaselineF: window.baselineF, decisionBaselineF, + presence: calibrated ? presence.occupied ? 'occupied' : 'empty' : 'unavailable', + presenceSourceKey: sourceMatches ? entry.sourceKey : null, + mechanicallyQuiet: raw.device.baseMoving === false && raw.device.priming === false && raw.device.vibrating === false, + deviceFresh: fresh(raw.device.observedAt, now, DEVICE_MAX_AGE_MS), + sensorReady: signals?.state === 'ready' && fresh(signals.sampleAt, now, VITAL_MAX_AGE_MS) + && signals.expiresAt !== null && signals.expiresAt > now, + manualPaused: state.pausedUntil > now || raw.owner === 'manual' || raw.owner === 'run-once', + }) + return parsed.success ? parsed.data : undefined +} diff --git a/src/comfort/report-summary.ts b/src/comfort/report-summary.ts new file mode 100644 index 000000000..0dce17b01 --- /dev/null +++ b/src/comfort/report-summary.ts @@ -0,0 +1,95 @@ +import { createHash } from 'node:crypto' +import { setImmediate } from 'node:timers/promises' +import { comfortEventSchema } from './contract' +import { comfortLocalDate } from './policy' +import { previewReportId } from './report-evidence' +import { previewSummarySchema, type PreviewSummary, type PreviewEvidence } from './report-contract' + +export const MIN_OCCUPIED_OBSERVATION_MS = 14_400_000 +export const MAX_REPORT_ROWS = 20_000 +export const REPORT_RETENTION_MS = 30 * 86_400_000 +export interface PreviewSourceRow { id: number, at: number, event: string } +export class PreviewReportError extends Error { + constructor(message: string, readonly code: 'NOT_FOUND' | 'CONFLICT' | 'PRECONDITION_FAILED') { super(message) } +} + +export function reportDigest(rows: PreviewSourceRow[]): string { + const hash = createHash('sha256') + for (const row of rows) hash.update(JSON.stringify([row.id, row.at, row.event])) + return hash.digest('hex') +} +function range(values: number[]) { + return values.length ? { minimum: Math.min(...values), maximum: Math.max(...values) } : null +} +function qualified(value: PreviewEvidence) { + return value.presence === 'occupied' && value.deviceFresh && value.mechanicallyQuiet && value.presenceSourceKey !== null +} + +/** Counts recorded observations, not inferred sleep minutes or continuous sensor coverage. */ +export async function summarizePreview(id: string, rows: PreviewSourceRow[], now: number) { + if (!rows.length) throw new PreviewReportError('Preview report source is no longer retained.', 'NOT_FOUND') + if (rows.length > MAX_REPORT_ROWS) throw new PreviewReportError('Preview report exceeds the inspection limit. Export and investigate its journal before review.', 'PRECONDITION_FAILED') + const observations = [] + const issues = new Set() + const configs = new Set() + const runs = new Set() + const builds = new Set() + const reasons = new Map() + const gaps: PreviewSummary['gaps'] = [] + let previous: (PreviewSourceRow & { evidence: PreviewEvidence }) | undefined + let occupiedObservationMs = 0 + let occupiedCount = 0 + let sensorReadyCount = 0 + let deviceFreshCount = 0 + let manualPauseCount = 0 + for (const [index, row] of rows.entries()) { + const event = comfortEventSchema.parse(JSON.parse(row.event)) + const evidence = event.preview + if (!evidence || event.kind !== 'preview' || event.session !== evidence.session || previewReportId(evidence) !== id + || event.at !== row.at || row.at < evidence.start || row.at >= evidence.end || row.at > now) { + throw new PreviewReportError('Preview observations have inconsistent identity or timestamps.', 'PRECONDITION_FAILED') + } + if (evidence.environment !== 'production') issues.add('Some observations came from a test, development or unidentified runtime.') + if (!evidence.buildRevision) issues.add('Some observations have no recorded build identity.') + else builds.add(evidence.buildRevision) + if (previous && row.at <= previous.at) issues.add('The observation clock repeated or moved backward; inspect before counting this night.') + if (previous && row.at - previous.at > 90_000) gaps.push({ from: previous.at, to: row.at }) + if (previous && row.at > previous.at && row.at - previous.at <= 90_000 + && evidence.runId === previous.evidence.runId && evidence.presenceSourceKey === previous.evidence.presenceSourceKey + && qualified(evidence) && qualified(previous.evidence)) occupiedObservationMs += row.at - previous.at + previous = { ...row, evidence } + configs.add(JSON.stringify([evidence.scheduleRevision, evidence.policy, evidence.bedroom?.configuration ?? null])) + runs.add(evidence.runId) + if (evidence.presence === 'occupied') occupiedCount++ + if (evidence.sensorReady) sensorReadyCount++ + if (evidence.deviceFresh) deviceFreshCount++ + if (evidence.manualPaused) manualPauseCount++ + reasons.set(event.message, (reasons.get(event.message) ?? 0) + 1) + observations.push({ id: row.id, at: row.at, eligible: event.eligible, proposedF: event.proposedF, + reportedF: event.reportedF, message: event.message, evidence }) + // Journal reads must yield so a larger report does not monopolize the Pod event loop. + if (index % 256 === 255) await setImmediate() + } + const first = observations[0] + const last = observations.at(-1) + if (!last) throw new PreviewReportError('Preview report is empty.', 'NOT_FOUND') + const { session, start, end, timezone } = first.evidence + const metadata = { session, start, end, timezone } + if (first.at - start > 90_000) gaps.unshift({ from: start, to: first.at }) + if (Math.min(now, end) - last.at > 90_000) gaps.push({ from: last.at, to: Math.min(now, end) }) + if (first.at < now - REPORT_RETENTION_MS) issues.add('Some source observations exceed the journal retention window.') + if (occupiedObservationMs < MIN_OCCUPIED_OBSERVATION_MS) issues.add('Fewer than four hours of fresh, mechanically quiet occupied-side observation intervals were recorded.') + if (observations.length < 2 || first.at === last.at || occupiedCount === 0) issues.add('No repeated occupied-side observations are available for owner confirmation.') + const summary = previewSummarySchema.parse({ + id, digest: reportDigest(rows), metadata, localDate: comfortLocalDate(start, timezone), + state: now < end ? 'collecting' : 'closed', firstAt: first.at, lastAt: last.at, + observationCount: observations.length, occupiedObservationMs, minimumOccupiedObservationMs: MIN_OCCUPIED_OBSERVATION_MS, occupiedCount, sensorReadyCount, deviceFreshCount, manualPauseCount, + eligibleCount: observations.filter(event => event.eligible).length, + proposedRangeF: range(observations.flatMap(event => event.proposedF === null ? [] : [event.proposedF])), + reportedRangeF: range(observations.flatMap(event => event.reportedF === null ? [] : [event.reportedF])), + reasons: [...reasons].map(([message, count]) => ({ message, count })).sort((first, second) => second.count - first.count).slice(0, 20), + gaps: gaps.slice(0, 20), gapCount: gaps.length, configurations: configs.size, runs: runs.size, builds: [...builds].sort(), + issues: [...issues], reviewable: now >= end && issues.size === 0, + }) + return { summary, observations } +} diff --git a/src/comfort/reports.ts b/src/comfort/reports.ts new file mode 100644 index 000000000..fc6a8f03e --- /dev/null +++ b/src/comfort/reports.ts @@ -0,0 +1,169 @@ +import type Database from 'better-sqlite3' +import type { NightProof, OwnerIdentity } from './owner-contract' +import type { Side } from '@/src/hardware/types' +import { currentBedroomGuidance } from '@/src/bedroom/fusion' +import { + previewReportDetailSchema, previewReportListSchema, previewReviewInputSchema, previewReviewSchema, + previewSummarySchema, reportIdSchema, type PreviewReportItem, type PreviewReview, type PreviewReviewInput, +} from './report-contract' +import { MAX_REPORT_ROWS, PreviewReportError, reportDigest, summarizePreview, type PreviewSourceRow } from './report-summary' + +const MAX_REVIEWS_PER_SIDE = 1000 +const REVIEW_RETENTION_MS = 180 * 86_400_000 +interface ReviewRow { id: number, at: number, review: string, snapshot: string } + +/** Owner review never changes settings, grants an activation lease or asserts sleep duration. */ +export function createPreviewReports(database: Database.Database, clock = Date.now) { + function now() { + const at = clock() + if (!Number.isSafeInteger(at) || at <= 0) throw new Error('Preview report clock is unavailable') + return at + } + function source(side: Side, id: string): PreviewSourceRow[] { + reportIdSchema.parse(id) + return database.prepare(`SELECT id, observed_at AS at, event FROM comfort_events + WHERE side = ? AND report_id = ? ORDER BY id LIMIT ?`).all(side, id, MAX_REPORT_ROWS + 1) as PreviewSourceRow[] + } + function latestReview(side: Side, id: string): ReviewRow | undefined { + return database.prepare(`SELECT id, created_at AS at, review, snapshot FROM comfort_reviews + WHERE side = ? AND report_id = ? ORDER BY id DESC LIMIT 1`) + .get(side, id) as ReviewRow | undefined + } + function decodeReview(row: ReviewRow | undefined): PreviewReview | null { + return row ? previewReviewSchema.parse({ ...JSON.parse(row.review), id: row.id, at: row.at }) : null + } + async function load(side: Side, id: string, generatedAt: number) { + const rows = source(side, id) + const saved = latestReview(side, id) + const review = decodeReview(saved) + if (review && review.at > generatedAt) throw new PreviewReportError('The saved review is ahead of the current clock.', 'PRECONDITION_FAILED') + const result = rows.length + ? await summarizePreview(id, rows, generatedAt) + : saved + ? { summary: { ...previewSummarySchema.parse(JSON.parse(saved.snapshot)), state: 'source_expired' as const, reviewable: false, + issues: ['Original journal source is no longer retained. This saved review cannot count toward current night evidence.'] }, observations: [] } + : null + if (!result) throw new PreviewReportError('Preview report not found for this side.', 'NOT_FOUND') + const reviewCurrent = result.summary.state === 'closed' && review?.digest === result.summary.digest + return { ...result, review, reviewCurrent, + countsAsOwnerConfirmedNight: result.summary.reviewable && reviewCurrent + && review?.outcome === 'reviewed' && review.confirmedActualNight === true } + } + + return { + /** Activation requires four hours of eligible intervals per reviewed night on this exact configuration. */ + async activationProofs(side: Side, identity: OwnerIdentity): Promise { + const generatedAt = now() + const ids = database.prepare(`SELECT report_id AS id FROM comfort_events WHERE side = ? AND report_id IS NOT NULL + GROUP BY report_id ORDER BY MAX(observed_at) DESC LIMIT 30`).all(side) as { id: string }[] + const proofs: NightProof[] = [] + for (const { id } of ids) { + const result = await load(side, id, generatedAt) + if (!result.countsAsOwnerConfirmedNight || !result.review) continue + let eligibleMs = 0 + const matches = (observation: typeof result.observations[number]) => { + const value = observation.evidence + return observation.eligible && value.hardwareIdentity === identity.hardware && value.buildRevision === identity.build + && value.scheduleRevision === identity.schedule && value.presenceSourceKey === identity.calibration + && value.policy.minimumF === identity.policy.minimumF && value.policy.maximumF === identity.policy.maximumF + && value.policy.sensorAware === identity.policy.sensorAware && value.policy.followBedtime === identity.policy.followBedtime + && value.presence === 'occupied' && value.deviceFresh && value.mechanicallyQuiet && !value.manualPaused + && (!identity.policy.sensorAware || value.sensorReady) + && (value.policy.roomAware === true) === (identity.policy.roomAware === true) + && (!identity.policy.roomAware || (identity.bedroomConfiguration != null + && value.bedroom?.configuration === identity.bedroomConfiguration + && currentBedroomGuidance(value.bedroom, observation.at))) + } + for (let index = 1; index < result.observations.length; index++) { + const previous = result.observations[index - 1] + const current = result.observations[index] + const gap = current.at - previous.at + if (gap > 0 && gap <= 90_000 && current.evidence.runId === previous.evidence.runId + && matches(previous) && matches(current)) eligibleMs += gap + } + const last = result.observations.at(-1) + if (eligibleMs >= 14_400_000 && last) proofs.push({ summary: result.summary, reviewId: result.review.id, lastObservationId: last.id, eligibleMs }) + } + const accepted: NightProof[] = [] + for (const proof of proofs.sort((first, second) => first.summary.metadata.start - second.summary.metadata.start)) { + if (!accepted.some(previous => previous.summary.localDate === proof.summary.localDate + || (previous.summary.metadata.start < proof.summary.metadata.end && previous.summary.metadata.end > proof.summary.metadata.start))) accepted.push(proof) + } + return accepted.slice(-7) + }, + assertProofsCurrent(side: Side, proofs: NightProof[], includeSource: boolean) { + for (const proof of proofs) { + const review = decodeReview(latestReview(side, proof.summary.id)) + if (review?.id !== proof.reviewId || review.at > now() || review.outcome !== 'reviewed' || !review.confirmedActualNight + || review.digest !== proof.summary.digest) throw new PreviewReportError('A night review changed. Review activation again.', 'CONFLICT') + if (includeSource && reportDigest(source(side, proof.summary.id)) !== proof.summary.digest) { + throw new PreviewReportError('Night source changed during activation review.', 'CONFLICT') + } + const appended = database.prepare('SELECT 1 FROM comfort_events WHERE side = ? AND report_id = ? AND id > ? LIMIT 1').get(side, proof.summary.id, proof.lastObservationId) + if (appended) throw new PreviewReportError('New observations were added to a reviewed night.', 'CONFLICT') + } + }, + async list(side: Side) { + const generatedAt = now() + const ids = database.prepare(`SELECT report_id AS id, MAX(at) AS latest FROM ( + SELECT report_id, observed_at AS at FROM comfort_events WHERE side = ? AND report_id IS NOT NULL + UNION ALL SELECT report_id, created_at AS at FROM comfort_reviews WHERE side = ? AND created_at >= ? + ) GROUP BY report_id ORDER BY latest DESC, report_id LIMIT 30`) + .all(side, side, generatedAt - REVIEW_RETENTION_MS) as { id: string }[] + const items: PreviewReportItem[] = [] + for (const { id } of ids) { + const { summary, review, reviewCurrent, countsAsOwnerConfirmedNight } = await load(side, id, generatedAt) + items.push({ summary, review, reviewCurrent, countsAsOwnerConfirmedNight }) + } + // A timezone or routine edit cannot turn overlapping observations into two nights. + const confirmed = items.filter(item => item.countsAsOwnerConfirmedNight).sort((first, second) => first.summary.metadata.start - second.summary.metadata.start) + const dates = new Set() + let coveredUntil = 0 + let ownerConfirmedNights = 0 + for (const item of confirmed) { + if (item.summary.metadata.start >= coveredUntil && !dates.has(item.summary.localDate)) ownerConfirmedNights++ + dates.add(item.summary.localDate) + coveredUntil = Math.max(coveredUntil, item.summary.metadata.end) + } + return previewReportListSchema.parse({ apiVersion: 1, generatedAt, side, items, + targetNights: 7, ownerConfirmedNights, activationAvailable: false }) + }, + + async detail(side: Side, id: string, afterId = 0) { + if (!Number.isSafeInteger(afterId) || afterId < 0) throw new PreviewReportError('Invalid observation cursor.', 'PRECONDITION_FAILED') + const result = await load(side, id, now()) + const page = result.observations.filter(observation => observation.id > afterId).slice(0, 201) + return previewReportDetailSchema.parse({ ...result, observations: page.slice(0, 200), + nextAfterId: page.length > 200 ? page[199].id : null }) + }, + + async review(input: PreviewReviewInput) { + const request = previewReviewInputSchema.parse(input) + const result = await load(request.side, request.id, now()) + if (result.summary.digest !== request.digest) throw new PreviewReportError('The report changed. Refresh and review its current observations.', 'CONFLICT') + if (result.summary.state !== 'closed' || (request.outcome === 'reviewed' && !result.summary.reviewable)) { + throw new PreviewReportError('This report is unfinished or lacks evidence for night confirmation. Inspect its issues first.', 'PRECONDITION_FAILED') + } + const saved = database.transaction(() => { + const at = now() + // Recheck the exact raw rows after yielding during parsing, before accepting a review. + if (reportDigest(source(request.side, request.id)) !== request.digest) throw new PreviewReportError('The journal changed during review. Refresh the report.', 'CONFLICT') + if (at < result.summary.metadata.end || at < result.summary.lastAt) throw new PreviewReportError('The report clock changed during review.', 'PRECONDITION_FAILED') + const previous = decodeReview(latestReview(request.side, request.id)) + const value = { digest: request.digest, outcome: request.outcome, confirmedActualNight: request.confirmedActualNight, note: request.note } + if (previous?.digest === value.digest && previous.outcome === value.outcome + && previous.confirmedActualNight === value.confirmedActualNight && previous.note === value.note) return previous + if ((previous?.id ?? null) !== request.expectedReviewId) throw new PreviewReportError('Another review was saved. Refresh before replacing its conclusion.', 'CONFLICT') + const insert = database.prepare('INSERT INTO comfort_reviews (side, report_id, created_at, review, snapshot) VALUES (?, ?, ?, ?, ?)') + .run(request.side, request.id, at, JSON.stringify(value), JSON.stringify(result.summary)) + const protectedReviews = `SELECT json_extract(night.value, '$.reviewId') FROM comfort_activations, json_each(comfort_activations.snapshot, '$.nights') AS night WHERE revoked_at IS NULL` + database.prepare(`DELETE FROM comfort_reviews WHERE created_at < ? AND id NOT IN (${protectedReviews})`).run(at - REVIEW_RETENTION_MS) + database.prepare(`DELETE FROM comfort_reviews WHERE side = ? AND id NOT IN + (SELECT id FROM comfort_reviews WHERE side = ? ORDER BY id DESC LIMIT ?) AND id NOT IN (${protectedReviews})`) + .run(request.side, request.side, MAX_REVIEWS_PER_SIDE) + return previewReviewSchema.parse({ ...value, id: Number(insert.lastInsertRowid), at }) + })() + return saved + }, + } +} diff --git a/src/comfort/routines.ts b/src/comfort/routines.ts new file mode 100644 index 000000000..0097fa771 --- /dev/null +++ b/src/comfort/routines.ts @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto' +import { DAYS_OF_WEEK, powerOffDay, type DayOfWeek } from '@/src/lib/scheduleTime' +import { expandOccurrences, type OccurrenceSource } from '@/src/scheduler/occurrences' +import type { RoutineWindow } from './policy' + +interface PowerRow { id: number, dayOfWeek: DayOfWeek, onTime: string, offTime: string, offDayOffset?: number, onTemperature: number } +interface TargetRow { id: number, dayOfWeek: DayOfWeek, time: string, temperature: number } +interface AlarmRow { id: number, dayOfWeek: DayOfWeek, time: string } +export interface ComfortSchedules { powers: PowerRow[], targets: TargetRow[], alarms: AlarmRow[], timezone: string, source?: string } +const DAY_MS = 86_400_000 +const clockPattern = /^([01]\d|2[0-3]):[0-5]\d$/ + +function job(id: string, type: string, day: DayOfWeek, time: string, temperature?: number): OccurrenceSource { + if (!clockPattern.test(time) || !DAYS_OF_WEEK.includes(day)) throw new Error('Invalid comfort schedule') + const [hour, minute] = time.split(':').map(Number) + return { id, type, schedule: `${minute} ${hour} * * ${DAYS_OF_WEEK.indexOf(day)}`, metadata: { targetTemperature: temperature } } +} + +/** Use the scheduler's actual weekday/DST events, never invent a next-day OFF. */ +export function comfortRoutineAt(schedules: ComfortSchedules, now: number): RoutineWindow | null { + if (!Number.isSafeInteger(now)) return null + const jobs: OccurrenceSource[] = [] + try { + jobs.push(...scheduleJobs(schedules)) + const events = expandOccurrences(jobs, new Date(now - 2 * DAY_MS), new Date(now + 2 * DAY_MS), schedules.timezone) + const candidates = events.filter(event => event.type === 'start' && event.at <= now).flatMap((start) => { + const end = events.find(event => event.type === 'end' && event.at > start.at) + if (!end || end.at <= now || end.at - start.at > 26 * 3_600_000) return [] + return [{ start, end }] + }) + // Overlapping starts have no unambiguous personal-learning session. + if (candidates.length !== 1) return null + const { start, end } = candidates[0] + const targets = events.filter(event => (event.type === 'target' || event.type === 'start') + && event.at >= start.at && event.at <= now) + .sort((first, second) => second.at - first.at || first.id.localeCompare(second.id)) + const target = targets[0] + if (!target || target.targetTempF === null || target.targetTempF < 55 || target.targetTempF > 110) return null + const alarm = events.find(event => event.type === 'alarm' && event.at > start.at && event.at <= end.at) + const session = `${start.id}:${start.at}` + const revision = comfortScheduleRevision(schedules) + if (!revision) return null + const changes: { id: string, at: number, temperatureF: number }[] = [] + for (const event of events.filter(event => event.type === 'target' && event.at > start.at && event.at < end.at)) { + if (event.targetTempF === null || !Number.isFinite(event.targetTempF) || event.targetTempF < 55 || event.targetTempF > 110) return null + changes.push({ id: event.id, at: event.at, temperatureF: event.targetTempF }) + } + if (start.targetTempF === null) return null + return { phases: { revision, startF: start.targetTempF, changes }, session, segment: `${session}:${target.id}:${target.at}`, start: start.at, end: end.at, baselineF: target.targetTempF, alarmAt: alarm?.at ?? null } + } + catch { return null } +} + +function scheduleJobs(schedules: ComfortSchedules): OccurrenceSource[] { + const jobs: OccurrenceSource[] = [] + for (const row of schedules.powers) { + if (row.onTime === row.offTime || !Number.isFinite(row.onTemperature)) throw new Error('Invalid comfort schedule') + jobs.push(job(`power:${row.id}`, 'start', row.dayOfWeek, row.onTime, row.onTemperature)) + jobs.push(job(`end:${row.id}`, 'end', powerOffDay(row), row.offTime)) + } + for (const row of schedules.targets) { + if (!Number.isFinite(row.temperature)) throw new Error('Invalid comfort schedule') + jobs.push(job(`temperature:${row.id}`, 'target', row.dayOfWeek, row.time, row.temperature)) + } + for (const row of schedules.alarms) jobs.push(job(`alarm:${row.id}`, 'alarm', row.dayOfWeek, row.time)) + return jobs +} + +/** Bind qualification to every enabled weekly phase, including when no routine is active. */ +export function comfortScheduleRevision(schedules: ComfortSchedules): string | null { + try { + const jobs = scheduleJobs(schedules) + if (!schedules.powers.length) return null + return createHash('sha256').update(JSON.stringify([schedules.timezone, schedules.source ?? null, jobs.sort((first, second) => first.id.localeCompare(second.id))])).digest('hex') + } + catch { return null } +} diff --git a/src/comfort/runtime.ts b/src/comfort/runtime.ts new file mode 100644 index 000000000..78e349670 --- /dev/null +++ b/src/comfort/runtime.ts @@ -0,0 +1,281 @@ +import { randomUUID } from 'node:crypto' +import type { BedroomComfortGuidance } from '@/src/bedroom/contract' +import { currentBedroomGuidance } from '@/src/bedroom/fusion' +import { capturePreviewEvidence, type PreviewOrigin } from './report-evidence' +import { BedEntryTracker, bedtimePreview, type BedtimeStatus } from './bedtime' +import type { ComfortSignals } from './signals' +import type { Side } from '@/src/hardware/types' +import { COMFORT_ACTIVATION_REASON, comfortConfigSchema, comfortFeedbackSchema, type ComfortStatus } from './contract' +import { comfortLocalDate, decideComfort, learnComfort, MANUAL_PAUSE_MS, type ComfortConfig, type ComfortContext, type ComfortDecision, type ComfortFeedback, type ComfortState } from './policy' +import { comfortEvent, type ComfortStore } from './store' +import type { ThermalContext, ThermalLearning } from './thermal-learning' +import { thermalObservation } from './thermal-observation' + +const STORAGE_FAULT = 'Personal comfort is withheld because its current inputs, settings or observation journal are unavailable.' +interface Observation { at: number, decision: ComfortDecision, stateKey: string, signals: ComfortSignals | null, timing: BedtimeStatus, bedroom: BedroomComfortGuidance | null, thermalContext?: ThermalContext | null, thermalQualified: boolean } +export class ComfortInputError extends Error {} + +export interface ComfortRuntimeDeps { + now(): number + origin?: PreviewOrigin + store: ComfortStore + withSideLock(side: Side, work: () => Promise): Promise + readContext(side: Side, state: ComfortState, now: number): { context: ComfortContext, timezone: string } + prepareSignals?(side: Side, now: number): Promise + timezone(): string + pendingPause(side: Side): number | null + acknowledgePause(side: Side, at: number): void + activation?: { + admitted(side: Side, state: ComfortState): boolean + revoke(side: Side, reason: string): void + revokeLease(side: Side): void + reconcile(side: Side): Promise + } +} + +/** Preview remains inert; qualified Active proposals use the shared temperature authority. */ +export class ComfortRuntime { + private readonly runId = randomUUID() + private observations: Partial> = {} + private recorded: Partial> = {} + private thermalRecorded: Partial> = {} + private faults: Partial> = {} + private readonly entryTrackers = { left: new BedEntryTracker(), right: new BedEntryTracker() } + private running = false + private timer?: ReturnType + private pending?: Promise + private lastPrunedAt = 0 + + constructor(private readonly deps: ComfortRuntimeDeps) {} + + isRunning(): boolean { + return this.running + } + + status(side: Side): ComfortStatus { + let state: ComfortState | null = null + let history: ComfortStatus['history'] = [] + let timezone: string | null = null + let deviceDate: string | null = null + try { + timezone = this.deps.timezone() + deviceDate = comfortLocalDate(this.deps.now(), timezone) + state = this.deps.store.read(side) + history = this.deps.store.history(side) + } + catch { this.faults[side] = STORAGE_FAULT } + const observed = this.observations[side] + const now = this.deps.now() + const observation = observed && observed.at <= now && now - observed.at < 10_000 ? observed : undefined + const signals = observation?.signals + const signalExpired = signals?.expiresAt !== null && signals?.expiresAt !== undefined && now >= signals.expiresAt + const bedroomExpired = state?.config.roomAware && observation?.bedroom?.state === 'ready' && !currentBedroomGuidance(observation.bedroom, now) + const fault = this.faults[side] ?? (this.deps.pendingPause(side) !== null ? STORAGE_FAULT : null) + const active = state !== null && this.deps.activation?.admitted(side, state) === true + let learning: ThermalLearning | null = null + if (timezone && this.deps.store.learning) { + try { + learning = this.deps.store.learning(side, !fault && !bedroomExpired + && observation?.stateKey === JSON.stringify(state) + && ((observation.decision.eligible && !signalExpired) || observation.thermalQualified) + ? observation.thermalContext ?? null + : null, timezone, now) + } + catch { /* Research availability must never interrupt the control policy. */ } + } + let decision = fault || signalExpired || bedroomExpired || observation?.stateKey !== JSON.stringify(state) ? null : observation?.decision ?? null + if (decision && state?.config.mode === 'active' && !active) { + decision = { ...decision, eligible: false, proposedF: null, retainPreviousLease: false, reason: COMFORT_ACTIVATION_REASON } + } + return { + apiVersion: 1, generatedAt: this.deps.now(), side, state, history, fault, timezone, deviceDate, + learning, + evaluatedAt: observed?.at ?? null, + decision, + runtime: this.running ? 'running' : 'stopped', + activation: { available: false, current: active, reason: active ? 'Owner activation is current. Every adjustment still rechecks live guards.' : COMFORT_ACTIVATION_REASON }, + features: { roomAware: true, sensorAware: true, followBedtime: true, nightReports: true, modeOnly: true, modelTraining: true, ...(this.deps.activation ? { ownerActivation: true } : {}) }, + timing: fault || signalExpired || observation?.stateKey !== JSON.stringify(state) ? null : observation?.timing ?? null, + signals: fault || signalExpired || observation?.stateKey !== JSON.stringify(state) ? null : signals ?? null, + bedroom: fault || observation?.stateKey !== JSON.stringify(state) ? null : observation?.bedroom ?? null, + } + } + + async configure(side: Side, input: ComfortConfig): Promise { + const config = comfortConfigSchema.parse(input) + if (config.mode === 'active') throw new Error(COMFORT_ACTIVATION_REASON) + if (config.followBedtime && !config.sensorAware) throw new ComfortInputError('Bed-entry timing requires the Pod sensor guard.') + await this.deps.withSideLock(side, async () => { + const state = this.recoverPause(side) + this.commitConfiguration(side, state, config) + }) + return this.status(side) + } + + /** Read settings inside the same lock as the write; a mode switch cannot restore stale limits. */ + async setMode(side: Side, mode: ComfortConfig['mode']): Promise { + comfortConfigSchema.shape.mode.parse(mode) + if (mode === 'active') throw new Error(COMFORT_ACTIVATION_REASON) + await this.deps.withSideLock(side, async () => { + const state = this.recoverPause(side) + this.commitConfiguration(side, state, { ...state.config, mode }) + }) + return this.status(side) + } + + private commitConfiguration(side: Side, state: ComfortState, config: ComfortConfig): void { + this.deps.activation?.revoke(side, 'Owner changed personal comfort configuration') + this.deps.store.commit(side, { ...state, config }, + comfortEvent(this.deps.now(), 'configuration', `Personal comfort set to ${config.mode}.`)) + if (state.config.followBedtime !== config.followBedtime || state.config.mode !== config.mode || !config.sensorAware) this.entryTrackers[side].reset() + this.observations[side] = undefined + this.recorded[side] = undefined + this.faults[side] = undefined + } + + async feedback(side: Side, input: ComfortFeedback): Promise { + const feedback = comfortFeedbackSchema.parse(input) + await this.deps.withSideLock(side, async () => { + const now = this.deps.now() + const today = comfortLocalDate(now, this.deps.timezone()) + const since = new Date(Date.parse(today) - 14 * 86_400_000).toISOString().slice(0, 10) + if (feedback.date > today || feedback.date < since) throw new ComfortInputError('Check-ins must describe today or the previous fourteen local dates.') + this.recoverPause(side) + this.deps.store.saveFeedback(side, feedback, + comfortEvent(now, 'feedback', `${feedback.date}: ${feedback.comfort}.`)) + }) + return this.status(side) + } + + /** Recover a failed manual-pause write before evaluating or changing consent. */ + private recoverPause(side: Side): ComfortState { + let state = this.deps.store.read(side) + const pending = this.deps.pendingPause(side) + if (pending !== null) { + state = { ...state, pausedUntil: Math.max(state.pausedUntil, pending + MANUAL_PAUSE_MS) } + this.deps.store.commit(side, state, comfortEvent(pending, 'manual_pause', 'Recovered the six-hour pause after a manual control.'), true) + this.deps.acknowledgePause(side, pending) + } + return state + } + + async evaluate(side: Side): Promise { + // Prepare history outside the actuator lock; current hardware is read again inside it. + try { + if (this.deps.store.read(side).config.sensorAware) await this.deps.prepareSignals?.(side, this.deps.now()) + } + catch { + this.deps.activation?.revokeLease(side) + this.faults[side] = STORAGE_FAULT + this.entryTrackers[side].reset() + this.observations[side] = undefined + return + } + await this.deps.withSideLock(side, async () => { + try { + const now = this.deps.now() + let state = this.recoverPause(side) + const original = JSON.stringify(state) + const { context: rawContext, timezone } = this.deps.readContext(side, state, now) + let context = rawContext + const admitted = state.config.mode === 'active' && this.deps.activation?.admitted(side, state) === true + // Only the application adapter records hardware acknowledgments. + const learning = (state.config.mode === 'preview' || (admitted && (!state.application || state.application.outcome === 'acknowledged'))) && context.window + ? learnComfort(state, this.deps.store.feedback(side), context.window.session, timezone, now) + : { state, message: null } + state = learning.state + const guard = decideComfort({ ...state, config: { ...state.config, followBedtime: false } }, context) + const timing = bedtimePreview(state, context, this.entryTrackers[side], guard.eligible) + if (timing.selection) state = { ...state, bedtime: timing.selection } + context = { ...context, window: timing.window, timing: timing.status } + // Preserve raw-schedule safety holds, especially the unexpected-target pause. + let decision = guard.eligible ? decideComfort(state, context) : guard + if (state.config.mode === 'active' && !admitted) { + decision = { ...decision, eligible: false, proposedF: null, retainPreviousLease: false, + reason: COMFORT_ACTIVATION_REASON } + } + if (decision.pauseRequired) state = { ...state, pausedUntil: Math.max(state.pausedUntil, now + MANUAL_PAUSE_MS) } + const preview = capturePreviewEvidence(state, rawContext, decision.baselineF, timezone, this.runId, + this.deps.origin ?? { environment: 'unknown', buildRevision: null }) + const key = JSON.stringify([preview, decision, context.window?.segment, context.owner, context.device.targetF, + context.signals?.state, context.signals?.baselineDays, context.signals?.sampleAt, timing.status]) + const previous = this.recorded[side] + const lastThermal = this.thermalRecorded[side] + const thermalReceipt = thermalObservation(state, rawContext, preview) + const recordThermal = (decision.eligible || thermalReceipt !== null) && context.thermalContext + && (lastThermal === undefined || now < lastThermal || now - lastThermal >= 300_000) + const record = state.config.mode !== 'off' && (!previous || previous.key !== key || now - previous.at >= 60_000 || now < previous.at) + const event = record + ? { + ...comfortEvent(now, state.config.mode === 'active' ? 'active' : 'preview', learning.message ? `${decision.reason} ${learning.message}` : decision.reason), + session: context.window?.session ?? null, proposedF: decision.proposedF, reportedF: context.device.targetF, + eligible: decision.eligible, deviceObservedAt: context.device.observedAt, + presenceObservedAt: context.presence.observedAt, owner: context.owner, signals: context.signals ?? null, timing: timing.status, bedroom: context.bedroom ?? null, preview, + thermalContext: recordThermal ? context.thermalContext : null, + ...(recordThermal && thermalReceipt ? { thermalObservation: thermalReceipt } : {}), + } + : undefined + if (event || JSON.stringify(state) !== original) this.deps.store.commit(side, state, event, decision.pauseRequired) + if (event?.thermalContext) this.thermalRecorded[side] = now + if (record) this.recorded[side] = { at: now, key } + this.observations[side] = { at: now, decision, stateKey: JSON.stringify(state), signals: context.signals ?? null, timing: timing.status, bedroom: context.bedroom ?? null, thermalContext: context.thermalContext ?? null, thermalQualified: thermalReceipt !== null } + this.faults[side] = undefined + } + catch { + this.deps.activation?.revokeLease(side) + this.faults[side] = STORAGE_FAULT + this.entryTrackers[side].reset() + this.observations[side] = undefined + } + }) + if (!this.faults[side]) { + try { + await this.deps.activation?.reconcile(side) + } + catch { + this.deps.activation?.revokeLease(side) + this.faults[side] = STORAGE_FAULT + } + } + } + + private tick(): Promise { + if (this.pending) return this.pending + this.pending = (async () => { + for (const side of ['left', 'right'] as const) { + if (!this.running) break + await this.evaluate(side) + } + const now = this.deps.now() + if (this.running && (!this.lastPrunedAt || now - this.lastPrunedAt >= 3_600_000)) { + try { + this.deps.store.prune(now) + this.lastPrunedAt = now + } + catch { /* Retain observations; a later tick retries pruning. */ } + } + })().finally(() => { this.pending = undefined }) + return this.pending + } + + async start(): Promise { + if (this.running) return + this.running = true + this.timer = setInterval(() => { + void this.tick() + }, 5_000) + this.timer.unref() + await this.tick() + } + + async stop(): Promise { + this.running = false + this.deps.activation?.revokeLease('left') + this.deps.activation?.revokeLease('right') + clearInterval(this.timer) + await this.pending + this.entryTrackers.left.reset() + this.entryTrackers.right.reset() + this.observations = {} + } +} diff --git a/src/comfort/signal-reader.ts b/src/comfort/signal-reader.ts new file mode 100644 index 000000000..4a3b65b8a --- /dev/null +++ b/src/comfort/signal-reader.ts @@ -0,0 +1,172 @@ +import type Database from 'better-sqlite3' +import { setImmediate as yieldToControls } from 'node:timers/promises' +import { z } from 'zod' +import type { Side } from '@/src/hardware/types' +import { parseVitalEvidence, VITAL_EVIDENCE_QUERY } from '@/src/biometrics/mechanical/vital-evidence' +import { mechanicalSpanSchema, readMechanicalSnapshot } from '@/src/biometrics/mechanical/snapshot' +import { MECHANICAL_MAX_SPANS } from '@/src/biometrics/mechanical/context' +import { COMFORT_WINDOW_MS, HISTORY_DAY_MS, median, missingComfortSignals, personalBaseline, summarizeComfortSignals } from './signals' +import type { ComfortSignals, ComfortWindow, PersonalBaseline, ComfortSignalIssue } from './signals' + +const hash = z.string().regex(/^[a-f0-9]{64}$/) +const identitySchema = z.object({ config_hash: hash, manifest_hash: hash, bundle_version: z.literal(1) }) +const query = VITAL_EVIDENCE_QUERY.replace('SELECT v.*,', `SELECT v.*, r.config_hash, + CASE WHEN length(CAST(r.bundle AS BLOB)) <= 65536 AND json_valid(r.bundle) THEN json_extract(r.bundle, '$.manifestHash') END AS manifest_hash, + CASE WHEN length(CAST(r.bundle AS BLOB)) <= 65536 AND json_valid(r.bundle) THEN json_extract(r.bundle, '$.schemaVersion') END AS bundle_version,`) +type Span = z.infer +type Estimate = ReturnType + +const issueMessages: Record = { + missing_estimate: 'No live Pod heart-rate estimate has been recorded for this side yet.', + invalid_provenance: 'The latest estimate is missing valid processing or mechanical evidence. Older readings cannot replace it.', + low_signal: 'The latest saved Pod estimate is flagged low signal. Raw sensor activity alone does not make it suitable for an adjustment.', + low_quality: 'The latest Pod estimate is below the comparison quality threshold. Waiting for a clearer signal.', + unsupported_estimate: 'The latest estimate is outside the conservative comparison envelope or carries another quality warning.', + stale_estimate: 'The latest Pod estimate is stale or has an invalid time. Waiting for a fresh estimate.', + quiet_context: 'Waiting for fresh confirmation that priming, vibration and base movement are idle.', + incomplete_windows: 'Waiting for two adjacent, complete five-minute summaries with fresh quiet-state evidence.', + storage_unavailable: 'Qualified sensor evidence could not be read. No temperature adjustment is proposed.', +} + +class SignalUnavailable extends Error { + constructor(readonly issue: ComfortSignalIssue) { super(issueMessages[issue]) } +} + +/** Keep source channel and numerical pipeline identity separate across restarts and routing changes. */ +function estimate(raw: unknown) { + if (raw === undefined) throw new SignalUnavailable('missing_estimate') + let evidence: ReturnType, identity: z.infer + try { + evidence = parseVitalEvidence(raw) + identity = identitySchema.parse(raw) + } + catch { throw new SignalUnavailable('invalid_provenance') } + if (evidence.flags.includes('low_signal')) throw new SignalUnavailable('low_signal') + if (evidence.row.quality_score < 0.5) throw new SignalUnavailable('low_quality') + if (evidence.row.heart_rate === null || evidence.row.heart_rate < 30 || evidence.row.heart_rate > 90 + || evidence.flags.some(flag => !['no_hrv', 'no_br'].includes(flag) && !flag.startsWith('mechanical_context_v1:'))) { + throw new SignalUnavailable('unsupported_estimate') + } + return { ...evidence, heartRate: evidence.row.heart_rate, + key: JSON.stringify([evidence.row.side, evidence.input.sourceSide, identity.config_hash, identity.manifest_hash]) } +} + +function covered(value: Estimate, spans: Map, now: number): boolean { + const span = spans.get(value.proof.spanId) + return value.timestampMs <= now && value.proof.endMs <= now && !!span && span.epoch === value.proof.epoch + && span.startMs <= value.proof.startMs && span.endMs >= value.proof.endMs +} + +/** Five distinct minute estimates summarize a window; they are not continuous five-minute HR measurements. */ +export function completeComfortWindows(rows: unknown[], key: string, spans: Map, now: number): ComfortWindow[] { + const groups = new Map() + for (const raw of rows) { + try { + const value = estimate(raw) + if (value.key !== key || !covered(value, spans, now)) continue + const start = Math.floor(value.timestampMs / COMFORT_WINDOW_MS) * COMFORT_WINDOW_MS + const values = groups.get(start) ?? [] + values.push(value) + groups.set(start, values) + } + catch { /* Missing provenance stays excluded; it is never repaired from current state. */ } + } + const windows: ComfortWindow[] = [] + for (const [start, values] of groups) { + const end = start + COMFORT_WINDOW_MS + values.sort((first, second) => first.timestampMs - second.timestampMs) + const first = values[0], span = spans.get(first.proof.spanId) + if (end > now || values.length !== 5 || new Set(values.map(value => Math.floor(value.timestampMs / 60_000))).size !== 5 + || values.some(value => value.proof.spanId !== first.proof.spanId || value.proof.epoch !== first.proof.epoch) + || values.some((value, index) => index > 0 && (value.timestampMs - values[index - 1].timestampMs < 45_000 || value.timestampMs - values[index - 1].timestampMs > 75_000)) + || !span || span.startMs > start || span.endMs < end) continue + windows.push({ start, end, heartRate: median(values.map(value => value.heartRate)), spanId: span.id, epoch: span.epoch, + evidenceStart: Math.min(start, ...values.map(value => value.proof.startMs)), evidenceEnd: Math.max(end, ...values.map(value => value.proof.endMs)) }) + } + return windows.sort((first, second) => first.start - second.start) +} + +interface HistoryCache { key: string, at: number, windows: ComfortWindow[] } +interface Prepared { key: string, timezone: string, at: number, baseline: PersonalBaseline } + +/** Historical work yields between bounded UTC-day reads, before acquiring any hardware side lock. */ +export class ComfortSignalReader { + private history: Partial> = {} + private prepared: Partial> = {} + constructor(private readonly database: Database.Database) {} + + private rows(side: Side, from: number, to: number, limit: number): unknown[] { + // LEFT joins retain malformed/latest rows so they cannot silently fall back to old good data. + const rows = this.database.prepare(`${query.replaceAll('JOIN ', 'LEFT JOIN ')} + WHERE v.side=? AND v.timestamp>=? AND v.timestamp limit) throw new Error('Vital density exceeds the bounded reader') + return rows + } + + private spans(now: number): Map { + const rows = this.database.prepare('SELECT id, epoch, start_ms AS startMs, end_ms AS endMs FROM biometric_mechanical_spans ORDER BY end_ms DESC LIMIT ?').all(MECHANICAL_MAX_SPANS + 1) + if (rows.length > MECHANICAL_MAX_SPANS) throw new Error('Mechanical history exceeds retention bound') + const spans = rows.map(row => mechanicalSpanSchema.parse(row)).filter(span => span.endMs <= now) + return new Map(spans.map(span => [span.id, span])) + } + + private latest(side: Side, now: number) { + const raw = this.database.prepare(`${query.replaceAll('JOIN ', 'LEFT JOIN ')} WHERE v.side=? ORDER BY v.timestamp DESC, v.id DESC LIMIT 1`).get(side) + const value = estimate(raw) + if (value.timestampMs > now || now - value.timestampMs > 120_000 || value.proof.endMs > now) throw new SignalUnavailable('stale_estimate') + return value + } + + async prepare(side: Side, timezone: string, now: number): Promise { + this.prepared[side] = undefined + try { + const latest = this.latest(side, now) + let cache = this.history[side] + if (!cache || cache.key !== latest.key || now < cache.at || now - cache.at >= 900_000) { + const spans = this.spans(now), windows: ComfortWindow[] = [] + const from = now - 29 * HISTORY_DAY_MS, until = now - HISTORY_DAY_MS + for (let start = Math.floor(from / HISTORY_DAY_MS) * HISTORY_DAY_MS; start < until; start += HISTORY_DAY_MS) { + const rows = this.rows(side, Math.max(from, start), Math.min(until, start + HISTORY_DAY_MS), 1500) + windows.push(...completeComfortWindows(rows, latest.key, spans, now)) + await yieldToControls() + } + cache = { key: latest.key, at: now, windows } + this.history[side] = cache + } + // Re-check retained evidence every evaluation; deleted/invalidated spans cannot live on in a cached baseline. + const spans = this.spans(now) + const windows = cache.windows.filter((window) => { + const span = spans.get(window.spanId) + return span?.epoch === window.epoch && span.startMs <= window.evidenceStart && span.endMs >= window.evidenceEnd + }) + this.prepared[side] = { key: latest.key, timezone, at: now, baseline: { ...personalBaseline(windows, timezone, now), asOf: cache.at } } + } + catch { this.history[side] = undefined } + } + + read(side: Side, timezone: string, now: number): ComfortSignals { + try { + return this.database.transaction(() => { + const latest = this.latest(side, now), snapshot = readMechanicalSnapshot(this.database, now) + const state = snapshot?.state + if (!snapshot || !state || state.reasons.length || state.confirmedThroughMs === null || now >= state.validUntilMs + || state.confirmedThroughMs > now || latest.proof.spanId !== state.spanId || latest.proof.epoch !== state.epoch) throw new SignalUnavailable('quiet_context') + const confirmedThrough = state.confirmedThroughMs + const spans = new Map(snapshot.spans.filter(span => span.id === state.spanId && span.epoch === state.epoch) + .map(span => [span.id, { ...span, endMs: Math.min(span.endMs, confirmedThrough) }])) + if (!covered(latest, spans, now)) throw new SignalUnavailable('quiet_context') + const recent = completeComfortWindows(this.rows(side, now - 20 * 60_000, now + 1, 32), latest.key, spans, now).slice(-2) + const prepared = this.prepared[side] + const baseline = prepared?.key === latest.key && prepared.timezone === timezone && prepared.at <= now && now - prepared.at < 10_000 ? prepared.baseline : null + const expiresAt = Math.min(state.validUntilMs, latest.timestampMs + 120_000, (recent.at(-1)?.end ?? now) + 360_000) + const result = summarizeComfortSignals(baseline, recent, latest.input.sourceSide, expiresAt, now) + return result.state === 'stale' ? { ...result, issue: 'incomplete_windows' as const } : result + })() + } + catch (error) { + const issue = error instanceof SignalUnavailable ? error.issue : 'storage_unavailable' + return { ...missingComfortSignals(issueMessages[issue]), issue } + } + } +} diff --git a/src/comfort/signals.ts b/src/comfort/signals.ts new file mode 100644 index 000000000..6b222d886 --- /dev/null +++ b/src/comfort/signals.ts @@ -0,0 +1,74 @@ +import { z } from 'zod' + +export const COMFORT_WINDOW_MS = 300_000 +export const BASELINE_DAYS_REQUIRED = 7 +export const BASELINE_WINDOWS_PER_DAY = 12 +export const HISTORY_DAY_MS = 86_400_000 + +export const comfortSignalIssueSchema = z.enum(['missing_estimate', 'invalid_provenance', 'low_signal', + 'low_quality', 'unsupported_estimate', 'stale_estimate', 'quiet_context', 'incomplete_windows', 'storage_unavailable']) +export type ComfortSignalIssue = z.infer + +const time = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const comfortSignalsSchema = z.object({ + state: z.enum(['learning', 'ready', 'stale', 'review']), reason: z.string(), + sampleAt: time.nullable(), expiresAt: time.nullable(), + baselineHeartRate: z.number().finite().nullable(), baselineDays: z.number().int().nonnegative(), + baselineWindows: z.number().int().nonnegative(), baselineAsOf: time.nullable(), + recentHeartRates: z.array(z.number().finite()).max(2), + sourceSide: z.enum(['left', 'right']).nullable(), + source: z.literal('qualified_live_pod'), + issue: comfortSignalIssueSchema.optional(), +}) +export type ComfortSignals = z.infer +export interface ComfortWindow { start: number, end: number, heartRate: number, spanId: string, epoch: string, evidenceStart: number, evidenceEnd: number } +export interface PersonalBaseline { heartRate: number | null, days: number, windows: number, asOf: number } + +export function missingComfortSignals(reason = 'Waiting for qualified live Pod estimates.'): ComfortSignals { + return { state: 'stale', reason, sampleAt: null, expiresAt: null, baselineHeartRate: null, + baselineDays: 0, baselineWindows: 0, baselineAsOf: null, recentHeartRates: [], sourceSide: null, source: 'qualified_live_pod' } +} + +export function median(values: number[]): number { + const sorted = [...values].sort((first, second) => first - second) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2 +} + +/** Each qualified local date has equal weight, regardless of recording length. */ +export function personalBaseline(windows: ComfortWindow[], timezone: string, now: number): PersonalBaseline { + const dates = new Map>() + const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }) + for (const window of windows) { + if (window.start < now - 29 * HISTORY_DAY_MS || window.end > now - HISTORY_DAY_MS) continue + const date = dateFormatter.format(window.end - 1) + const values = dates.get(date) ?? new Map() + values.set(window.start, window.heartRate) + dates.set(date, values) + } + const days = [...dates.values()].filter(values => values.size >= BASELINE_WINDOWS_PER_DAY) + return { heartRate: days.length >= BASELINE_DAYS_REQUIRED ? median(days.map(values => median([...values.values()]))) : null, + days: days.length, windows: days.reduce((count, values) => count + values.size, 0), asOf: now } +} + +/** A deviation is a reason to hold comfort proposals, never a diagnosis or treatment. */ +export function summarizeComfortSignals(baseline: PersonalBaseline | null, recent: ComfortWindow[], sourceSide: 'left' | 'right', expiresAt: number, now: number): ComfortSignals { + const latest = recent.at(-1) + const output: ComfortSignals = { ...missingComfortSignals(), sourceSide, + baselineHeartRate: baseline?.heartRate ?? null, baselineDays: baseline?.days ?? 0, + baselineWindows: baseline?.windows ?? 0, baselineAsOf: baseline?.asOf ?? null, + sampleAt: latest?.end ?? null, expiresAt, recentHeartRates: recent.map(window => window.heartRate) } + if (recent.length !== 2 || !latest || recent[0].end !== latest.start || latest.end > now + || now - latest.end > 360_000 || now >= expiresAt) { + return { ...output, state: 'stale', reason: 'Waiting for two adjacent, complete five-minute summaries with fresh quiet-state evidence.' } + } + if (!baseline || baseline.heartRate === null) return { ...output, state: 'learning', + reason: `Learning your Pod baseline: ${output.baselineDays} of ${BASELINE_DAYS_REQUIRED} qualified days. Each day needs at least ${BASELINE_WINDOWS_PER_DAY} complete summaries.` } + const baselineHeartRate = baseline.heartRate + const threshold = Math.max(10, baselineHeartRate * 0.2) + const deviations = recent.map(window => window.heartRate - baselineHeartRate) + if (deviations.every(value => value >= threshold) || deviations.every(value => value <= -threshold)) { + return { ...output, state: 'review', reason: 'Both recent summaries differ from your baseline. Holding comfort proposals; this estimate does not identify a medical condition.' } + } + return { ...output, state: 'ready', reason: 'Recent qualified Pod estimates are within the personal comparison guard. Other comfort safeguards still apply.' } +} diff --git a/src/comfort/store.ts b/src/comfort/store.ts new file mode 100644 index 000000000..8b0c52613 --- /dev/null +++ b/src/comfort/store.ts @@ -0,0 +1,85 @@ +import { synchronizeJournalComfort } from '@/src/journal/store' +import { durableTransaction } from '@/src/db/durable' +import { previewReportId } from './report-evidence' +import type Database from 'better-sqlite3' +import type { Side } from '@/src/hardware/types' +import { comfortEventSchema, comfortFeedbackSchema, comfortStateSchema, type ComfortEvent } from './contract' +import { defaultComfortState, type ComfortFeedback, type ComfortState } from './policy' +import { comfortLocalDate } from './policy' +import { thermalDatasetAudit, type ThermalDatasetAudit } from './thermal-dataset' +import { evaluateThermalLearning, type ThermalContext, type ThermalLearning } from './thermal-learning' + +export interface ComfortStore { + read(side: Side): ComfortState + commit(side: Side, state: ComfortState, event?: ComfortEvent, durable?: boolean): void + history(side: Side): ComfortEvent[] + feedback(side: Side): ComfortFeedback[] + saveFeedback(side: Side, feedback: ComfortFeedback, event: ComfortEvent): void + prune(now: number): void + learning?(side: Side, context: ThermalContext | null, timezone: string, now: number): ThermalLearning +} + +/** Missing rows default OFF; malformed persisted data must fail closed, never reset silently. */ +export function createComfortStore(database: Database.Database): ComfortStore { + const datasetCache: Partial> = {} + return { + learning(side, context, timezone, now) { + let cached = datasetCache[side] + if (!cached || now < cached.at || now - cached.at >= 300000) { + cached = { at: now, audit: thermalDatasetAudit(database, side, now) } + datasetCache[side] = cached + } + return { ...evaluateThermalLearning(cached.audit.nights, context, comfortLocalDate(now, timezone)), collection: cached.audit.collection } + }, + read(side) { + const row = database.prepare('SELECT state FROM comfort_states WHERE side = ?').get(side) as { state: string } | undefined + return row ? comfortStateSchema.parse(JSON.parse(row.state)) : defaultComfortState() + }, + commit(side, state, event, durable = false) { + const encoded = JSON.stringify(comfortStateSchema.parse(state)) + const eventJson = event ? JSON.stringify(comfortEventSchema.parse(event)) : null + const write = () => { + database.prepare('INSERT INTO comfort_states (side, state) VALUES (?, ?) ON CONFLICT(side) DO UPDATE SET state = excluded.state').run(side, encoded) + if (event && eventJson) database.prepare('INSERT INTO comfort_events (side, observed_at, event, report_id) VALUES (?, ?, ?, ?)').run(side, event.at, eventJson, event.preview ? previewReportId(event.preview) : null) + } + if (durable) durableTransaction(database, write) + else database.transaction(write)() + }, + history(side) { + const rows = database.prepare('SELECT event FROM comfort_events WHERE side = ? ORDER BY observed_at DESC, id DESC LIMIT 60').all(side) as { event: string }[] + return rows.map(row => comfortEventSchema.parse(JSON.parse(row.event))) + }, + feedback(side) { + const rows = database.prepare('SELECT local_date AS date, comfort FROM comfort_feedback WHERE side = ? ORDER BY local_date DESC LIMIT 60').all(side) + return rows.map(row => comfortFeedbackSchema.parse(row)) + }, + saveFeedback(side, feedback, event) { + datasetCache[side] = undefined + const report = comfortFeedbackSchema.parse(feedback) + const encoded = JSON.stringify(comfortEventSchema.parse(event)) + database.transaction(() => { + database.prepare(`INSERT INTO comfort_feedback (side, local_date, comfort, recorded_at) VALUES (?, ?, ?, ?) + ON CONFLICT(side, local_date) DO UPDATE SET comfort = excluded.comfort, recorded_at = excluded.recorded_at`).run(side, report.date, report.comfort, event.at) + synchronizeJournalComfort(database, side, report.date, report.comfort, event.at) + database.prepare('INSERT INTO comfort_events (side, observed_at, event) VALUES (?, ?, ?)').run(side, event.at, encoded) + })() + }, + prune(now) { + database.transaction(() => { + // Sparse research observations need enough history for chronological validation. + database.prepare(`DELETE FROM comfort_events WHERE observed_at < ? OR + (observed_at < ? AND json_extract(event, '$.thermalContext') IS NULL)`) + .run(now - 90 * 86_400_000, now - 30 * 86_400_000) + for (const side of ['left', 'right']) { + database.prepare(`DELETE FROM comfort_events WHERE side = ? AND id NOT IN + (SELECT id FROM comfort_events WHERE side = ? ORDER BY observed_at DESC, id DESC LIMIT 50000) + AND id NOT IN (SELECT id FROM comfort_events WHERE side = ? + AND json_extract(event, '$.thermalContext') IS NOT NULL + ORDER BY observed_at DESC, id DESC LIMIT 26000)`).run(side, side, side) + } + })() + }, + } +} + +export { comfortEvent } from './event' diff --git a/src/comfort/tests/application.test.ts b/src/comfort/tests/application.test.ts new file mode 100644 index 000000000..9f41a4ef1 --- /dev/null +++ b/src/comfort/tests/application.test.ts @@ -0,0 +1,287 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { beforeControlWrite } from '@/src/controls/context' +import { withSideLock } from '@/src/hardware/sideLock' +import { TemperatureController, type TemperatureRequest } from '@/src/temperature/controller' +import { createTemperatureApplicationRecorder, readTemperatureApplications } from '@/src/temperature/applications' +import { ComfortApplicationAdapter, type ComfortAdmission } from '../application' +import { createComfortStore } from '../store' +import { ADJUSTMENT_INTERVAL_MS, defaultComfortState, type ComfortContext } from '../policy' +import { migratedComfortDatabase } from './databaseFixture' + +const databases: ReturnType[] = [] +const directories: string[] = [] +afterEach(() => { + for (const database of databases.splice(0)) if (database.open) database.close() + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +function fixture(filename = ':memory:', initialize = true) { + const database = migratedComfortDatabase(filename) + databases.push(database) + const store = createComfortStore(database) + let now = Date.parse('2026-10-26T08:00:00Z'), monotonic = 1000 + let enabled = true, pause: number | null = null + let admission: ComfortAdmission | null = { revision: 'synthetic-owner-grant', issuedAt: now - 1000, expiresAt: now + 86_400_000 } + const context: ComfortContext = { + now, window: { session: 'synthetic-night', segment: 'synthetic-phase', start: now - 3_600_000, end: now + 4 * 3_600_000, baselineF: 75, alarmAt: null }, + device: { observedAt: now, powered: true, waterAdequate: true, priming: false, vibrating: false, baseMoving: false, targetF: 75 }, + presence: { observedAt: now, available: true, occupied: true }, + eitherAway: false, routinesPaused: false, alarmOverride: false, owner: 'schedule', targetKnown: true, + } + if (initialize) store.commit('left', { ...defaultComfortState(), config: { mode: 'active', minimumF: 70, maximumF: 95 }, offsetF: -2 }) + const baseline: TemperatureRequest = { id: 'routine', source: 'schedule', temperature: 75, startsAt: now - 1000, + expiresAt: now + 86_400_000, createdAt: now - 1000, priority: 0 } + const commands: { target: number, part: 'level' | 'duration' }[] = [] + let beforeApply: () => Promise = async () => {} + let betweenWrites: () => void = () => {} + let reportsTarget = true + let hold: TemperatureRequest | null = null + const controller = new TemperatureController({ + now: () => now, withSideLock, + readHold: () => hold, writeHold: (_side, value) => { hold = value }, + readBaseline: () => [baseline], isPowered: () => context.device.powered, + readCurrentTarget: () => context.device.targetF, + readHardwareDeadline: () => null, writeHardwareDeadline: () => {}, isBlocked: () => false, + connect: async () => {}, powerOff: async () => { context.device.powered = false }, publish: () => {}, + recordApplication: createTemperatureApplicationRecorder(database, () => now), + apply: async (_side, target) => { + await beforeApply() + beforeControlWrite() + commands.push({ target, part: 'level' }) + betweenWrites() + beforeControlWrite() + commands.push({ target, part: 'duration' }) + if (reportsTarget) { + context.device.targetF = target + context.device.observedAt = now + } + }, + }) + const deps = { + now: () => now, monotonicNow: () => monotonic, store, authority: controller, withSideLock, + autopilotEnabled: () => enabled, admission: () => admission, pendingPause: () => pause, + readContext: () => ({ ...context, now, device: { ...context.device }, presence: { ...context.presence } }), + } + const adapter = new ComfortApplicationAdapter(deps) + const advance = (milliseconds: number, fresh = true) => { + now += milliseconds + monotonic += Math.max(0, milliseconds) + context.now = now + if (fresh) { + context.device.observedAt = now + context.presence.observedAt = now + } + } + return { database, store, context, controller, deps, adapter, commands, advance, + applications: () => readTemperatureApplications(database, 'left', undefined, now).items, + beforeApply: (work: () => Promise) => { beforeApply = work }, + betweenWrites: (work: () => void) => { betweenWrites = work }, + reportTarget: (value: boolean) => { reportsTarget = value }, + enable: (value: boolean) => { enabled = value }, + grant: (value: ComfortAdmission | null) => { admission = value }, + pause: () => { pause = now }, + mono: (value: number) => { monotonic = value }, + } +} + +describe('personal comfort execution adapter (synthetic qualification, mocked hardware)', () => { + it('rechecks bedroom expiry after a queued write', async () => { + const test = fixture() + const saved = test.store.read('left') + test.store.commit('left', { ...saved, config: { ...saved.config, roomAware: true } }) + test.context.bedroom = { state: 'ready', offsetF: -1, reason: 'Synthetic room.', sourceIds: ['temperature', 'humidity'], + expiresAt: test.context.now + 2000, configuration: 'f'.repeat(64) } + test.beforeApply(async () => { + test.advance(2000) + }) + await expect(test.adapter.reconcile('left')).rejects.toThrow('Current control conditions') + expect(test.commands.filter(command => command.target === 74)).toEqual([]) + }) + it('withdraws an expired bedroom lease during the correction cooldown', async () => { + const test = fixture() + const saved = test.store.read('left') + test.store.commit('left', { ...saved, config: { ...saved.config, roomAware: true } }) + test.context.bedroom = { state: 'ready', offsetF: -1, reason: 'Synthetic room.', sourceIds: ['temperature', 'humidity'], + expiresAt: test.context.now + 2000, configuration: 'f'.repeat(64) } + await test.adapter.reconcile('left') + expect(test.commands.some(command => command.target === 74)).toBe(true) + test.advance(2000) + await test.adapter.reconcile('left') + expect(test.controller.status('left').source).not.toBe('autopilot') + }) + it('persists only winning one-degree applications and retains the correction clock across keepalives and restarts', async () => { + const test = fixture() + await test.adapter.reconcile('left') + const first = test.store.read('left') + expect(first).toMatchObject({ lastTargetF: 74, lastAppliedAt: test.context.now, lastSegment: 'synthetic-phase', application: { outcome: 'acknowledged' } }) + expect(test.commands).toEqual([{ target: 74, part: 'level' }, { target: 74, part: 'duration' }]) + expect(test.applications()[0]).toMatchObject({ source: 'autopilot', outcome: 'acknowledged', requestId: `policy:personal-comfort:${first.application?.requestId}` }) + test.advance(5000) + const restarted = new ComfortApplicationAdapter(test.deps) + await restarted.reconcile('left') + expect(test.commands).toHaveLength(2) + await test.controller.reconcile('left', true) + expect(test.store.read('left').lastAppliedAt).toBe(first.lastAppliedAt) + expect(test.applications()).toHaveLength(2) + test.advance(ADJUSTMENT_INTERVAL_MS - 5000) + await restarted.reconcile('left') + expect(test.store.read('left')).toMatchObject({ lastTargetF: 73, lastAppliedAt: test.context.now }) + expect(test.commands.slice(-2)).toEqual([{ target: 73, part: 'level' }, { target: 73, part: 'duration' }]) + expect(test.store.read('right')).toEqual(defaultComfortState()) + }) + + it.each(['off', 'preview'] as const)('never submits personal changes in %s mode', async (mode) => { + const test = fixture() + const state = test.store.read('left') + test.store.commit('left', { ...state, config: { ...state.config, mode } }) + await test.adapter.reconcile('left') + expect(test.store.read('left').application).toBeUndefined() + expect(test.commands.every(command => command.target === 75)).toBe(true) + }) + + it.each(['consent', 'grant', 'pending_pause', 'empty', 'water', 'stale', 'off', 'base', 'vibration', 'priming', 'alarm', 'bedtime'] as const)( + 'withholds a new proposal when %s is unavailable', async (missing) => { + const test = fixture() + if (missing === 'consent') test.enable(false) + if (missing === 'grant') test.grant(null) + if (missing === 'pending_pause') test.pause() + if (missing === 'empty') test.context.presence.occupied = false + if (missing === 'water') test.context.device.waterAdequate = null + if (missing === 'stale') test.context.device.observedAt = test.context.now - 16_000 + if (missing === 'off') test.context.device.powered = false + if (missing === 'base') test.context.device.baseMoving = null + if (missing === 'vibration') test.context.device.vibrating = true + if (missing === 'priming') test.context.device.priming = true + if (missing === 'alarm') test.context.alarmOverride = true + if (missing === 'bedtime') { + const state = test.store.read('left') + test.store.commit('left', { ...state, config: { ...state.config, followBedtime: true } }) + } + await test.adapter.reconcile('left') + expect(test.store.read('left').application).toBeUndefined() + expect(test.commands.every(command => command.target === 75)).toBe(true) + }, + ) + + it('does not reserve a correction for an unselected proposal, even when a rule spoofs its ID', async () => { + const test = fixture() + await test.controller.replaceAutopilot('left', [{ id: 'policy:personal-comfort:spoof', source: 'autopilot', temperature: 76, + startsAt: test.context.now, expiresAt: test.context.now + 60_000, createdAt: test.context.now, priority: 0 }]) + await test.adapter.reconcile('left') + expect(test.store.read('left').application).toBeUndefined() + expect(test.commands).toHaveLength(2) + expect(test.commands[0].target).toBe(76) + }) + + it.each(['consent', 'grant', 'expiry', 'monotonic', 'pause', 'water', 'manual', 'stop', 'slow_inputs'] as const)( + 'rechecks %s after the write has waited in the hardware queue', async (change) => { + const test = fixture() + test.beforeApply(async () => { + if (change === 'consent') test.enable(false) + if (change === 'grant') test.grant({ revision: 'replaced', issuedAt: test.context.now - 1, expiresAt: test.context.now + 60_000 }) + if (change === 'expiry') test.advance(10_000) + if (change === 'monotonic') test.mono(11_000) + if (change === 'pause') test.pause() + if (change === 'water') test.context.device.waterAdequate = false + if (change === 'manual') { + const state = test.store.read('left') + test.store.commit('left', { ...state, pausedUntil: test.context.now + 6 * 3_600_000 }) + } + if (change === 'stop') test.adapter.revoke('left') + if (change === 'slow_inputs') { + const read = test.deps.readContext + test.deps.readContext = () => { + const result = read() + test.advance(11_000) + return result + } + } + }) + await expect(test.adapter.reconcile('left')).rejects.toThrow('control conditions') + expect(test.commands).toEqual([]) + expect(test.store.read('left')).toMatchObject({ lastAppliedAt: 0, lastTargetF: null, application: { outcome: 'unknown' } }) + expect(test.applications()[0].outcome).toBe('unknown') + }, + ) + + it('leaves a partial level/duration write unconfirmed and never retries it after a restart or elapsed cooldown', async () => { + const directory = mkdtempSync(path.join(tmpdir(), 'comfort-adapter-')) + directories.push(directory) + const filename = path.join(directory, 'config.db') + const test = fixture(filename) + test.betweenWrites(() => { + test.context.device.waterAdequate = false + }) + await expect(test.adapter.reconcile('left')).rejects.toThrow() + expect(test.commands).toEqual([{ target: 74, part: 'level' }]) + test.database.close() + const reopened = fixture(filename, false) + reopened.advance(ADJUSTMENT_INTERVAL_MS + 1) + await reopened.adapter.reconcile('left') + expect(reopened.commands.every(command => command.target === 75)).toBe(true) + expect(reopened.store.read('left')).toMatchObject({ lastAppliedAt: 0, application: { outcome: 'unknown' } }) + }) + + it.each(['reservation', 'journal', 'journal_result', 'comfort_result'] as const)( + 'fails closed if the %s persistence step fails', async (failure) => { + const test = fixture() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + if (failure === 'reservation') test.database.exec('CREATE TRIGGER reject_reservation BEFORE UPDATE ON comfort_states BEGIN SELECT RAISE(ABORT, \'test\'); END') + if (failure === 'journal') test.database.exec('CREATE TRIGGER reject_journal BEFORE INSERT ON temperature_applications BEGIN SELECT RAISE(ABORT, \'test\'); END') + if (failure === 'journal_result') test.database.exec('CREATE TRIGGER reject_result BEFORE UPDATE ON temperature_applications BEGIN SELECT RAISE(ABORT, \'test\'); END') + if (failure === 'comfort_result') test.betweenWrites(() => { + test.database.exec('CREATE TRIGGER reject_result BEFORE UPDATE ON comfort_states BEGIN SELECT RAISE(ABORT, \'test\'); END') + }) + if (failure === 'journal_result') await test.adapter.reconcile('left') + else await expect(test.adapter.reconcile('left')).rejects.toThrow('test') + const state = test.store.read('left') + expect(state.lastAppliedAt).toBe(0) + if (failure === 'reservation') expect(state.application).toBeUndefined() + else expect(state.application?.outcome).not.toBe('acknowledged') + expect(test.commands).toHaveLength(failure === 'reservation' || failure === 'journal' ? 0 : 2) + }, + ) + + it('persists an unexpected-target pause', async () => { + const test = fixture() + test.context.targetKnown = false + await test.adapter.reconcile('left') + expect(test.store.read('left').pausedUntil).toBe(test.context.now + 6 * 3_600_000) + expect(test.store.read('left').application).toBeUndefined() + }) + + it('latches unexpected targets during read-only selection and persists the pause inside the next side lock', async () => { + const test = fixture() + await test.adapter.reconcile('left') + const before = test.store.read('left') + test.context.targetKnown = false + test.context.device.targetF = 77 + expect(test.controller.status('left').source).toBe('schedule') + expect(test.store.read('left')).toEqual(before) + test.context.targetKnown = true + test.context.device.targetF = 74 + await test.adapter.reconcile('left') + expect(test.store.read('left').pausedUntil).toBe(test.context.now + 6 * 3_600_000) + }) + + it('waits briefly for a newer target report without refreshing its grace period or retrying the target', async () => { + const test = fixture() + test.reportTarget(false) + test.betweenWrites(() => test.advance(1, false)) + await test.adapter.reconcile('left') + expect(test.controller.status('left').source).toBe('autopilot') + test.advance(5000, false) + await test.adapter.reconcile('left') + expect(test.commands).toHaveLength(2) + await expect(test.controller.reconcile('left', true)).rejects.toThrow('control conditions') + expect(test.commands).toHaveLength(2) + test.advance(5001, false) + await test.controller.reconcile('left') + expect(test.controller.status('left').source).toBe('schedule') + expect(test.commands.slice(-2).every(command => command.target === 75)).toBe(true) + }) +}) diff --git a/src/comfort/tests/bedroom-evidence.test.ts b/src/comfort/tests/bedroom-evidence.test.ts new file mode 100644 index 000000000..b2de636c1 --- /dev/null +++ b/src/comfort/tests/bedroom-evidence.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { ownerFixture } from './ownerFixture' + +const open: ReturnType[] = [] +function fixture() { + const value = ownerFixture() + open.push(value) + return value +} +afterEach(() => { + for (const value of open.splice(0)) value.database.close() +}) + +describe('room configuration and overnight evidence', () => { + it('cannot reuse a reviewed night collected without room context to activate the new policy', async () => { + const value = fixture() + await value.night(1) + expect(await value.reports.activationProofs('left', value.identity)).toHaveLength(1) + expect(await value.reports.activationProofs('left', { ...value.identity, bedroomConfiguration: 'e'.repeat(64), + policy: { ...value.identity.policy, roomAware: true } })).toEqual([]) + }) + it('ties room-aware nights to the exact source configuration and qualified input lease', async () => { + const value = fixture() + value.identity.policy.roomAware = true + value.identity.bedroomConfiguration = 'e'.repeat(64) + const bedroom = { state: 'ready' as const, offsetF: -1 as const, reason: 'Synthetic warm room.', + sourceIds: ['temperature', 'humidity'], expiresAt: value.now() + 60000, configuration: value.identity.bedroomConfiguration } + await value.night(1, { bedroom }) + expect(await value.reports.activationProofs('left', value.identity)).toHaveLength(1) + expect(await value.reports.activationProofs('left', { ...value.identity, bedroomConfiguration: 'f'.repeat(64) })).toEqual([]) + await value.night(2, { bedroom: { ...bedroom, state: 'unavailable' } }) + expect(await value.reports.activationProofs('left', value.identity)).toHaveLength(1) + }) +}) diff --git a/src/comfort/tests/bedroom.test.ts b/src/comfort/tests/bedroom.test.ts new file mode 100644 index 000000000..1f84243d9 --- /dev/null +++ b/src/comfort/tests/bedroom.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { BedroomComfortGuidance } from '@/src/bedroom/contract' +import { decideComfort, defaultComfortState, type ComfortContext } from '../policy' + +const now = Date.parse('2026-10-27T04:00:00Z') +const guidance: BedroomComfortGuidance = { state: 'ready', offsetF: -1, reason: 'Warm room.', sourceIds: ['temperature', 'humidity'], + expiresAt: now + 120000, configuration: 'f'.repeat(64) } +function context(): ComfortContext { + return { + now, window: { session: 'night', segment: 'night:first', start: now - 60000, end: now + 3600000, baselineF: 75, alarmAt: null }, + device: { observedAt: now, powered: true, waterAdequate: true, priming: false, vibrating: false, baseMoving: false, targetF: 75 }, + presence: { observedAt: now, available: true, occupied: true }, + eitherAway: false, routinesPaused: false, alarmOverride: false, owner: 'schedule', targetKnown: true, bedroom: { ...guidance }, + } +} +function state() { + return { ...defaultComfortState(), config: { mode: 'active' as const, minimumF: 70, maximumF: 95, roomAware: true } } +} + +describe('bedroom-aware personal comfort', () => { + it('adds a room contribution only when selected and never mutates the saved curve', () => { + const input = context() + const before = structuredClone(input.window) + expect(decideComfort(state(), input)).toMatchObject({ eligible: true, baselineF: 75, proposedF: 74 }) + expect(decideComfort({ ...state(), config: { ...state().config, roomAware: false } }, input).proposedF).toBe(75) + expect(input.window).toEqual(before) + }) + it('shares the learner offset budget while retaining one-degree steps', () => { + const input = context() + input.device.targetF = 72 + expect(decideComfort({ ...state(), offsetF: -3 }, input)).toMatchObject({ eligible: true, proposedF: 72 }) + input.device.targetF = 75 + expect(decideComfort({ ...state(), offsetF: -3 }, input).proposedF).toBe(74) + }) + it.each([ + undefined, + { ...guidance, state: 'unavailable' as const }, + { ...guidance, state: 'disagreement' as const }, + { ...guidance, expiresAt: now }, + { ...guidance, expiresAt: now + 900001 }, + { ...guidance, configuration: null }, + { ...guidance, sourceIds: [] }, + ])('withdraws the previous room target when its inputs are unusable: %j', (bedroom) => { + const input = { ...context(), bedroom } + input.device.targetF = 74 + const previous = { ...state(), lastAppliedAt: now - 60000, lastTargetF: 74, lastSegment: 'night:first' } + expect(decideComfort(previous, input)).toMatchObject({ eligible: false, retainPreviousLease: false, proposedF: null }) + }) + it('keeps manual pauses, empty bed and the wake-warming window ahead of room context', () => { + expect(decideComfort({ ...state(), pausedUntil: now + 3600000 }, context()).reason).toContain('six hours') + const input = context() + input.presence.occupied = false + expect(decideComfort(state(), input).eligible).toBe(false) + input.presence.occupied = true + if (!input.window) throw new Error('Routine fixture missing') + input.window.alarmAt = now + 30 * 60000 + expect(decideComfort(state(), input)).toMatchObject({ eligible: false, reason: 'Leaving the alarm window to your routine.' }) + }) +}) diff --git a/src/comfort/tests/bedtime.test.ts b/src/comfort/tests/bedtime.test.ts new file mode 100644 index 000000000..487fc1556 --- /dev/null +++ b/src/comfort/tests/bedtime.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { BedEntryTracker, bedtimePreview, type BedEntrySample } from '../bedtime' +import { comfortRoutineAt, type ComfortSchedules } from '../routines' +import { DAYS_OF_WEEK } from '@/src/lib/scheduleTime' +import { defaultComfortState, decideComfort, type ComfortContext, type ComfortState } from '../policy' +import { missingComfortSignals } from '../signals' + +const MINUTE = 60_000 +const start = Date.parse('2026-10-25T05:00:00Z') +const sourceKey = 'a'.repeat(64) +function sample(measuredAt: number, state: BedEntrySample['state'] = 'present'): BedEntrySample { + return { measuredAt, sourceKey, state } +} +function schedules(): ComfortSchedules { + return { timezone: 'America/Los_Angeles', + powers: DAYS_OF_WEEK.map((dayOfWeek, index) => ({ id: index, dayOfWeek, onTime: '22:00', offTime: '07:00', onTemperature: 75 })), + targets: [{ id: 9, dayOfWeek: 'saturday', time: '23:00', temperature: 70 }], + alarms: [{ id: 10, dayOfWeek: 'sunday', time: '06:30' }], + } +} +function state(): ComfortState { + return { ...defaultComfortState(), config: { mode: 'preview', minimumF: 70, maximumF: 95, sensorAware: true, followBedtime: true } } +} +function context(now: number, schedule = schedules()): ComfortContext { + return { now, window: comfortRoutineAt(schedule, now), + device: { observedAt: now, powered: true, waterAdequate: true, priming: false, vibrating: false, baseMoving: false, targetF: 75 }, + presence: { observedAt: now, available: true, occupied: true, entry: sample(now) }, + signals: { ...missingComfortSignals(), state: 'ready', sampleAt: now, expiresAt: now + MINUTE }, + eitherAway: false, routinesPaused: false, alarmOverride: false, owner: 'schedule', targetKnown: true } +} +function observedEntry(entryAt: number) { + const tracker = new BedEntryTracker() + tracker.observe(sample(entryAt - MINUTE, 'absent'), entryAt - MINUTE) + for (let minute = 0; minute <= 10; minute++) tracker.observe(sample(entryAt + minute * MINUTE), entryAt + minute * MINUTE) + return tracker +} + +describe('observed bed entry', () => { + it('requires an empty-to-person transition and ten minutes of advancing samples', () => { + const tracker = new BedEntryTracker() + for (let minute = 0; minute <= 20; minute++) expect(tracker.observe(sample(start + minute * MINUTE), start + minute * MINUTE)).toBeNull() + tracker.observe(sample(start + 21 * MINUTE, 'absent'), start + 21 * MINUTE) + for (let minute = 22; minute < 32; minute++) expect(tracker.observe(sample(start + minute * MINUTE), start + minute * MINUTE)).toBeNull() + expect(tracker.observe(sample(start + 32 * MINUTE), start + 32 * MINUTE)).toEqual({ entryAt: start + 22 * MINUTE, confirmedAt: start + 32 * MINUTE, sourceKey }) + }) + + it('never turns repeated frames into elapsed presence', () => { + const tracker = new BedEntryTracker() + tracker.observe(sample(start - MINUTE, 'absent'), start - MINUTE) + for (let second = 0; second <= 600; second += 5) expect(tracker.observe(sample(start), start + second * 1000)).toBeNull() + expect(tracker.observe(sample(start + 601000), start + 601000)).toBeNull() + }) + + it.each(['gap', 'source', 'backward', 'future', 'unknown', 'missing', 'clock'] as const)('discards unfinished evidence after %s', (change) => { + const tracker = observedEntry(start) + const checkedAt = start + 11 * MINUTE + const changed = sample(checkedAt) + if (change === 'source') changed.sourceKey = 'b'.repeat(64) + if (change === 'backward') changed.measuredAt = start + 9 * MINUTE + if (change === 'future') changed.measuredAt = checkedAt + 1 + if (change === 'unknown') changed.state = 'unknown' + expect(tracker.observe(change === 'missing' ? undefined : changed, change === 'gap' ? checkedAt + 90001 : change === 'clock' ? start : checkedAt)).toBeNull() + for (let minute = 12; minute <= 24; minute++) expect(tracker.observe(sample(start + minute * MINUTE), start + minute * MINUTE)).toBeNull() + }) +}) + +describe('bounded temperature-phase Preview', () => { + it.each([-90, -60, -20, 0, 20, 60, 90])('selects once for entry %i minutes from start and preserves power/alarm boundaries', (offset) => { + const entryAt = start + offset * MINUTE + const tracker = observedEntry(entryAt) + let result = bedtimePreview(state(), context(Math.max(start, entryAt + 10 * MINUTE)), tracker, true) + // Keep early-entry observations continuous until the actual power window starts. + if (offset < -10) { + const continuous = observedEntry(entryAt) + for (let current = entryAt + 11 * MINUTE; current <= start; current += MINUTE) continuous.observe(sample(current), current) + result = bedtimePreview(state(), context(start), continuous, true) + } + expect(result.status.state).toBe('ready') + expect(result.selection?.shiftMinutes).toBe(Math.max(-30, Math.min(30, offset))) + expect(result.window).toMatchObject({ start, end: start + 9 * 60 * MINUTE, alarmAt: start + 8.5 * 60 * MINUTE }) + const saved = { ...state(), bedtime: result.selection } + const later = context(start + 120 * MINUTE) + expect(bedtimePreview(saved, later, new BedEntryTracker(), true).selection).toEqual(result.selection) + }) + + it('shifts only intermediate phases, bounds against the original baseline, and never crosses the end', () => { + const tracker = observedEntry(start - 20 * MINUTE) + for (let minute = -9; minute <= 0; minute++) tracker.observe(sample(start + minute * MINUTE), start + minute * MINUTE) + const selected = bedtimePreview(state(), context(start), tracker, true).selection + const saved = { ...state(), bedtime: selected } + const current = context(start + 45 * MINUTE) + const preview = bedtimePreview(saved, current, new BedEntryTracker(), true) + expect(preview.status).toMatchObject({ state: 'ready', scheduledBaselineF: 75, phaseBaselineF: 70 }) + expect(decideComfort(saved, { ...current, window: preview.window, timing: preview.status })).toMatchObject({ eligible: true, proposedF: 74 }) + current.device.targetF = 72 + expect(decideComfort(saved, { ...current, window: preview.window, timing: preview.status })).toMatchObject({ eligible: false, proposedF: null }) + expect(bedtimePreview(saved, context(start + 9 * 60 * MINUTE), tracker, true).window).toBeNull() + }) + + it('requires guards before selecting and discards entry evidence during mechanical interference', () => { + const tracker = observedEntry(start) + const input = context(start + 10 * MINUTE) + expect(bedtimePreview(state(), input, tracker, false)).toMatchObject({ selection: undefined, status: { state: 'waiting' } }) + input.device.vibrating = true + expect(bedtimePreview(state(), input, tracker, true).selection).toBeUndefined() + input.device.vibrating = false + for (let minute = 11; minute <= 22; minute++) { + expect(bedtimePreview(state(), context(start + minute * MINUTE), tracker, true).selection).toBeUndefined() + } + }) + + it('delays a late phase without advancing the initial power event', () => { + const tracker = observedEntry(start + 20 * MINUTE) + const selected = bedtimePreview(state(), context(start + 30 * MINUTE), tracker, true).selection + const preview = bedtimePreview({ ...state(), bedtime: selected }, context(start + 75 * MINUTE), new BedEntryTracker(), true) + expect(preview.status).toMatchObject({ state: 'ready', scheduledBaselineF: 70, phaseBaselineF: 75 }) + expect(preview.window?.start).toBe(start) + }) + + it('clips shifts for a phase close to the power boundary instead of dropping it', () => { + const schedule = schedules() + schedule.targets[0].time = '22:10' + const tracker = observedEntry(start - 20 * MINUTE) + for (let minute = -9; minute <= 0; minute++) tracker.observe(sample(start + minute * MINUTE), start + minute * MINUTE) + const result = bedtimePreview(state(), context(start, schedule), tracker, true) + expect(result.selection?.shiftMinutes).toBe(-9) + expect(result.status.phaseBaselineF).toBe(75) + }) + + it('withholds changed routines or sensor identity, and out-of-window entry', () => { + const tracker = observedEntry(start) + const selected = bedtimePreview(state(), context(start + 10 * MINUTE), tracker, true).selection + const changed = schedules() + changed.targets[0].temperature = 71 + expect(bedtimePreview({ ...state(), bedtime: selected }, context(start + 11 * MINUTE, changed), tracker, true).status.state).toBe('withheld') + const input = context(start + 11 * MINUTE) + input.presence.entry = { ...sample(input.now), sourceKey: 'b'.repeat(64) } + expect(bedtimePreview({ ...state(), bedtime: selected }, input, tracker, true).status.state).toBe('withheld') + expect(bedtimePreview(state(), context(start + 101 * MINUTE), observedEntry(start + 91 * MINUTE), true).status.state).toBe('withheld') + }) + + it.each(['2026-03-08T06:00:00Z', '2026-11-01T05:00:00Z'])('uses actual elapsed scheduler events across DST at %s', (startTime) => { + const actualStart = Date.parse(startTime) + const schedule = schedules() + schedule.targets = [{ id: 20, dayOfWeek: 'sunday', time: '01:00', temperature: 70 }] + const tracker = observedEntry(actualStart + 20 * MINUTE) + const input = context(actualStart + 30 * MINUTE, schedule) + const preview = bedtimePreview(state(), input, tracker, true) + expect(preview.selection?.shiftMinutes).toBe(20) + expect(preview.window).toMatchObject({ start: input.window?.start, end: input.window?.end, alarmAt: input.window?.alarmAt }) + const phaseAt = input.window?.phases?.changes[0].at + if (phaseAt === undefined) throw new Error('Fixture requires a phase') + const later = context(phaseAt + 19 * MINUTE, schedule) + expect(bedtimePreview({ ...state(), bedtime: preview.selection }, later, tracker, true).status.phaseBaselineF).toBe(75) + later.now += MINUTE + later.presence.entry = sample(later.now) + expect(bedtimePreview({ ...state(), bedtime: preview.selection }, later, tracker, true).status.phaseBaselineF).toBe(70) + }) +}) diff --git a/src/comfort/tests/databaseFixture.ts b/src/comfort/tests/databaseFixture.ts new file mode 100644 index 000000000..0e49dafb5 --- /dev/null +++ b/src/comfort/tests/databaseFixture.ts @@ -0,0 +1,10 @@ +import path from 'node:path' +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' + +export function migratedComfortDatabase(filename = ':memory:') { + const database = new Database(filename) + migrate(drizzle(database), { migrationsFolder: path.resolve('src/db/migrations') }) + return database +} diff --git a/src/comfort/tests/forecastFixture.ts b/src/comfort/tests/forecastFixture.ts new file mode 100644 index 000000000..b40845f97 --- /dev/null +++ b/src/comfort/tests/forecastFixture.ts @@ -0,0 +1,19 @@ +import type { ThermalRecording } from '../thermal-dataset' + +/** Synthetic dynamic heat exchange exercises software only, never personal training or sleep claims. */ +export function forecastRecordings(count = 24, humidityCoupling = 0.04): ThermalRecording[] { + return Array.from({ length: count }, (_, nightIndex) => { + const start = Date.UTC(2026, 8, 1 + nightIndex, 20) + const date = new Date(start + 8 * 3_600_000).toISOString().slice(0, 10) + const targetF = 69 + nightIndex % 3 + let waterF = targetF + 2 + const samples = Array.from({ length: 97 }, (_, index) => { + const roomF = 73 + 2 * Math.sin(index / 12 + nightIndex % 3) + const humidity = 50 + 8 * Math.sin(index / 9 + nightIndex % 4) + const sample = { at: start + index * 300_000, context: { targetF, waterF, roomF, humidity } } + waterF += 0.16 * (targetF - waterF) + 0.05 * (roomF - waterF) + humidityCoupling * (humidity - 45) + return sample + }) + return { date, samples } + }) +} diff --git a/src/comfort/tests/instance.test.ts b/src/comfort/tests/instance.test.ts new file mode 100644 index 000000000..4bdf04f9a --- /dev/null +++ b/src/comfort/tests/instance.test.ts @@ -0,0 +1,149 @@ +import type Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DAYS_OF_WEEK } from '@/src/lib/scheduleTime' +import * as schema from '@/src/db/schema' +import { defaultComfortState } from '../policy' +import { migratedComfortDatabase } from './databaseFixture' + +const fixture = vi.hoisted(() => ({ + database: null as Database.Database | null, + monitor: vi.fn(), base: vi.fn(), occupancy: vi.fn(), controller: vi.fn(), bedroom: vi.fn(), synced: true, +})) +vi.mock('@/src/db', async () => { + const { drizzle: createDrizzle } = await import('drizzle-orm/better-sqlite3') + const schemaModule = await import('@/src/db/schema') + return { + get sqlite() { return fixture.database }, + get db() { + if (!fixture.database) throw new Error('Fixture not open') + return createDrizzle(fixture.database, { schema: schemaModule }) + }, + } +}) +vi.mock('@/src/hardware/dacMonitor.instance', () => ({ getDacMonitorIfRunning: fixture.monitor })) +vi.mock('@/src/hardware/base/instance', () => ({ getBaseControllerIfRunning: fixture.base })) +vi.mock('@/src/lib/occupancy', () => ({ getOccupancy: fixture.occupancy })) +vi.mock('@/src/temperature/instance', () => ({ getTemperatureControllerIfRunning: fixture.controller })) +vi.mock('@/src/hardware/sideMutations', () => ({ hasFirmwareSynced: () => fixture.synced })) +vi.mock('@/src/bedroom/instance', () => ({ getBedroom: fixture.bedroom })) +const { readComfortContext } = await import('../instance') +const { pauseComfortAfterManual, pendingManualPause } = await import('../manual') +const { createComfortStore } = await import('../store') +const now = Date.parse('2026-10-25T09:00:00Z') + +beforeEach(() => { + vi.useFakeTimers().setSystemTime(now) + vi.clearAllMocks() + fixture.database = migratedComfortDatabase() + fixture.synced = true + const database = drizzle(fixture.database) + database.insert(schema.deviceSettings).values({ timezone: 'America/Los_Angeles' }).run() + database.insert(schema.sideSettings).values([{ side: 'left', name: 'Left' }, { side: 'right', name: 'Right' }]).run() + database.insert(schema.deviceState).values([{ side: 'left', isPowered: true }, { side: 'right', isPowered: false }]).run() + database.insert(schema.powerSchedules).values(DAYS_OF_WEEK.map(dayOfWeek => ({ side: 'left' as const, dayOfWeek, onTime: '22:00', offTime: '07:00', onTemperature: 75 }))).run() + const side = { targetTemperature: 75, currentTemperature: 75, heatingDuration: 3600 } + fixture.monitor.mockReturnValue({ getFreshStatus: () => ({ leftSide: side, rightSide: side, waterLevel: 'ok', isPriming: false }), getLastPollAt: () => now - 1000 }) + fixture.base.mockReturnValue({ status: () => ({ state: 'connected', stale: false, lastUpdate: now - 500, moving: false, busy: false }) }) + fixture.occupancy.mockReturnValue({ level: { ageMs: 200 }, available: true, classification: 'person' }) + fixture.controller.mockReturnValue({ status: () => ({ source: 'schedule', targetTemperature: 75 }) }) + fixture.bedroom.mockReturnValue({ status: () => ({ aggregates: [ + { metric: 'temperature', status: 'ready', value: 74 }, { metric: 'humidity', status: 'ready', value: 50 }, + ] }), guidance: () => ({ state: 'ready', offsetF: 0, reason: 'Fixture', sourceIds: ['temperature', 'humidity'], expiresAt: now + 60000, configuration: 'c'.repeat(64) }) }) +}) +afterEach(() => { + fixture.database?.close() + vi.useRealTimers() +}) + +describe('live comfort input adapter', () => { + it('does not use an empty half as thermal exposure when a solo sleeper occupies the other half', () => { + fixture.database?.prepare('UPDATE device_settings SET bed_mode = \'solo-left\', unused_zone_mode = \'follow\'').run() + const state = defaultComfortState() + state.config.roomAware = true + const zone = (side: string) => ({ level: { active: side === 'right', ageMs: 100, sampleAt: now - 500, sourceKey: (side === 'left' ? 'a' : 'b').repeat(64) }, + available: true, classification: side === 'right' ? 'person' : 'none' }) + fixture.occupancy.mockImplementation(zone) + expect(readComfortContext('left', state, now).context).toMatchObject({ presence: { occupied: true }, thermalContext: null }) + fixture.occupancy.mockImplementation(() => ({ ...zone('left'), classification: 'person', level: { ...zone('left').level, active: true } })) + expect(readComfortContext('left', state, now).context.thermalContext).toEqual({ targetF: 75, waterF: 75, roomF: 74, humidity: 50 }) + fixture.occupancy.mockImplementation(() => ({ ...zone('left'), classification: 'person' })) + expect(readComfortContext('left', state, now).context.thermalContext).toBeNull() + }) + + it('uses both calibrated zones for a sole sleeper and invalidates one-zone entry evidence', () => { + fixture.database?.prepare('UPDATE device_settings SET bed_mode = \'solo-left\', unused_zone_mode = \'follow\'').run() + fixture.occupancy.mockImplementation((side: string) => ({ + level: { active: side === 'right', ageMs: 100, sampleAt: now - 500, sourceKey: (side === 'left' ? 'a' : 'b').repeat(64) }, + available: true, classification: side === 'right' ? 'person' : 'none', + })) + const presence = readComfortContext('left', defaultComfortState(), now).context.presence + expect(presence).toMatchObject({ occupied: true, available: true, entry: { state: 'present', measuredAt: now - 500 } }) + expect(presence.entry?.sourceKey).not.toBe('a'.repeat(64)) + expect(fixture.occupancy.mock.calls.map(call => call[0])).toEqual(['left', 'right']) + }) + + it('binds bed-entry evidence to calibrated level samples, never residual movement alone', () => { + const level = { active: true, ageMs: 200, sampleAt: now - 2000, sourceKey: 'a'.repeat(64) } + fixture.occupancy.mockReturnValue({ level, available: true, classification: 'person' }) + expect(readComfortContext('left', defaultComfortState(), now).context.presence.entry).toEqual({ measuredAt: now - 2000, sourceKey: level.sourceKey, state: 'present' }) + fixture.occupancy.mockReturnValue({ level: { ...level, active: false }, available: true, classification: 'person' }) + expect(readComfortContext('left', defaultComfortState(), now).context.presence.entry?.state).toBe('unknown') + fixture.occupancy.mockReturnValue({ level: { ...level, active: false }, available: true, classification: 'none' }) + expect(readComfortContext('left', defaultComfortState(), now).context.presence.entry?.state).toBe('absent') + fixture.occupancy.mockReturnValue({ level: { ageMs: 200 }, available: false, classification: 'person' }) + expect(readComfortContext('left', defaultComfortState(), now).context.presence.entry).toBeUndefined() + }) + + it('does not treat an unconfigured base as confirmed absent', () => { + fixture.base.mockReturnValue({ status: () => ({ state: 'unconfigured' }) }) + expect(readComfortContext('left', defaultComfortState(), now).context.device.baseMoving).toBeNull() + }) + + it('reads current reported targets, sensor age, routine and shared ownership without creating hardware', () => { + expect(readComfortContext('left', defaultComfortState(), now)).toMatchObject({ + timezone: 'America/Los_Angeles', context: { + window: { baselineF: 75 }, owner: 'schedule', targetKnown: true, + device: { observedAt: now - 1000, powered: true, waterAdequate: true, baseMoving: false, targetF: 75 }, + presence: { observedAt: now - 200, available: true, occupied: true }, + }, + }) + }) + + it('makes missing/future device data and base motion explicitly unavailable', () => { + fixture.monitor.mockReturnValue({ getFreshStatus: () => null, getLastPollAt: () => now + 1 }) + fixture.base.mockReturnValue(null) + expect(readComfortContext('left', defaultComfortState(), now).context.device).toMatchObject({ observedAt: null, powered: false, waterAdequate: null, baseMoving: null, targetF: null }) + }) + + it('never trusts persisted powered state before the first firmware report', () => { + fixture.synced = false + expect(readComfortContext('left', defaultComfortState(), now).context.device.powered).toBe(false) + }) + + it('uses either-side vibration and requires calibrated person presence', () => { + fixture.database?.prepare('UPDATE device_state SET is_alarm_vibrating = 1 WHERE side = \'right\'').run() + fixture.occupancy.mockReturnValue({ level: { ageMs: 90_001 }, available: false, classification: 'light' }) + expect(readComfortContext('left', defaultComfortState(), now).context).toMatchObject({ device: { vibrating: true }, presence: { occupied: false, available: false } }) + }) + + it('applies a wake window longer than thirty minutes and reflects edits within the minute', () => { + if (!fixture.database) throw new Error('Fixture not open') + drizzle(fixture.database).insert(schema.alarmSchedules).values({ side: 'left', dayOfWeek: 'sunday', time: '03:00', vibrationIntensity: 50, duration: 30, alarmTemperature: 80, wakeWindow: 90 }).run() + expect(readComfortContext('left', defaultComfortState(), now).context.alarmOverride).toBe(true) + fixture.database.prepare('UPDATE power_schedules SET on_temperature = 77').run() + expect(readComfortContext('left', defaultComfortState(), now).context.window?.baselineF).toBe(77) + }) + + it('journals manual intent, and does not throw when storage fails', () => { + if (!fixture.database) throw new Error('Fixture not open') + pauseComfortAfterManual('left') + expect(createComfortStore(fixture.database).read('left').pausedUntil).toBe(now + 6 * 3_600_000) + fixture.database.exec('CREATE TRIGGER reject_comfort BEFORE INSERT ON comfort_events BEGIN SELECT RAISE(ABORT, \'test\'); END') + expect(() => pauseComfortAfterManual('right')).not.toThrow() + expect(pendingManualPause('right')).toBe(now) + fixture.database.exec('DROP TRIGGER reject_comfort') + pauseComfortAfterManual('right') + expect(pendingManualPause('right')).toBeNull() + }) +}) diff --git a/src/comfort/tests/owner-runtime.test.ts b/src/comfort/tests/owner-runtime.test.ts new file mode 100644 index 000000000..993ae897d --- /dev/null +++ b/src/comfort/tests/owner-runtime.test.ts @@ -0,0 +1,63 @@ +import { expect, it, vi } from 'vitest' +import { withSideLock } from '@/src/hardware/sideLock' +import { beforeControlWrite } from '@/src/controls/context' +import { TemperatureController } from '@/src/temperature/controller' +import { createTemperatureApplicationRecorder } from '@/src/temperature/applications' +import { ComfortApplicationAdapter } from '../application' +import { ComfortRuntime } from '../runtime' +import { ownerFixture } from './ownerFixture' + +it('connects owner review, runtime learning and the single authority without creating Preview actions and withdraws consent on Stop', async () => { + const fixture = ownerFixture() + try { + const { owner, store, context, database } = fixture + await fixture.ready() + fixture.refresh(fixture.now() + 6 * 3_600_000 + 1) + context.device.powered = true + context.device.offConfirmed = false + context.device.targetF = 75 + context.window = { session: 'synthetic-current-night', segment: 'first', baselineF: 75, start: fixture.now() - 1000, + end: fixture.now() + 8 * 3_600_000, alarmAt: null } + fixture.configure({ offsetF: -2 }) + const applied = vi.fn(async (_side: unknown, target: number) => { + beforeControlWrite() + context.device.targetF = target + }) + const authority = new TemperatureController({ + now: fixture.now, withSideLock, readHold: () => null, writeHold: () => {}, + readBaseline: () => [{ id: 'routine', source: 'schedule', temperature: 75, startsAt: fixture.now() - 1000, + expiresAt: fixture.now() + 8 * 3_600_000, createdAt: fixture.now() - 1000, priority: 0 }], + isPowered: () => true, readCurrentTarget: () => context.device.targetF, + readHardwareDeadline: () => null, writeHardwareDeadline: () => {}, isBlocked: () => false, + connect: async () => {}, powerOff: async () => {}, publish: () => {}, apply: applied, + recordApplication: createTemperatureApplicationRecorder(database, fixture.now), + }) + const adapter = new ComfortApplicationAdapter({ now: fixture.now, monotonicNow: () => 1000, + store, authority, withSideLock, autopilotEnabled: fixture.deps.autopilotEnabled, admission: owner.admission, + pendingPause: () => null, readContext: () => context }) + const runtime = new ComfortRuntime({ + now: fixture.now, store, withSideLock, timezone: () => 'America/Los_Angeles', + pendingPause: () => null, acknowledgePause: () => {}, readContext: () => ({ context, timezone: 'America/Los_Angeles' }), + activation: { admitted: (side, state) => owner.admission(side, state) !== null, + revoke: owner.revoke, revokeLease: side => adapter.revoke(side), reconcile: side => adapter.reconcile(side) }, + }) + await runtime.evaluate('left') + expect(applied).not.toHaveBeenCalled() + const review = await owner.prepare({ side: 'left', purpose: 'activation', kind: null }) + await owner.confirm({ side: 'left', id: review.id, confirmed: true, note: 'Synthetic activation' }) + await runtime.evaluate('left') + expect(applied).toHaveBeenCalledTimes(1) + expect(applied).toHaveBeenLastCalledWith('left', 74) + expect(store.read('left')).toMatchObject({ lastTargetF: 74, lastAppliedAt: fixture.now(), application: { outcome: 'acknowledged' } }) + expect(store.history('left')[0]).toMatchObject({ kind: 'active' }) + expect(store.history('left')[0].preview).toBeUndefined() + // Existing native v1 validation requires available=false; current conveys the new owner grant. + expect(runtime.status('left').activation).toMatchObject({ available: false, current: true }) + await runtime.setMode('left', 'off') + expect(owner.admission('left', store.read('left'))).toBeNull() + expect(runtime.status('left').activation.available).toBe(false) + expect(runtime.status('left').activation.current).toBe(false) + await runtime.stop() + } + finally { fixture.database.close() } +}) diff --git a/src/comfort/tests/owner.test.ts b/src/comfort/tests/owner.test.ts new file mode 100644 index 000000000..58d8e97e1 --- /dev/null +++ b/src/comfort/tests/owner.test.ts @@ -0,0 +1,156 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { createComfortOwner } from '../owner' +import { ownerConfirmSchema, ownerPrepareSchema } from '../owner-contract' +import { ownerFixture } from './ownerFixture' + +const fixtures: ReturnType[] = [] +function fixture() { + const value = ownerFixture() + fixtures.push(value) + return value +} +afterEach(() => fixtures.splice(0).forEach(value => value.database.close())) + +describe('owner qualification and thermal activation', () => { + it('cannot activate saved Active settings, user-uploaded evidence, missing production identity or zero nights', async () => { + const { owner, store, configure, setIdentifiable } = fixture() + expect(ownerPrepareSchema.safeParse({ side: 'left', purpose: 'activation', kind: null, nights: 7 }).success).toBe(false) + expect(ownerConfirmSchema.safeParse({ side: 'left', id: randomUUID(), confirmed: true, note: '', snapshot: {} }).success).toBe(false) + configure({ config: { ...store.read('left').config, mode: 'active' } }) + expect(owner.admission('left', store.read('left'))).toBeNull() + await expect(owner.prepare({ side: 'left', purpose: 'activation', kind: null })).rejects.toThrow() + setIdentifiable(false) + expect((await owner.status('left')).identity).toBeNull() + await expect(owner.prepare({ side: 'left', purpose: 'qualification', kind: 'presence_empty' })).rejects.toThrow('identity') + }) + + it('records physical owner observations separately from fresh sensor and acknowledgment evidence', async () => { + const value = fixture() + await value.qualifyAll() + const status = await value.owner.status('left') + expect(status.qualifications).toHaveLength(6) + expect(status.canActivate).toBe(false) + expect(status.nights).toEqual([]) + expect(status.qualifications.find(record => record.kind === 'warming')?.evidence).toMatchObject({ powered: true, targetF: 77 }) + expect(value.store.read('left').lastAppliedAt).toBe(0) + }) + + it('requires correct presence, fresh idle telemetry and attended temperature evidence before a review', async () => { + const value = fixture() + await expect(value.owner.prepare({ side: 'left', purpose: 'qualification', kind: 'presence_occupied' })).rejects.toThrow('occupied') + await expect(value.owner.prepare({ side: 'left', purpose: 'qualification', kind: 'warming' })).rejects.toThrow('manual temperature') + value.context.device.observedAt = value.now() - 16_000 + await expect(value.qualify('presence_empty')).rejects.toThrow('fresh firmware') + value.refresh() + value.context.device.baseMoving = null + await expect(value.qualify('presence_empty')).rejects.toThrow('idle') + }) + + it('expires reviews, rejects cross-side confirmation and rechecks meaningful observations despite newer poll times', async () => { + const value = fixture() + const review = await value.owner.prepare({ side: 'left', purpose: 'qualification', kind: 'presence_empty' }) + await expect(value.owner.confirm({ side: 'right', id: review.id, confirmed: true, note: '' })).rejects.toThrow('not found') + value.refresh(value.now() + 1000) + await value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: 'Empty side observed' }) + await value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: 'Lost reply retry' }) + expect((await value.owner.status('left')).qualifications).toHaveLength(1) + const stale = await value.owner.prepare({ side: 'left', purpose: 'qualification', kind: 'presence_empty' }) + value.refresh(value.now() + 60_000) + await expect(value.owner.confirm({ side: 'left', id: stale.id, confirmed: true, note: '' })).rejects.toThrow('expired') + const changed = await value.owner.prepare({ side: 'left', purpose: 'qualification', kind: 'presence_empty' }) + value.context.device.targetF = 76 + await expect(value.owner.confirm({ side: 'left', id: changed.id, confirmed: true, note: '' })).rejects.toThrow('changed') + }) + + it('admits only after all checks and seven matching source-backed eligible nights, and retains evidence after source retention', async () => { + const value = fixture() + await value.ready() + expect(await value.owner.status('left')).toMatchObject({ canActivate: true, active: false, reasons: [] }) + const review = await value.owner.prepare({ side: 'left', purpose: 'activation', kind: null }) + await value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: 'Activate bounded thermal adjustments' }) + expect(value.store.read('left').config.mode).toBe('active') + expect(value.owner.admission('left', value.store.read('left'))?.revision).toBe(review.id) + value.database.exec('DELETE FROM comfort_events') + const restarted = createComfortOwner(value.deps) + expect(restarted.admission('left', value.store.read('left'))?.revision).toBe(review.id) + expect(value.store.read('left').lastAppliedAt).toBe(0) + value.refresh(value.now() + 181 * 86_400_000) + await value.night(1) + expect((await restarted.status('left')).nights).toHaveLength(7) + expect(restarted.admission('left', value.store.read('left'))?.revision).toBe(review.id) + value.setEnabled(false) + expect(restarted.admission('left', value.store.read('left'))).toBeNull() + value.setEnabled(true) + value.setRunning(false) + expect(restarted.admission('left', value.store.read('left'))).toBeNull() + }) + + it.each([ + { eligible: false }, { hardware: null }, { hardware: 'e'.repeat(64) }, { build: 'f'.repeat(40) }, { policy: { maximumF: 94 } }, + ])('does not qualify a reviewed night with incompatible or withheld evidence: %j', async (change) => { + const value = fixture() + await value.night(1, change) + expect((await value.reports.list('left')).ownerConfirmedNights).toBe(1) + expect((await value.owner.status('left')).nights).toHaveLength(0) + }) + + it('rechecks the exact night source before committing activation and rolls all writes back on failure', async () => { + const value = fixture() + await value.ready() + const review = await value.owner.prepare({ side: 'left', purpose: 'activation', kind: null }) + value.database.exec('CREATE TRIGGER reject_activation BEFORE INSERT ON comfort_activations BEGIN SELECT RAISE(ABORT, \'synthetic disk failure\'); END') + await expect(value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: '' })).rejects.toThrow('disk failure') + expect(value.store.read('left').config.mode).toBe('preview') + expect(value.database.prepare('SELECT consumed_at FROM comfort_owner_reviews WHERE id = ?').get(review.id)).toEqual({ consumed_at: null }) + value.database.exec('DROP TRIGGER reject_activation') + value.database.exec('UPDATE comfort_events SET event = replace(event, \'Synthetic preview.\', \'Changed after review.\') WHERE id = (SELECT MIN(id) FROM comfort_events)') + await expect(value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: '' })).rejects.toThrow('source changed') + expect(value.store.read('left').config.mode).toBe('preview') + }) + + it('withdraws a grant on identity changes, review changes or owner retest, without enabling other rules', async () => { + const value = fixture() + await value.ready() + const review = await value.owner.prepare({ side: 'left', purpose: 'activation', kind: null }) + await value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: '' }) + for (const field of ['hardware', 'calibration', 'schedule', 'build'] as const) { + const before = value.identity[field] + value.identity[field] = 'f'.repeat(field === 'build' ? 40 : 64) + expect(value.owner.admission('left', value.store.read('left'))).toBeNull() + value.identity[field] = before + } + const original = value.store.read('left').config + value.configure({ config: { ...original, maximumF: 94 } }) + expect(value.owner.admission('left', value.store.read('left'))).toBeNull() + value.configure({ config: original }) + expect(value.owner.admission('left', value.store.read('left'))).not.toBeNull() + const night = review.snapshot.nights[0] + await value.reports.review({ side: 'left', id: night.summary.id, digest: night.summary.digest, expectedReviewId: night.reviewId, + outcome: 'needs_retest', confirmedActualNight: false, note: 'Observation requires retest' }) + expect(value.owner.admission('left', value.store.read('left'))).toBeNull() + const check = review.snapshot.qualifications[0] + await value.owner.retest('left', check.kind, check.id) + expect(value.store.read('left').config.mode).toBe('preview') + expect(value.deps.revokeLease).toHaveBeenCalledWith('left') + await expect(value.owner.retest('left', check.kind, check.id)).rejects.toThrow('changed') + }) + + it('preserves uncertain application evidence and returns to Preview without declaring success or replaying', async () => { + const value = fixture() + const application = { requestId: randomUUID(), segment: 'night', targetF: 74, previousF: 75, createdAt: value.now() - 1000, + reservedAt: value.now() - 1000, finishedAt: null, receiptId: null, outcome: 'unknown' as const } + value.configure({ application, config: { ...value.store.read('left').config, mode: 'active' } }) + const review = await value.owner.prepare({ side: 'left', purpose: 'resolution', kind: null }) + await value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: 'Observed current target; unknown old result remains unknown.' }) + expect(value.store.read('left')).toMatchObject({ config: { mode: 'preview' }, lastAppliedAt: 0, lastTargetF: null, + pausedUntil: value.now() + 6 * 3_600_000 }) + expect(value.store.read('left').application).toBeUndefined() + const saved = value.database.prepare('SELECT snapshot FROM comfort_resolutions').get() as { snapshot: string } + expect(JSON.parse(saved.snapshot).application).toEqual(application) + const later = { ...application, requestId: randomUUID() } + value.configure({ application: later }) + await value.owner.confirm({ side: 'left', id: review.id, confirmed: true, note: '' }) + expect(value.store.read('left').application).toEqual(later) + }) +}) diff --git a/src/comfort/tests/ownerFixture.ts b/src/comfort/tests/ownerFixture.ts new file mode 100644 index 000000000..1b35d2a44 --- /dev/null +++ b/src/comfort/tests/ownerFixture.ts @@ -0,0 +1,112 @@ +import { randomUUID } from 'node:crypto' +import { vi } from 'vitest' +import type { BedroomComfortGuidance } from '@/src/bedroom/contract' +import { withSideLock } from '@/src/hardware/sideLock' +import { createComfortOwner } from '../owner' +import { createComfortStore, comfortEvent } from '../store' +import { defaultComfortState, type ComfortContext, type ComfortState } from '../policy' +import { capturePreviewEvidence, previewReportId } from '../report-evidence' +import { createPreviewReports } from '../reports' +import type { OwnerIdentity, QualificationKind } from '../owner-contract' +import { migratedComfortDatabase } from './databaseFixture' + +const DAY = 86_400_000 +/** Synthetic database only. No production identity overrides or hardware services are involved. */ +export function ownerFixture() { + const database = migratedComfortDatabase() + const store = createComfortStore(database) + let now = Date.parse('2026-11-05T18:00:00Z') + let running = true + let enabled = true + let identifiable = true + const identity: OwnerIdentity = { hardware: 'd'.repeat(64), calibration: 'c'.repeat(64), schedule: 'b'.repeat(64), build: 'a'.repeat(40), + policy: { minimumF: 70, maximumF: 95, sensorAware: false, followBedtime: false } } + const context: ComfortContext = { + now, window: null, + device: { observedAt: now, powered: false, offConfirmed: true, targetF: 75, waterAdequate: true, priming: false, vibrating: false, baseMoving: false }, + presence: { observedAt: now, available: true, occupied: false, entry: { measuredAt: now, sourceKey: identity.calibration, state: 'absent' } }, + eitherAway: false, routinesPaused: false, alarmOverride: false, owner: 'schedule', targetKnown: true, + } + const configure = (change: Partial) => store.commit('left', { ...store.read('left'), ...change }) + configure({ ...defaultComfortState(), config: { ...identity.policy, mode: 'preview' } }) + const revokeLease = vi.fn() + const deps = { database, store, now: () => now, running: () => running, autopilotEnabled: () => enabled, withSideLock, revokeLease, + identity: (_side: unknown, state: ComfortState) => identifiable + ? { ...identity, policy: { minimumF: state.config.minimumF, maximumF: state.config.maximumF, + sensorAware: state.config.sensorAware === true, followBedtime: state.config.followBedtime === true, roomAware: state.config.roomAware === true } } + : null, + context: () => context } + const owner = createComfortOwner(deps) + const reports = createPreviewReports(database, () => now) + function refresh(at = now) { + now = at + context.now = at + context.device.observedAt = at + context.presence.observedAt = at + if (context.presence.entry) context.presence.entry.measuredAt = at + } + function application(source: 'manual' | 'schedule', targetF: number) { + database.prepare(`INSERT INTO temperature_applications (attempt_id, side, attempted_at, finished_at, outcome, request) + VALUES (?, 'left', ?, ?, 'acknowledged', ?)`).run(randomUUID(), now - 120_001, now - 120_000, + JSON.stringify({ side: 'left', source, targetF, durationSec: null, reason: source === 'manual' ? 'manual' : 'reconcile', requestId: null })) + } + async function qualify(kind: QualificationKind) { + const review = await owner.prepare({ side: 'left', purpose: 'qualification', kind }) + await owner.confirm({ side: 'left', id: review.id, confirmed: true, note: 'Synthetic owner observation in an isolated test.' }) + return review + } + async function qualifyAll() { + await qualify('presence_empty') + context.presence.occupied = true + if (context.presence.entry) context.presence.entry.state = 'present' + await qualify('presence_occupied') + context.device.powered = true + context.device.targetF = 73 + application('manual', 73) + await qualify('cooling') + context.device.targetF = 77 + application('manual', 77) + await qualify('warming') + context.device.powered = false + configure({ pausedUntil: now + 6 * 3_600_000 }) + await qualify('manual_stop') + application('schedule', 75) + database.prepare(`INSERT INTO alarm_occurrences (side, schedule_id, original_at, effective_at, outcome, attempted_at) + VALUES ('left', 1, ?, ?, 'acknowledged', ?)`).run(now - 1000, now - 1000, now - 1000) + await qualify('offline_routine') + } + async function night(daysAgo: number, change: { eligible?: boolean, hardware?: string | null, build?: string, policy?: Partial, bedroom?: BedroomComfortGuidance } = {}) { + const start = now - daysAgo * DAY + const state = { ...defaultComfortState(), config: { ...identity.policy, ...change.policy, mode: 'preview' as const } } + const runId = randomUUID() + let id = '' + for (let minute = 0; minute <= 240; minute++) { + const at = start + minute * 60_000 + const input: ComfortContext = { ...context, now: at, + bedroom: change.bedroom ? { ...change.bedroom, expiresAt: at + 60000 } : undefined, + window: { start, end: start + 8 * 3_600_000, session: `synthetic:${start}`, segment: 'first', baselineF: 75, alarmAt: null, + phases: { revision: identity.schedule, startF: 75, changes: [] } }, + device: { ...context.device, observedAt: at, powered: true, targetF: 75 }, + presence: { observedAt: at, available: true, occupied: true, entry: { measuredAt: at, sourceKey: identity.calibration, state: 'present' } } } + const evidence = capturePreviewEvidence(state, input, 75, 'America/Los_Angeles', runId, + { environment: 'production', buildRevision: change.build ?? identity.build, hardwareIdentity: change.hardware === undefined ? identity.hardware : change.hardware }) + if (!evidence) throw new Error('Synthetic evidence is required') + id = previewReportId(evidence) + const event = { ...comfortEvent(at, 'preview', 'Synthetic preview.'), session: evidence.session, eligible: change.eligible ?? true, + proposedF: 74, reportedF: 75, deviceObservedAt: at, presenceObservedAt: at, preview: evidence } + database.prepare('INSERT INTO comfort_events (side, observed_at, event, report_id) VALUES (?, ?, ?, ?)').run('left', at, JSON.stringify(event), id) + } + const detail = await reports.detail('left', id) + await reports.review({ side: 'left', id, digest: detail.summary.digest, expectedReviewId: null, outcome: 'reviewed', + confirmedActualNight: true, note: 'Synthetic fixture; not an actual night.' }) + return id + } + async function ready() { + await qualifyAll() + for (let days = 7; days > 0; days--) await night(days) + } + return { database, store, owner, reports, identity, context, deps, configure, refresh, qualify, qualifyAll, night, ready, application, + now: () => now, setRunning: (value: boolean) => { running = value }, + setEnabled: (value: boolean) => { enabled = value }, + setIdentifiable: (value: boolean) => { identifiable = value } } +} diff --git a/src/comfort/tests/policy.test.ts b/src/comfort/tests/policy.test.ts new file mode 100644 index 000000000..96db3e7b3 --- /dev/null +++ b/src/comfort/tests/policy.test.ts @@ -0,0 +1,199 @@ +import { missingComfortSignals } from '../signals' +import { describe, expect, it } from 'vitest' +import { ADJUSTMENT_INTERVAL_MS, decideComfort, defaultComfortState, learnComfort, MANUAL_PAUSE_MS } from '../policy' +import type { ComfortContext, ComfortFeedback, ComfortState, RoutineWindow } from '../policy' + +const now = Date.parse('2026-10-25T05:00:00Z') +function activeState(): ComfortState { + return { ...defaultComfortState(), config: { mode: 'active', minimumF: 70, maximumF: 95 }, offsetF: -2 } +} +function context(): ComfortContext { + return { + now, window: { session: 'night-1', segment: 'night-1:bedtime', start: now - 60_000, end: now + 8 * 3_600_000, baselineF: 75, alarmAt: null }, + device: { observedAt: now, powered: true, waterAdequate: true, priming: false, vibrating: false, baseMoving: false, targetF: 75 }, + presence: { observedAt: now, available: true, occupied: true }, + eitherAway: false, routinesPaused: false, alarmOverride: false, owner: 'schedule', targetKnown: true, + } +} + +function routineWindow(input: ComfortContext): RoutineWindow { + if (!input.window) throw new Error('This fixture requires a routine window') + return input.window +} + +describe('personal comfort decisions', () => { + it('defaults off and moves at most one degree toward the bounded learned target', () => { + expect(decideComfort(defaultComfortState(), context())).toMatchObject({ eligible: false, proposedF: null }) + expect(decideComfort(activeState(), context())).toMatchObject({ eligible: true, baselineF: 75, proposedF: 74, pauseRequired: false }) + expect(decideComfort({ ...activeState(), offsetF: 3 }, context()).proposedF).toBe(76) + }) + + it('labels preview proposals without changing learner state', () => { + const state = activeState() + state.config.mode = 'preview' + const before = structuredClone(state) + expect(decideComfort(state, context())).toMatchObject({ eligible: true, proposedF: 74, reason: expect.stringContaining('Preview only') }) + expect(state).toEqual(before) + }) + + it.each([ + ['unknown water', (input: ComfortContext) => { input.device.waterAdequate = null }], + ['low water', (input: ComfortContext) => { input.device.waterAdequate = false }], + ['priming', (input: ComfortContext) => { input.device.priming = true }], + ['vibration', (input: ComfortContext) => { input.device.vibrating = true }], + ['base movement', (input: ComfortContext) => { input.device.baseMoving = true }], + ['power off', (input: ComfortContext) => { input.device.powered = false }], + ['missing presence calibration', (input: ComfortContext) => { input.presence.available = false }], + ['empty side', (input: ComfortContext) => { input.presence.occupied = false }], + ['away mode', (input: ComfortContext) => { input.eitherAway = true }], + ['paused routines', (input: ComfortContext) => { input.routinesPaused = true }], + ['alarm override', (input: ComfortContext) => { input.alarmOverride = true }], + ['manual owner', (input: ComfortContext) => { input.owner = 'manual' }], + ['run-once owner', (input: ComfortContext) => { input.owner = 'run-once' }], + ['no routine', (input: ComfortContext) => { input.window = null }], + ['future routine', (input: ComfortContext) => { routineWindow(input).start = now + 1 }], + ['ended routine', (input: ComfortContext) => { routineWindow(input).end = now }], + ['alarm half-hour', (input: ComfortContext) => { routineWindow(input).alarmAt = now + 30 * 60_000 }], + ['unknown target', (input: ComfortContext) => { input.device.targetF = null }], + ['invalid target', (input: ComfortContext) => { input.device.targetF = NaN }], + ['invalid clock', (input: ComfortContext) => { input.now = NaN }], + ['routine outside limits', (input: ComfortContext) => { routineWindow(input).baselineF = 65 }], + ])('withholds and revokes an old target on %s', (_label, change) => { + const input = context() + change(input) + expect(decideComfort(activeState(), input)).toMatchObject({ eligible: false, retainPreviousLease: false }) + }) + + it.each([null, NaN, Infinity, now + 1, now - 15_001])('rejects device timestamp %s', (observedAt) => { + const input = context() + input.device.observedAt = observedAt + expect(decideComfort(activeState(), input).eligible).toBe(false) + }) + + it.each([null, NaN, Infinity, now + 1, now - 90_001])('rejects presence timestamp %s', (observedAt) => { + const input = context() + input.presence.observedAt = observedAt + expect(decideComfort(activeState(), input).eligible).toBe(false) + }) + + it('requires a persisted six-hour pause for an unexplained target change', () => { + const input = { ...context(), targetKnown: false } + expect(decideComfort(activeState(), input)).toMatchObject({ eligible: false, pauseRequired: true }) + const state = { ...activeState(), pausedUntil: now + MANUAL_PAUSE_MS } + expect(decideComfort(state, context()).reason).toContain('six hours') + }) + + it('holds an acknowledged same-phase target during cooldown, but never after a guard failure', () => { + const input = context() + input.device.targetF = 74 + const state = { ...activeState(), lastAppliedAt: now - 60_000, lastTargetF: 74, lastSegment: routineWindow(input).segment } + expect(decideComfort(state, input)).toMatchObject({ eligible: false, proposedF: 73, retainPreviousLease: true }) + input.device.waterAdequate = false + expect(decideComfort(state, input).retainPreviousLease).toBe(false) + input.device.waterAdequate = true + state.lastSegment = 'a-different-phase' + expect(decideComfort(state, input).retainPreviousLease).toBe(false) + state.lastAppliedAt = now - ADJUSTMENT_INTERVAL_MS + expect(decideComfort(state, input).eligible).toBe(true) + state.lastAppliedAt = now + 1 + expect(decideComfort(state, input).retainPreviousLease).toBe(false) + }) + + it('cannot step toward a learned target through an out-of-band value', () => { + const input = context() + input.device.targetF = 68 + expect(decideComfort(activeState(), input).eligible).toBe(false) + input.device.targetF = 70 + routineWindow(input).baselineF = 70 + expect(decideComfort(activeState(), input).proposedF).toBe(70) + }) + + it.each(['learning', 'stale', 'review'] as const)('withholds sensor-aware correction for %s estimates', (state) => { + const input = context() + input.signals = { ...missingComfortSignals(), expiresAt: now + 10000, state, sampleAt: now, reason: 'Reviewing a Pod baseline.' } + const policy = activeState() + policy.config.sensorAware = true + expect(decideComfort(policy, input)).toMatchObject({ eligible: false, reason: input.signals.reason }) + }) + + it.each([null, NaN, Infinity, now + 1, now - 360_001])('withholds nominally ready but invalid vital time %s', (sampleAt) => { + const input = context() + input.signals = { ...missingComfortSignals(), expiresAt: now + 10000, state: 'ready', sampleAt, reason: 'Ready' } + const policy = activeState() + policy.config.sensorAware = true + expect(decideComfort(policy, input).eligible).toBe(false) + }) + + it.each([null, NaN, Infinity, now])('withholds expired or invalid proof deadline %s', (expiresAt) => { + const input = context(), policy = activeState() + policy.config.sensorAware = true + input.signals = { ...missingComfortSignals(), state: 'ready', sampleAt: now, expiresAt, reason: 'Ready' } + expect(decideComfort(policy, input).eligible).toBe(false) + }) + + it('admits fresh sensor-aware evidence only inside all other guards', () => { + const input = context() + input.signals = { ...missingComfortSignals(), expiresAt: now + 10000, state: 'ready', sampleAt: now, reason: 'Ready' } + const policy = activeState() + policy.config.sensorAware = true + expect(decideComfort(policy, input)).toMatchObject({ eligible: true, proposedF: 74 }) + input.presence.available = false + expect(decideComfort(policy, input).eligible).toBe(false) + }) + + it.each([ + { offsetF: 4 }, { offsetF: NaN }, { lastAppliedAt: NaN }, { pausedUntil: Infinity }, + { config: { mode: 'active' as const, minimumF: 96, maximumF: 70 } }, + ])('fails closed on invalid saved state %j', (change) => { + expect(decideComfort({ ...activeState(), ...change }, context()).eligible).toBe(false) + }) +}) + +function reports(comfort: ComfortFeedback['comfort']): ComfortFeedback[] { + return ['2026-10-21', '2026-10-22', '2026-10-23'].map(date => ({ date, comfort })) +} + +describe('personal comfort learning', () => { + it('learns one degree from three new dates and consumes them exactly once', () => { + const original = { ...activeState(), offsetF: 0 } + const learned = learnComfort(original, reports('warm'), 'night-1', 'America/Los_Angeles', now) + expect(learned.state).toMatchObject({ offsetF: -1, feedbackThrough: '2026-10-23', learnedSession: 'night-1' }) + expect(original.offsetF).toBe(0) + expect(learnComfort(learned.state, reports('warm'), 'night-1', 'America/Los_Angeles', now).message).toBeNull() + expect(learnComfort(learned.state, reports('warm'), 'night-2', 'America/Los_Angeles', now).state.offsetF).toBe(-1) + }) + + it('deduplicates dates and requires at least three, with a two-thirds majority', () => { + const original = { ...activeState(), offsetF: 0 } + const repeated = Array.from({ length: 9 }, () => reports('warm')[0]) + expect(learnComfort(original, repeated, 'night', 'UTC', now).message).toBeNull() + const mixed = reports('warm') + mixed[2].comfort = 'cold' + expect(learnComfort(original, mixed, 'night', 'UTC', now).state.offsetF).toBe(-1) + mixed[1].comfort = 'comfortable' + expect(learnComfort(original, mixed, 'night', 'UTC', now).state.offsetF).toBe(0) + }) + + it('bounds learned offsets and preserves the consumption cursor after reset', () => { + expect(learnComfort({ ...activeState(), offsetF: 3 }, reports('cold'), 'night', 'UTC', now).state.offsetF).toBe(3) + expect(learnComfort({ ...activeState(), offsetF: -3 }, reports('warm'), 'night', 'UTC', now).state.offsetF).toBe(-3) + const reset = { ...activeState(), offsetF: 0, feedbackThrough: '2026-10-23' } + expect(learnComfort(reset, reports('warm'), 'night', 'UTC', now).state.offsetF).toBe(0) + }) + + it('excludes malformed, old and future local dates', () => { + const feedback: ComfortFeedback[] = ['2026-02-30', '2026-10-01', '2026-10-26'].map(date => ({ date, comfort: 'cold' })) + const state = learnComfort(activeState(), feedback, 'night', 'UTC', now) + expect(state.message).toBeNull() + const midnight = Date.parse('2026-10-25T01:00:00Z') // still October 24 in Los Angeles + feedback.splice(0, 3, { date: '2026-10-23', comfort: 'warm' }, { date: '2026-10-24', comfort: 'warm' }, { date: '2026-10-25', comfort: 'warm' }) + expect(learnComfort(activeState(), feedback, 'night', 'America/Los_Angeles', midnight).message).toBeNull() + expect(learnComfort(activeState(), feedback, 'night', 'UTC', midnight).message).not.toBeNull() + }) + + it('does not learn while off, from an invalid cursor, or in an unknown timezone', () => { + expect(learnComfort(defaultComfortState(), reports('warm'), 'night', 'UTC', now).message).toBeNull() + expect(learnComfort({ ...activeState(), feedbackThrough: 'bad' }, reports('warm'), 'night', 'UTC', now).message).toBeNull() + expect(learnComfort(activeState(), reports('warm'), 'night', 'Unknown/Zone', now).message).toBeNull() + }) +}) diff --git a/src/comfort/tests/reports.test.ts b/src/comfort/tests/reports.test.ts new file mode 100644 index 000000000..fef878b4b --- /dev/null +++ b/src/comfort/tests/reports.test.ts @@ -0,0 +1,217 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import type { Side } from '@/src/hardware/types' +import { createComfortStore, comfortEvent } from '../store' +import { defaultComfortState, type ComfortContext } from '../policy' +import { capturePreviewEvidence, previewReportId, type PreviewOrigin } from '../report-evidence' +import { createPreviewReports } from '../reports' +import { migratedComfortDatabase } from './databaseFixture' + +const databases: ReturnType[] = [] +afterEach(() => databases.splice(0).forEach(database => database.close())) +const MINUTE = 60_000 +const DAY = 86_400_000 +const now = Date.parse('2026-10-26T18:00:00Z') +function harness() { + const database = migratedComfortDatabase() + databases.push(database) + const store = createComfortStore(database) + let clock = now + const reports = createPreviewReports(database, () => clock) + function record(start: number, options: { + side?: Side + minutes?: number[] + origin?: PreviewOrigin + timezone?: string + sourceKey?: string + runId?: string + alterContext?: (context: ComfortContext) => void } = {}) { + const side = options.side ?? 'left' + const runId = options.runId ?? randomUUID() + const state = { ...defaultComfortState(), config: { mode: 'preview' as const, minimumF: 70, maximumF: 95 } } + let id = '' + for (const minute of options.minutes ?? Array.from({ length: 241 }, (_, index) => index)) { + const at = start + minute * MINUTE + const context: ComfortContext = { now: at, + window: { start, end: start + 8 * 60 * MINUTE, session: `night:${start}`, segment: 'initial', baselineF: 75, alarmAt: null, + phases: { revision: 'b'.repeat(64), startF: 75, changes: [] } }, + device: { observedAt: at, targetF: 75, powered: true, waterAdequate: true, priming: false, vibrating: false, baseMoving: false }, + presence: { observedAt: at, available: true, occupied: true, + entry: { measuredAt: at, sourceKey: options.sourceKey ?? 'c'.repeat(64), state: 'present' } }, + eitherAway: false, routinesPaused: false, alarmOverride: false, targetKnown: true, owner: 'schedule' } + options.alterContext?.(context) + const evidence = capturePreviewEvidence(state, context, 75, options.timezone ?? 'America/Los_Angeles', runId, + options.origin ?? { environment: 'production', buildRevision: 'a'.repeat(40) }) + if (!evidence) throw new Error('Synthetic report fixture needs evidence') + id = previewReportId(evidence) + store.commit(side, state, { ...comfortEvent(at, 'preview', 'Preview only.'), session: evidence.session, + eligible: true, proposedF: 74, reportedF: 75, deviceObservedAt: at, presenceObservedAt: at, owner: 'schedule', preview: evidence }) + } + return id + } + async function confirm(id: string, side: Side = 'left') { + const report = await reports.detail(side, id) + return reports.review({ side, id, digest: report.summary.digest, expectedReviewId: report.review?.id ?? null, + outcome: 'reviewed', confirmedActualNight: true, note: 'Synthetic test confirmation, not an actual night.' }) + } + return { database, store, reports, record, confirm, setClock: (value: number) => { + clock = value + } } +} + +describe('Preview reports and owner review', () => { + it('does not turn legacy history, check-ins or baseline dates into a Preview night', async () => { + const { store, reports } = harness() + store.commit('left', defaultComfortState(), { ...comfortEvent(now - DAY, 'preview', 'Legacy observation'), session: 'legacy' }) + expect(await reports.list('left')).toMatchObject({ items: [], ownerConfirmedNights: 0, targetNights: 7, activationAvailable: false }) + }) + + it('summarizes and pages real journal rows, requiring explicit review before counting', async () => { + const { record, reports, confirm, store } = harness() + const id = record(now - DAY) + const first = await reports.detail('left', id) + expect(first.summary).toMatchObject({ observationCount: 241, occupiedObservationMs: 4 * 60 * MINUTE, + reviewable: true, eligibleCount: 241, sensorReadyCount: 0, gapCount: 1 }) + expect(first.summary.gaps[0]).toEqual({ from: now - DAY + 4 * 60 * MINUTE, to: now - DAY + 8 * 60 * MINUTE }) + expect(first.observations).toHaveLength(200) + expect(first.nextAfterId).not.toBeNull() + const next = await reports.detail('left', id, first.nextAfterId ?? 0) + expect(next.observations).toHaveLength(41) + expect(next.nextAfterId).toBeNull() + expect((await reports.list('left')).ownerConfirmedNights).toBe(0) + const reviewed = await confirm(id) + expect(await confirm(id)).toEqual(reviewed) + expect((await reports.list('left')).ownerConfirmedNights).toBe(1) + expect(store.read('left')).toMatchObject({ config: { mode: 'preview' }, lastAppliedAt: 0, lastTargetF: null }) + expect((await reports.list('right')).items).toEqual([]) + await expect(reports.detail('right', id)).rejects.toMatchObject({ code: 'NOT_FOUND' }) + }) + + it('cannot count two distant samples, sparse checks, or test/development origins as an overnight observation', async () => { + for (const options of [ + { minutes: [0, 240] }, { minutes: Array.from({ length: 241 }, (_, index) => index * 1.5) }, + { origin: { environment: 'test' as const, buildRevision: 'a'.repeat(40) } }, + { origin: { environment: 'development' as const, buildRevision: 'a'.repeat(40) } }, + { origin: { environment: 'production' as const, buildRevision: null } }, + ]) { + const { record, reports, confirm } = harness() + // Two-minute gaps exceed the allowed pairing interval; 90 seconds is the inclusive boundary. + if ('minutes' in options && options.minutes?.length === 241) options.minutes = options.minutes.map(value => value * 1.1) + const id = record(now - DAY, options) + const report = await reports.detail('left', id) + expect(report.summary.reviewable).toBe(false) + await expect(confirm(id)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + expect((await reports.list('left')).ownerConfirmedNights).toBe(0) + await reports.review({ side: 'left', id, digest: report.summary.digest, expectedReviewId: null, outcome: 'needs_retest', confirmedActualNight: false, note: 'Incomplete' }) + } + }) + + it('does not bridge runtime restarts or calibration changes when computing observation intervals', async () => { + const { record, reports } = harness() + const start = now - DAY + const id = record(start, { minutes: [0] }) + record(start, { minutes: [1], sourceKey: 'd'.repeat(64) }) + expect((await reports.detail('left', id)).summary.occupiedObservationMs).toBe(0) + }) + + it.each(['stale device', 'vibration', 'unknown base', 'stale frame', 'ambiguous level', 'empty'] as const)( + 'excludes %s observations from the occupied interval requirement', async (condition) => { + const { record, reports, confirm } = harness() + const id = record(now - DAY, { alterContext: (context) => { + if (condition === 'stale device') context.device.observedAt = context.now - 60_000 + if (condition === 'vibration') context.device.vibrating = true + if (condition === 'unknown base') context.device.baseMoving = null + if (condition === 'empty') context.presence.occupied = false + if (context.presence.entry) { + if (condition === 'stale frame') context.presence.entry.measuredAt = context.now - 60_000 + if (condition === 'ambiguous level') context.presence.entry.state = 'unknown' + } + } }) + expect((await reports.detail('left', id)).summary).toMatchObject({ occupiedObservationMs: 0, reviewable: false }) + await expect(confirm(id)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + }, + ) + + it('rejects a review when its journal changes while the report yields to other work', async () => { + const { record, reports, database } = harness() + const id = record(now - DAY, { minutes: Array.from({ length: 300 }, (_, index) => index) }) + const initial = await reports.detail('left', id) + const pending = reports.review({ side: 'left', id, digest: initial.summary.digest, expectedReviewId: null, + outcome: 'reviewed', confirmedActualNight: true, note: '' }) + // Parsing yields after 256 rows. Mutate the already-read source before its transaction. + database.prepare('DELETE FROM comfort_events WHERE id = (SELECT MIN(id) FROM comfort_events WHERE report_id = ?)').run(id) + await expect(pending).rejects.toMatchObject({ code: 'CONFLICT' }) + expect(database.prepare('SELECT COUNT(*) AS count FROM comfort_reviews').get()).toEqual({ count: 0 }) + }) + + it('keeps a prior review as history after retention expires without counting it as current evidence', async () => { + const { record, reports, confirm, setClock, database } = harness() + const id = record(now - DAY) + await confirm(id) + setClock(now + 31 * DAY) + expect(await reports.detail('left', id)).toMatchObject({ summary: { reviewable: false }, countsAsOwnerConfirmedNight: false }) + database.prepare('DELETE FROM comfort_events WHERE report_id = ?').run(id) + expect(await reports.detail('left', id)).toMatchObject({ summary: { state: 'source_expired' }, review: { outcome: 'reviewed' } }) + setClock(now + 181 * DAY) + expect((await reports.list('left')).items).toEqual([]) + }) + + it('detects a reversed observation clock even when every timestamp is in the past', async () => { + const { record, reports, confirm } = harness() + const id = record(now - DAY, { minutes: [0, 2, 1, ...Array.from({ length: 241 }, (_, index) => index + 3)] }) + expect((await reports.detail('left', id)).summary.issues).toContain('The observation clock repeated or moved backward; inspect before counting this night.') + await expect(confirm(id)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + }) + + it('keeps collecting sessions and future or reversed clocks out of night counts', async () => { + const { record, reports, confirm, setClock } = harness() + const id = record(now - 5 * 60 * MINUTE) + expect((await reports.detail('left', id)).summary.state).toBe('collecting') + await expect(confirm(id)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + setClock(now - DAY) + await expect(reports.detail('left', id)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }) + }) + + it('rejects changed report digests and stale review conclusions, then invalidates reviews when source changes', async () => { + const { record, reports, database, confirm } = harness() + const id = record(now - DAY) + const initial = await reports.detail('left', id) + await expect(reports.review({ side: 'left', id, digest: 'f'.repeat(64), expectedReviewId: null, + outcome: 'reviewed', confirmedActualNight: true, note: '' })).rejects.toMatchObject({ code: 'CONFLICT' }) + const saved = await confirm(id) + await expect(reports.review({ side: 'left', id, digest: initial.summary.digest, expectedReviewId: null, + outcome: 'needs_retest', confirmedActualNight: false, note: '' })).rejects.toMatchObject({ code: 'CONFLICT' }) + await reports.review({ side: 'left', id, digest: initial.summary.digest, expectedReviewId: saved.id, + outcome: 'needs_retest', confirmedActualNight: false, note: 'Review superseded.' }) + expect((await reports.list('left')).ownerConfirmedNights).toBe(0) + await confirm(id) + database.prepare('DELETE FROM comfort_events WHERE id = (SELECT MIN(id) FROM comfort_events WHERE report_id = ?)').run(id) + expect(await reports.detail('left', id)).toMatchObject({ reviewCurrent: false, countsAsOwnerConfirmedNight: false }) + database.prepare('DELETE FROM comfort_events WHERE report_id = ?').run(id) + expect(await reports.detail('left', id)).toMatchObject({ summary: { state: 'source_expired', reviewable: false }, reviewCurrent: false }) + }) + + it('requires explicit owner confirmation, counts distinct nights, and never unlocks Active at seven', async () => { + const { record, reports, confirm } = harness() + for (let day = 1; day <= 7; day++) await confirm(record(now - day * DAY)) + const list = await reports.list('left') + expect(list).toMatchObject({ ownerConfirmedNights: 7, activationAvailable: false }) + const id = list.items[0].summary.id + await expect(reports.review({ side: 'left', id, digest: list.items[0].summary.digest, expectedReviewId: list.items[0].review?.id ?? null, + outcome: 'reviewed', confirmedActualNight: false, note: '' })).rejects.toThrow('Confirm') + // Same observation window expressed in a different timezone remains one night. + await confirm(record(now - DAY, { timezone: 'Asia/Tokyo' })) + expect((await reports.list('left')).ownerConfirmedNights).toBe(7) + }) + + it('fails closed on malformed evidence and atomically leaves reviews unchanged on write failure', async () => { + const { record, reports, database, confirm } = harness() + const id = record(now - DAY) + database.exec('CREATE TRIGGER fail_review BEFORE INSERT ON comfort_reviews BEGIN SELECT RAISE(ABORT, \'test\'); END') + await expect(confirm(id)).rejects.toThrow('test') + expect((await reports.detail('left', id)).review).toBeNull() + database.exec('DROP TRIGGER fail_review') + database.prepare('UPDATE comfort_events SET event = ? WHERE id = (SELECT MIN(id) FROM comfort_events WHERE report_id = ?)').run('{bad', id) + await expect(reports.list('left')).rejects.toThrow() + }) +}) diff --git a/src/comfort/tests/routines.test.ts b/src/comfort/tests/routines.test.ts new file mode 100644 index 000000000..6ccb934a1 --- /dev/null +++ b/src/comfort/tests/routines.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { DAYS_OF_WEEK } from '@/src/lib/scheduleTime' +import { comfortRoutineAt, type ComfortSchedules } from '../routines' + +function schedules(): ComfortSchedules { + return { timezone: 'America/Los_Angeles', + powers: DAYS_OF_WEEK.map((dayOfWeek, index) => ({ id: index, dayOfWeek, onTime: '22:00', offTime: '07:00', onTemperature: 75 })), + targets: [{ id: 8, dayOfWeek: 'sunday', time: '01:00', temperature: 73 }], + alarms: [{ id: 9, dayOfWeek: 'sunday', time: '06:30' }], + } +} + +describe('personal comfort schedule adapter', () => { + it('uses actual next-day end events and current intermediate targets', () => { + const result = comfortRoutineAt(schedules(), Date.parse('2026-10-25T09:00:00Z')) + expect(result).toMatchObject({ start: Date.parse('2026-10-25T05:00:00Z'), end: Date.parse('2026-10-25T14:00:00Z'), baselineF: 73, alarmAt: Date.parse('2026-10-25T13:30:00Z') }) + }) + + it.each([ + ['2026-03-08T09:30:00Z', '2026-03-08T06:00:00Z', '2026-03-08T14:00:00Z'], + ['2026-11-01T10:30:00Z', '2026-11-01T05:00:00Z', '2026-11-01T15:00:00Z'], + ])('keeps local routine boundaries through DST at %s', (now, start, end) => { + expect(comfortRoutineAt(schedules(), Date.parse(now))).toMatchObject({ start: Date.parse(start), end: Date.parse(end) }) + }) + + it('does not invent Sunday OFF for a Saturday-only overnight row', () => { + const input = schedules() + input.powers = input.powers.filter(row => row.dayOfWeek === 'saturday') + expect(comfortRoutineAt(input, Date.parse('2026-10-25T06:00:00Z'))).toBeNull() + }) + + it.each([ + ['2026-10-25T09:00:00Z', '2026-10-25T05:00:00Z', '2026-10-25T14:00:00Z'], + ['2026-03-08T09:30:00Z', '2026-03-08T06:00:00Z', '2026-03-08T14:00:00Z'], + ['2026-11-01T10:30:00Z', '2026-11-01T05:00:00Z', '2026-11-01T15:00:00Z'], + ])('uses an explicitly imported Saturday overnight window through week-wrap/DST at %s', (now, start, end) => { + const input = schedules() + input.powers = input.powers.filter(row => row.dayOfWeek === 'saturday').map(row => ({ ...row, offDayOffset: 1 })) + expect(comfortRoutineAt(input, Date.parse(now))).toMatchObject({ start: Date.parse(start), end: Date.parse(end), baselineF: 73 }) + expect(comfortRoutineAt(input, Date.parse(end))).toBeNull() + }) + + it('withholds overlapping starts, duplicate starts, equal boundaries and malformed clocks', () => { + for (const onTime of ['21:30', '22:00', '07:00', '99:00']) { + const input = schedules() + input.powers.push({ id: 20, dayOfWeek: 'saturday', onTime, offTime: '07:00', onTemperature: 75 }) + expect(comfortRoutineAt(input, Date.parse('2026-10-25T06:00:00Z'))).toBeNull() + } + }) + + it('uses the same simultaneous power/temperature tie as shared arbitration', () => { + const input = schedules() + input.targets.push({ id: 20, dayOfWeek: 'saturday', time: '22:00', temperature: 80 }) + expect(comfortRoutineAt(input, Date.parse('2026-10-25T05:00:00Z'))?.baselineF).toBe(75) + }) + + it('withdraws exactly at the end and outside any bounded window', () => { + expect(comfortRoutineAt(schedules(), Date.parse('2026-10-25T14:00:00Z'))).toBeNull() + expect(comfortRoutineAt(schedules(), Date.parse('2026-10-25T20:00:00Z'))).toBeNull() + expect(comfortRoutineAt({ ...schedules(), timezone: 'bad-zone' }, Date.now())).toBeNull() + expect(comfortRoutineAt(schedules(), NaN)).toBeNull() + }) +}) diff --git a/src/comfort/tests/runtime.test.ts b/src/comfort/tests/runtime.test.ts new file mode 100644 index 000000000..01b402911 --- /dev/null +++ b/src/comfort/tests/runtime.test.ts @@ -0,0 +1,359 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Side } from '@/src/hardware/types' +import { withSideLock } from '@/src/hardware/sideLock' +import { ComfortRuntime, type ComfortRuntimeDeps } from '../runtime' +import { createComfortStore } from '../store' +import { defaultComfortState, MANUAL_PAUSE_MS, type ComfortContext } from '../policy' +import { missingComfortSignals } from '../signals' +import { migratedComfortDatabase } from './databaseFixture' + +const databases: ReturnType[] = [] +afterEach(() => { + databases.splice(0).forEach(database => database.close()) + vi.useRealTimers() +}) + +function harness() { + const database = migratedComfortDatabase() + databases.push(database) + const now = Date.parse('2026-10-25T10:00:00Z') + const store = createComfortStore(database) + const input: ComfortContext = { + now, window: { session: 'night-1', segment: 'night-1:bedtime', start: now - 3_600_000, end: now + 3_600_000, baselineF: 75, alarmAt: null }, + device: { observedAt: now, powered: true, waterAdequate: true, priming: false, vibrating: false, baseMoving: false, targetF: 75 }, + presence: { observedAt: now, available: true, occupied: true }, + eitherAway: false, routinesPaused: false, alarmOverride: false, owner: 'schedule', targetKnown: true, + } + const pending: Partial> = {} + const deps: ComfortRuntimeDeps = { + now: () => input.now, store, withSideLock, + readContext: () => ({ context: input, timezone: 'America/Los_Angeles' }), + timezone: () => 'America/Los_Angeles', + pendingPause: side => pending[side] ?? null, + acknowledgePause: (side) => { pending[side] = undefined }, + } + store.commit('left', { ...defaultComfortState(), offsetF: -2, config: { mode: 'preview', minimumF: 70, maximumF: 95 } }) + return { database, input, deps, store, pending, runtime: new ComfortRuntime(deps) } +} + +describe('production personal comfort Preview', () => { + it('collects qualified thermal research when HR is unavailable without making the proposal eligible', async () => { + const { input, store, deps } = harness() + if (!input.window) throw new Error('Routine fixture missing') + input.window.phases = { revision: 'b'.repeat(64), startF: 75, changes: [] } + input.presence.entry = { measuredAt: input.now, sourceKey: 'c'.repeat(64), state: 'present' } + input.signals = missingComfortSignals('Low signal') + input.thermalContext = { targetF: 75, waterF: 75, roomF: 74, humidity: 50 } + input.bedroom = { state: 'ready', offsetF: 0, reason: 'Fixture', sourceIds: ['temperature', 'humidity'], + expiresAt: input.now + 60000, configuration: 'e'.repeat(64) } + deps.origin = { environment: 'production', buildRevision: 'a'.repeat(40), hardwareIdentity: 'd'.repeat(64) } + const learning = vi.spyOn(store, 'learning') + store.commit('left', { ...store.read('left'), config: { ...store.read('left').config, sensorAware: true, roomAware: true } }) + const runtime = new ComfortRuntime(deps) + await runtime.evaluate('left') + expect(store.history('left')[0]).toMatchObject({ eligible: false, proposedF: null, + thermalContext: input.thermalContext, thermalObservation: { purpose: 'comfort_research' }, + preview: { sensorReady: false } }) + expect(runtime.status('left').decision).toMatchObject({ eligible: false, proposedF: null }) + expect(learning).toHaveBeenLastCalledWith('left', input.thermalContext, 'America/Los_Angeles', input.now) + expect(store.read('left')).toMatchObject({ lastAppliedAt: 0, lastTargetF: null }) + // A later manual change cannot silently enter the research dataset. + store.commit('left', { ...store.read('left'), pausedUntil: input.now + MANUAL_PAUSE_MS }) + input.now += 300000 + input.device.observedAt = input.now + input.presence.observedAt = input.now + input.presence.entry.measuredAt = input.now + input.bedroom.expiresAt = input.now + 60000 + await runtime.evaluate('left') + expect(store.history('left')[0].thermalContext).toBeNull() + expect(store.history('left')[0].thermalObservation).toBeUndefined() + runtime.status('left') + expect(learning).toHaveBeenLastCalledWith('left', null, 'America/Los_Angeles', input.now) + }) + + it('samples research context at most every five minutes without changing the control proposal', async () => { + const { input, store, runtime } = harness() + input.thermalContext = { targetF: 75, waterF: 75, roomF: 74, humidity: 50 } + await runtime.evaluate('left') + const firstDecision = runtime.status('left').decision?.proposedF + for (const elapsed of [60000, 120000, 300000]) { + input.now += elapsed + input.device.observedAt = input.now + input.presence.observedAt = input.now + await runtime.evaluate('left') + } + expect(store.history('left').filter(event => event.thermalContext)).toHaveLength(2) + expect(runtime.status('left').decision?.proposedF).toBe(firstDecision) + }) + + it('switches only mode after concurrent settings writes and preserves learning and manual pause', async () => { + const { runtime, store, pending, input } = harness() + pending.left = input.now + const config = { mode: 'off' as const, minimumF: 72, maximumF: 91, sensorAware: true, followBedtime: true } + await Promise.all([runtime.configure('left', config), runtime.setMode('left', 'preview')]) + expect(store.read('left')).toMatchObject({ config: { ...config, mode: 'preview' }, offsetF: -2, + pausedUntil: input.now + MANUAL_PAUSE_MS, lastAppliedAt: 0, lastTargetF: null }) + expect(store.read('right')).toEqual(defaultComfortState()) + expect(runtime.status('left').features.modeOnly).toBe(true) + const before = store.read('left') + await expect(runtime.setMode('left', 'active')).rejects.toThrow('Hardware qualification') + expect(store.read('left')).toEqual(before) + store.commit = () => { + throw new Error('Synthetic storage failure') + } + await expect(runtime.setMode('left', 'off')).rejects.toThrow('Synthetic storage failure') + expect(store.read('left')).toEqual(before) + }) + it('captures report provenance only for bounded Preview observations and changes the runtime identity after restart', async () => { + const { deps, input, store } = harness() + if (!input.window) throw new Error('Fixture requires a routine') + input.window.phases = { revision: 'b'.repeat(64), startF: 75, changes: [] } + input.presence.entry = { measuredAt: input.now, sourceKey: 'c'.repeat(64), state: 'present' } + deps.origin = { environment: 'production', buildRevision: 'a'.repeat(40) } + const runtime = new ComfortRuntime(deps) + await runtime.evaluate('left') + const first = store.history('left')[0].preview + expect(first).toMatchObject({ environment: 'production', buildRevision: 'a'.repeat(40), presence: 'occupied', + presenceSourceKey: 'c'.repeat(64), mechanicallyQuiet: true, deviceFresh: true, scheduledBaselineF: 75 }) + const restarted = new ComfortRuntime(deps) + await restarted.evaluate('left') + expect(store.history('left')[0].preview?.runId).not.toBe(first?.runId) + await restarted.configure('left', { ...store.read('left').config, mode: 'off' }) + const length = store.history('left').length + await restarted.evaluate('left') + expect(store.history('left')).toHaveLength(length) + await restarted.configure('left', { ...store.read('left').config, mode: 'preview' }) + input.window = null + await restarted.evaluate('left') + expect(store.history('left')[0].preview).toBeUndefined() + expect(store.read('left')).toMatchObject({ lastAppliedAt: 0, lastTargetF: null }) + }) + + it('persists a qualified phase choice, reuses it after restart, and withdraws on schedule edits or pauses', async () => { + const { deps, input, store, runtime } = harness() + const routine = input.window + if (!routine) throw new Error('Fixture requires a routine') + routine.start = input.now - 5 * 60000 + routine.phases = { revision: 'b'.repeat(64), startF: 75, + changes: [{ id: 'cool', at: routine.start + 30 * 60000, temperatureF: 70 }] } + const entryAt = input.now + const refresh = (now: number, occupied = true) => { + input.now = now + input.device.observedAt = now + input.presence = { observedAt: now, available: true, occupied, + entry: { measuredAt: now, sourceKey: 'a'.repeat(64), state: occupied ? 'present' : 'absent' } } + input.signals = { ...missingComfortSignals(), state: 'ready', sampleAt: now, expiresAt: now + 60000 } + } + await runtime.configure('left', { ...store.read('left').config, sensorAware: true, followBedtime: true }) + refresh(entryAt - 60000, false) + await runtime.evaluate('left') + for (let minute = 0; minute < 10; minute++) { + refresh(entryAt + minute * 60000) + await runtime.evaluate('left') + expect(store.read('left').bedtime).toBeUndefined() + } + refresh(entryAt + 10 * 60000) + await runtime.evaluate('left') + const selection = store.read('left').bedtime + expect(selection).toMatchObject({ entryAt, shiftMinutes: 5, session: routine.session }) + expect(store.history('left')[0].timing).toMatchObject({ state: 'ready', selection }) + expect(store.read('left')).toMatchObject({ lastAppliedAt: 0, lastTargetF: null }) + const restarted = new ComfortRuntime(deps) + refresh(entryAt + 11 * 60000) + await restarted.evaluate('left') + expect(restarted.status('left')).toMatchObject({ decision: { eligible: true }, timing: { selection } }) + expect(restarted.status('right').state?.bedtime).toBeUndefined() + store.commit('left', { ...store.read('left'), pausedUntil: input.now + MANUAL_PAUSE_MS }) + await restarted.evaluate('left') + expect(restarted.status('left').decision).toMatchObject({ eligible: false, reason: expect.stringContaining('six hours') }) + store.commit('left', { ...store.read('left'), pausedUntil: 0 }) + routine.phases.revision = 'c'.repeat(64) + await restarted.evaluate('left') + expect(restarted.status('left')).toMatchObject({ decision: { eligible: false }, timing: { state: 'withheld' } }) + expect(store.read('left').bedtime).toEqual(selection) + // A shifted phase outside the user range must not mask an unknown-target pause. + routine.phases.revision = 'b'.repeat(64) + routine.phases.startF = 60 + input.targetKnown = false + await restarted.evaluate('left') + expect(store.read('left').pausedUntil).toBe(input.now + MANUAL_PAUSE_MS) + }) + + it('does not publish a selected phase when its atomic journal write fails', async () => { + const { database, input, store, runtime } = harness() + const entryAt = input.now + if (!input.window) throw new Error('Fixture requires a routine') + input.window.start = entryAt + input.window.phases = { revision: 'b'.repeat(64), startF: 75, changes: [{ id: 'cool', at: entryAt + 30 * 60000, temperatureF: 70 }] } + await runtime.configure('left', { ...store.read('left').config, sensorAware: true, followBedtime: true }) + for (let minute = -1; minute <= 10; minute++) { + input.now = entryAt + minute * 60000 + input.device.observedAt = input.now + input.presence = { observedAt: input.now, available: true, occupied: minute >= 0, + entry: { measuredAt: input.now, sourceKey: 'a'.repeat(64), state: minute < 0 ? 'absent' : 'present' } } + input.signals = { ...missingComfortSignals(), state: 'ready', sampleAt: input.now, expiresAt: input.now + 60000 } + if (minute === 10) database.exec('CREATE TRIGGER reject_timing BEFORE INSERT ON comfort_events BEGIN SELECT RAISE(ABORT, \'test\'); END') + await runtime.evaluate('left') + } + expect(runtime.status('left')).toMatchObject({ decision: null, timing: null, fault: expect.any(String) }) + expect(store.read('left').bedtime).toBeUndefined() + database.exec('DROP TRIGGER reject_timing') + input.now += 5000 + input.device.observedAt = input.now + input.presence.observedAt = input.now + if (!input.presence.entry) throw new Error('Fixture requires an entry sample') + input.presence.entry.measuredAt = input.now + await runtime.evaluate('left') + expect(runtime.status('left').timing?.state).toBe('waiting') + expect(store.read('left').bedtime).toBeUndefined() + }) + + it('persists sensor Preview consent and evidence, expires status, and never acknowledges hardware', async () => { + const { runtime, store, input } = harness() + await runtime.configure('left', { ...store.read('left').config, sensorAware: true }) + input.signals = { ...missingComfortSignals(), state: 'ready', reason: 'Qualified comparison', + sampleAt: input.now - 60000, expiresAt: input.now + 2000, baselineHeartRate: 60, baselineDays: 7, baselineWindows: 84 } + await runtime.evaluate('left') + expect(runtime.status('left')).toMatchObject({ features: { sensorAware: true }, decision: { eligible: true }, signals: { baselineDays: 7 } }) + expect(store.history('left')[0].signals?.baselineHeartRate).toBe(60) + expect(store.read('left')).toMatchObject({ lastAppliedAt: 0, lastTargetF: null }) + input.now += 2000 + expect(runtime.status('left')).toMatchObject({ decision: null, signals: null }) + await runtime.evaluate('left') + expect(runtime.status('left').decision).toBeNull() + }) + + it('prepares history outside the side lock and rechecks manual control after preparation', async () => { + const { deps, store, input } = harness() + store.commit('left', { ...store.read('left'), config: { ...store.read('left').config, sensorAware: true } }) + let locked = false + deps.withSideLock = async (_side, work) => { + locked = true + try { + return await work() + } + finally { locked = false } + } + deps.prepareSignals = async () => { + expect(locked).toBe(false) + store.commit('left', { ...store.read('left'), pausedUntil: input.now + MANUAL_PAUSE_MS }) + } + const runtime = new ComfortRuntime(deps) + await runtime.evaluate('left') + expect(runtime.status('left').decision).toMatchObject({ eligible: false, reason: expect.stringContaining('six hours') }) + input.now += 10000 + expect(runtime.status('left')).toMatchObject({ evaluatedAt: input.now - 10000, decision: null }) + }) + + it('does not present an old eligible decision after a manual pause changes persisted state', async () => { + const { runtime, store, input } = harness() + await runtime.evaluate('left') + expect(runtime.status('left').decision?.eligible).toBe(true) + store.commit('left', { ...store.read('left'), pausedUntil: input.now + MANUAL_PAUSE_MS }) + expect(runtime.status('left')).toMatchObject({ decision: null, state: { pausedUntil: input.now + MANUAL_PAUSE_MS } }) + }) + it('records a bounded proposal with measurement times without claiming an applied target', async () => { + const { runtime, store, input } = harness() + await runtime.evaluate('left') + expect(runtime.status('left')).toMatchObject({ + evaluatedAt: input.now, decision: { eligible: true, proposedF: 74 }, + activation: { available: false }, state: { lastAppliedAt: 0, lastTargetF: null }, + }) + expect(store.history('left')[0]).toMatchObject({ kind: 'preview', proposedF: 74, reportedF: 75, owner: 'schedule', deviceObservedAt: input.now }) + }) + + it('rejects active and unsupported adapter settings, retaining existing consent', async () => { + const { runtime, store } = harness() + const config = store.read('left').config + await expect(runtime.configure('left', { ...config, mode: 'active' })).rejects.toThrow('qualification') + await expect(runtime.configure('left', { ...config, followBedtime: true })).rejects.toThrow('sensor guard') + expect(store.read('left').config).toEqual(config) + }) + + it('keeps migrated active settings inert and reports the missing qualification', async () => { + const { runtime, store } = harness() + const state = store.read('left') + store.commit('left', { ...state, config: { ...state.config, mode: 'active' } }) + await runtime.evaluate('left') + expect(runtime.status('left').decision).toMatchObject({ eligible: false, proposedF: null, reason: expect.stringContaining('qualification') }) + }) + + it('persists an unexpected-target pause across a new runtime and configuration changes', async () => { + const { runtime, deps, input, store } = harness() + input.targetKnown = false + await runtime.evaluate('left') + expect(store.read('left').pausedUntil).toBe(input.now + MANUAL_PAUSE_MS) + input.targetKnown = true + const restarted = new ComfortRuntime(deps) + await restarted.configure('left', { mode: 'preview', minimumF: 70, maximumF: 95 }) + await restarted.evaluate('left') + expect(restarted.status('left').decision).toMatchObject({ eligible: false, reason: expect.stringContaining('six hours') }) + }) + + it('recovers a failed manual journal before evaluating another proposal', async () => { + const { runtime, pending, input, store } = harness() + pending.left = input.now - 60_000 + await runtime.evaluate('left') + expect(pending.left).toBeUndefined() + expect(store.read('left').pausedUntil).toBe(input.now - 60_000 + MANUAL_PAUSE_MS) + expect(runtime.status('left').decision?.eligible).toBe(false) + }) + + it('withholds when journal writes fail, without acknowledging a proposal or consuming check-ins', async () => { + const { runtime, database, store } = harness() + database.exec('CREATE TRIGGER reject_comfort BEFORE INSERT ON comfort_events BEGIN SELECT RAISE(ABORT, \'test\'); END') + await runtime.evaluate('left') + expect(runtime.status('left')).toMatchObject({ decision: null, fault: expect.stringContaining('journal') }) + expect(store.read('left').learnedSession).toBe('') + database.exec('DROP TRIGGER reject_comfort') + await runtime.evaluate('left') + expect(runtime.status('left')).toMatchObject({ fault: null, decision: { eligible: true } }) + }) + + it('uses three unique check-ins once and preserves learning cursors across restart', async () => { + const { runtime, store, deps } = harness() + for (const date of ['2026-10-23', '2026-10-24', '2026-10-25']) await runtime.feedback('left', { date, comfort: 'warm' }) + await runtime.evaluate('left') + expect(store.read('left')).toMatchObject({ offsetF: -3, feedbackThrough: '2026-10-25', learnedSession: 'night-1' }) + await new ComfortRuntime(deps).evaluate('left') + expect(store.read('left').offsetF).toBe(-3) + }) + + it('validates feedback dates in the device timezone and leaves consumed edits consumed', async () => { + const { runtime, input, store } = harness() + input.now = Date.parse('2026-10-25T01:00:00Z') // Still October 24 in Los Angeles. + await expect(runtime.feedback('left', { date: '2026-10-25', comfort: 'cold' })).rejects.toThrow('local dates') + await expect(runtime.feedback('left', { date: '2026-10-01', comfort: 'warm' })).rejects.toThrow('local dates') + const state = store.read('left') + store.commit('left', { ...state, feedbackThrough: '2026-10-24' }) + await runtime.feedback('left', { date: '2026-10-24', comfort: 'cold' }) + expect(store.read('left').feedbackThrough).toBe('2026-10-24') + }) + + it('records changed guards immediately and limits unchanged observations to once a minute', async () => { + const { runtime, input, store } = harness() + await runtime.evaluate('left') + await runtime.evaluate('left') + expect(store.history('left')).toHaveLength(1) + input.device.priming = true + await runtime.evaluate('left') + expect(store.history('left')).toHaveLength(2) + input.now += 60_000 + input.device.observedAt = input.now + input.presence.observedAt = input.now + await runtime.evaluate('left') + expect(store.history('left')).toHaveLength(3) + }) + + it('runs without overlapping ticks and drains queued work before shutdown', async () => { + vi.useFakeTimers() + const { runtime, store } = harness() + await runtime.start() + await vi.advanceTimersByTimeAsync(15_000) + await runtime.stop() + const before = store.history('left') + await vi.advanceTimersByTimeAsync(60_000) + expect(runtime.status('left').runtime).toBe('stopped') + expect(store.history('left')).toEqual(before) + }) +}) diff --git a/src/comfort/tests/signal-reader.test.ts b/src/comfort/tests/signal-reader.test.ts new file mode 100644 index 000000000..51e7cb612 --- /dev/null +++ b/src/comfort/tests/signal-reader.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +import Database from 'better-sqlite3' +import { drizzle } from 'drizzle-orm/better-sqlite3' +import { migrate } from 'drizzle-orm/better-sqlite3/migrator' +import { fileURLToPath } from 'node:url' +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { ComfortSignalReader } from '../signal-reader' +import { HISTORY_DAY_MS, personalBaseline, type ComfortWindow } from '../signals' + +const databases: Database.Database[] = [] +afterEach(() => databases.splice(0).forEach(database => database.close())) +const now = Date.parse('2026-10-25T10:12:10Z') + +function fixture(days = 7, recentHeartRate = 60) { + const database = new Database(':memory:') + databases.push(database) + migrate(drizzle(database), { migrationsFolder: fileURLToPath(new URL('../../db/biometrics-migrations/', import.meta.url)) }) + const epoch = randomUUID(), currentSpan = randomUUID(), historyRun = randomUUID(), currentRun = randomUUID() + for (const run of [historyRun, currentRun]) database.prepare('INSERT INTO biometric_processing_runs (id,side,kind,bundle,config_hash,input_identity,started_at_ms) VALUES (?,\'left\',\'live\',?,?,?,?)') + .run(run, JSON.stringify({ schemaVersion: 1, manifestHash: 'b'.repeat(64) }), 'a'.repeat(64), + JSON.stringify({ sourceSide: 'left', timestampBasis: 'firmware_event_time_v1', mechanicalContext: 'mechanical-context-v1' }), now - 30 * HISTORY_DAY_MS) + const insert = (timestamp: number, span: string, run: string, heartRate = 60) => { + const id = Number(database.prepare('INSERT INTO vitals (side,timestamp,heart_rate,processing_run_id) VALUES (\'left\',?,?,?)').run(timestamp / 1000, heartRate, run).lastInsertRowid) + database.prepare('INSERT INTO vitals_quality (vitals_id,side,timestamp,quality_score,flags,created_at) VALUES (?,\'left\',?,0.9,?,?)') + .run(id, timestamp / 1000, JSON.stringify([`mechanical_context_v1:${epoch}:${span}:${timestamp - 31000}:${timestamp + 1000}`]), timestamp / 1000) + return id + } + const historicalSpans: string[] = [] + for (let day = 2; day < days + 2; day++) { + const start = Date.parse('2026-10-25T10:00:00Z') - day * HISTORY_DAY_MS, span = randomUUID() + historicalSpans.push(span) + database.prepare('INSERT INTO biometric_mechanical_spans VALUES (?,?,?,?)').run(span, epoch, start - 600000, start + 3600000) + for (let minute = 0; minute < 60; minute++) insert(start + minute * 60000 + 30000, span, historyRun) + } + const start = Date.parse('2026-10-25T10:00:00Z') + database.prepare('INSERT INTO biometric_mechanical_spans VALUES (?,?,?,?)').run(currentSpan, epoch, start - 600000, now) + for (let minute = 0; minute < 12; minute++) insert(start + minute * 60000 + 30000, currentSpan, currentRun, recentHeartRate) + const state = { version: 'mechanical-context-v1', epoch, publishedAtMs: now, confirmedThroughMs: now, + validUntilMs: now + 10000, blockedUntilMs: start - 600000, cleanSinceMs: start - 600000, + spanId: currentSpan, reasons: [], commandAtMs: null, commandUntilMs: 0 } + database.prepare('INSERT INTO biometric_mechanical_state VALUES (1,?)').run(JSON.stringify(state)) + const reader = new ComfortSignalReader(database) + return { database, reader, currentRun, historyRun, currentSpan, historicalSpans, state, insert, + async read(at = now, timezone = 'America/Los_Angeles') { + await reader.prepare('left', timezone, at) + return reader.read('left', timezone, at) + } } +} + +describe('qualified personal baseline with real migrated SQLite', () => { + it('distinguishes poor signal, quality, missing provenance and missing data without granting eligibility', async () => { + for (const [mutation, issue] of [ + ['UPDATE vitals_quality SET flags=json_insert(flags,\'$[#]\',\'low_signal\') WHERE vitals_id=(SELECT MAX(id) FROM vitals)', 'low_signal'], + ['UPDATE vitals_quality SET quality_score=0.1 WHERE vitals_id=(SELECT MAX(id) FROM vitals)', 'low_quality'], + ['UPDATE vitals SET processing_run_id=NULL WHERE id=(SELECT MAX(id) FROM vitals)', 'invalid_provenance'], + ['DELETE FROM vitals', 'missing_estimate'], + ['UPDATE vitals SET heart_rate=100 WHERE id=(SELECT MAX(id) FROM vitals)', 'unsupported_estimate'], + ]) { + const setup = fixture() + setup.database.exec(mutation) + expect(await setup.read()).toMatchObject({ state: 'stale', issue, sampleAt: null, recentHeartRates: [] }) + } + }) + it('names incomplete windows and storage failures without leaking query details', async () => { + const setup = fixture() + setup.database.prepare('DELETE FROM vitals WHERE timestamp=?').run(Date.parse('2026-10-25T10:06:30Z') / 1000) + expect(await setup.read()).toMatchObject({ state: 'stale', issue: 'incomplete_windows' }) + setup.database.exec('DROP TABLE vitals_quality') + expect(await setup.read()).toMatchObject({ state: 'stale', issue: 'storage_unavailable', reason: expect.not.stringContaining('vitals_quality') }) + }) + it('uses historical receipts from older quiet intervals while requiring the current interval for recent windows', async () => { + const setup = fixture() + expect(await setup.read()).toMatchObject({ state: 'ready', baselineDays: 7, baselineWindows: 84, + baselineHeartRate: 60, recentHeartRates: [60, 60], sampleAt: Date.parse('2026-10-25T10:10:00Z'), expiresAt: now + 10000 }) + setup.database.prepare('UPDATE biometric_mechanical_state SET payload=?').run(JSON.stringify({ ...setup.state, spanId: randomUUID() })) + expect(setup.reader.read('left', 'America/Los_Angeles', now).state).toBe('stale') + }) + + it('keeps insufficient history in learning and sustained deviations in review', async () => { + expect(await fixture(6).read()).toMatchObject({ state: 'learning', baselineDays: 6, baselineHeartRate: null }) + expect(await fixture(7, 75).read()).toMatchObject({ state: 'review', baselineHeartRate: 60, recentHeartRates: [75, 75] }) + expect(await fixture(7, 45).read()).toMatchObject({ state: 'review' }) + }) + + it.each(['legacy', 'replay', 'quality', 'duplicate quality', 'timestamp', 'low signal', 'manifest', 'physical side', 'config'])('does not learn from incompatible %s history', async (kind) => { + const setup = fixture(), database = setup.database + if (kind === 'legacy') database.prepare('UPDATE vitals SET processing_run_id=NULL WHERE processing_run_id=?').run(setup.historyRun) + if (kind === 'replay') database.prepare('UPDATE biometric_processing_runs SET kind=\'replay\' WHERE id=?').run(setup.historyRun) + if (kind === 'quality') database.prepare('DELETE FROM vitals_quality WHERE timestamp { + const setup = fixture() + expect((await setup.read()).state).toBe('ready') + // The first estimate also depends on signal immediately before the summary. + setup.database.prepare('UPDATE biometric_mechanical_spans SET start_ms=start_ms+600000 WHERE id=?').run(setup.historicalSpans[0]) + expect(await setup.read()).toMatchObject({ state: 'learning', baselineDays: 6 }) + }) + + it('revalidates cached span retention and changes in the numerical pipeline', async () => { + const setup = fixture() + expect((await setup.read()).state).toBe('ready') + setup.database.prepare('DELETE FROM biometric_mechanical_spans WHERE id=?').run(setup.historicalSpans[0]) + expect(await setup.read()).toMatchObject({ state: 'learning', baselineDays: 6 }) + setup.database.prepare('UPDATE biometric_processing_runs SET config_hash=? WHERE id=?').run('d'.repeat(64), setup.currentRun) + expect(await setup.read()).toMatchObject({ state: 'learning', baselineDays: 0 }) + }) + + it.each(['missing minute', 'duplicate minute', 'low quality', 'future', 'missing run', 'stale quiet state', 'motion', 'oversized bundle'])('withholds current %s readings without falling back to older ready evidence', async (kind) => { + const setup = fixture(), database = setup.database + if (kind === 'missing minute') database.prepare('DELETE FROM vitals WHERE timestamp=?').run(Date.parse('2026-10-25T10:06:30Z') / 1000) + if (kind === 'duplicate minute') setup.insert(Date.parse('2026-10-25T10:06:40Z'), setup.currentSpan, setup.currentRun) + if (kind === 'low quality') database.exec('UPDATE vitals_quality SET quality_score=0.1 WHERE vitals_id=(SELECT MAX(id) FROM vitals)') + if (kind === 'future') database.exec(`UPDATE vitals SET timestamp=${now / 1000 + 1} WHERE id=(SELECT MAX(id) FROM vitals)`) + if (kind === 'missing run') database.exec('UPDATE vitals SET processing_run_id=NULL WHERE id=(SELECT MAX(id) FROM vitals)') + if (kind === 'oversized bundle') database.prepare('UPDATE biometric_processing_runs SET bundle=? WHERE id=?').run(' '.repeat(65537), setup.currentRun) + if (kind === 'stale quiet state') setup.state.publishedAtMs -= 10000 + if (kind === 'motion') Object.assign(setup.state, { reasons: ['base_motion'], spanId: null }) + database.prepare('UPDATE biometric_mechanical_state SET payload=?').run(JSON.stringify(setup.state)) + expect((await setup.read()).state).toBe('stale') + }) + + it('expires prepared evidence on clock reversal, a delayed lock or timezone change', async () => { + const setup = fixture() + await setup.read() + expect(setup.reader.read('left', 'America/Los_Angeles', now + 10000).state).toBe('stale') + expect(setup.reader.read('left', 'America/Los_Angeles', now - 1000).state).toBe('stale') + expect(setup.reader.read('left', 'UTC', now).state).toBe('learning') + expect(setup.reader.read('right', 'UTC', now).state).toBe('stale') + }) +}) + +it('weights days equally, deduplicates summaries and excludes the latest 24 hours', () => { + const windows: ComfortWindow[] = [] + for (let day = 2; day < 9; day++) { + const start = Date.parse('2026-10-25T10:00:00Z') - day * HISTORY_DAY_MS + for (let index = 0; index < (day === 2 ? 100 : 12); index++) windows.push({ start: start + index * 300000, + end: start + (index + 1) * 300000, heartRate: day === 2 ? 90 : 60, spanId: 'fixture', epoch: 'fixture', evidenceStart: start - 300000, evidenceEnd: start + (index + 1) * 300000 }) + } + expect(personalBaseline([...windows, ...windows, { ...windows[0], start: now - 300000, end: now, heartRate: 90 }], 'America/Los_Angeles', now)) + .toMatchObject({ heartRate: 60, days: 7, windows: 172 }) +}) + +it('qualifies dates in the configured timezone instead of counting UTC dates as local nights', () => { + const windows: ComfortWindow[] = [] + for (let day = 0; day < 7; day++) { + const start = Date.parse('2026-10-10T00:00:00Z') + day * HISTORY_DAY_MS + for (const hour of [0, 23]) for (let index = 0; index < 6; index++) { + const at = start + hour * 3600000 + index * 300000 + windows.push({ start: at, end: at + 300000, evidenceStart: at - 300000, evidenceEnd: at + 300000, + heartRate: 60, spanId: 'fixture', epoch: 'fixture' }) + } + } + expect(personalBaseline(windows, 'UTC', now)).toMatchObject({ days: 7, heartRate: 60 }) + expect(personalBaseline(windows, 'America/Los_Angeles', now)).toMatchObject({ days: 6, heartRate: null }) +}) diff --git a/src/comfort/tests/store.test.ts b/src/comfort/tests/store.test.ts new file mode 100644 index 000000000..d3ab60438 --- /dev/null +++ b/src/comfort/tests/store.test.ts @@ -0,0 +1,107 @@ +import path from 'node:path' +import os from 'node:os' +import { mkdtempSync, rmSync } from 'node:fs' +import Database from 'better-sqlite3' +import { describe, expect, it, vi } from 'vitest' +import { createComfortStore, comfortEvent } from '../store' +import { defaultComfortState, MANUAL_PAUSE_MS } from '../policy' + +import { migratedComfortDatabase } from './databaseFixture' + +describe('personal comfort SQLite persistence', () => { + it('flushes command checkpoints with FULL sync and restores ordinary write mode, including failure', () => { + const database = migratedComfortDatabase() + try { + database.pragma('synchronous = NORMAL') + const observed: number[] = [] + const prepare = database.prepare.bind(database) + vi.spyOn(database, 'prepare').mockImplementation((sql) => { + if (sql.startsWith('INSERT INTO comfort_states')) observed.push(database.pragma('synchronous', { simple: true }) as number) + return prepare(sql) + }) + createComfortStore(database).commit('left', defaultComfortState(), undefined, true) + expect(observed).toEqual([2]) + expect(database.pragma('synchronous', { simple: true })).toBe(1) + database.exec('CREATE TRIGGER reject_sync BEFORE INSERT ON comfort_states BEGIN SELECT RAISE(ABORT, \'disk failure\'); END') + expect(() => createComfortStore(database).commit('right', defaultComfortState(), undefined, true)).toThrow('disk failure') + expect(database.pragma('synchronous', { simple: true })).toBe(1) + expect(() => database.transaction(() => createComfortStore(database).commit('right', defaultComfortState(), undefined, true))()).toThrow('independent') + } + finally { database.close() } + }) + it('retains settings, bed-entry selection, manual pause and consumed feedback through reopening', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'comfort-store-')) + const filename = path.join(directory, 'state.db') + let database = migratedComfortDatabase(filename) + try { + const store = createComfortStore(database) + expect(store.read('right').config.mode).toBe('off') + const state = { ...defaultComfortState(), pausedUntil: Date.now() + MANUAL_PAUSE_MS, feedbackThrough: '2026-10-24', offsetF: -2, + bedtime: { session: 'night-1', revision: 'b'.repeat(64), sourceKey: 'a'.repeat(64), entryAt: 1792922400000, confirmedAt: 1792923000000, shiftMinutes: -20 } } + store.commit('left', state, comfortEvent(Date.now(), 'manual_pause', 'Manual pause')) + database.close() + database = new Database(filename) + expect(createComfortStore(database).read('left')).toEqual(state) + expect(createComfortStore(database).history('left')[0].kind).toBe('manual_pause') + } + finally { + database.close() + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('does not replace corrupt state with defaults or accept invalid dates', () => { + const database = migratedComfortDatabase() + try { + database.prepare('INSERT INTO comfort_states VALUES (?, ?)').run('left', '{broken') + expect(() => createComfortStore(database).read('left')).toThrow() + expect(() => createComfortStore(database).saveFeedback('right', { date: '2026-02-30', comfort: 'cold' }, comfortEvent(1, 'feedback', 'bad'))).toThrow() + expect(createComfortStore(database).feedback('right')).toEqual([]) + } + finally { database.close() } + }) + + it('updates one check-in per local date and prunes old observations without dropping state', () => { + const database = migratedComfortDatabase() + try { + const store = createComfortStore(database) + const now = Date.parse('2026-10-25T12:00:00Z') + store.commit('left', defaultComfortState(), comfortEvent(now - 31 * 86_400_000, 'preview', 'old')) + store.saveFeedback('left', { date: '2026-10-24', comfort: 'warm' }, comfortEvent(now, 'feedback', 'warm')) + store.saveFeedback('left', { date: '2026-10-24', comfort: 'cold' }, comfortEvent(now + 1, 'feedback', 'cold')) + store.prune(now) + expect(store.feedback('left')).toEqual([{ date: '2026-10-24', comfort: 'cold' }]) + expect(store.history('left')).toHaveLength(2) + expect(store.read('left')).toEqual(defaultComfortState()) + } + finally { database.close() } + }) + + it('retains sparse thermal observations for 90 days while ordinary history expires after 30', () => { + const database = migratedComfortDatabase() + try { + const store = createComfortStore(database) + const now = Date.parse('2026-10-25T12:00:00Z') + const thermalContext = { targetF: 70, waterF: 71, roomF: 75, humidity: 50 } + for (const days of [31, 91]) { + store.commit('left', defaultComfortState(), { ...comfortEvent(now - days * 86400000, 'preview', 'thermal'), thermalContext }) + } + store.prune(now) + expect(store.history('left')).toHaveLength(1) + expect(store.history('left')[0].thermalContext).toEqual(thermalContext) + } + finally { database.close() } + }) + + it('rolls state back when the event insert fails', () => { + const database = migratedComfortDatabase() + try { + const store = createComfortStore(database) + store.commit('left', defaultComfortState()) + database.exec('CREATE TRIGGER reject_comfort BEFORE INSERT ON comfort_events BEGIN SELECT RAISE(ABORT, \'test\'); END') + expect(() => store.commit('left', { ...defaultComfortState(), offsetF: 2 }, comfortEvent(1, 'preview', 'test'))).toThrow() + expect(store.read('left').offsetF).toBe(0) + } + finally { database.close() } + }) +}) diff --git a/src/comfort/tests/thermal-dataset.test.ts b/src/comfort/tests/thermal-dataset.test.ts new file mode 100644 index 000000000..9fa9a83f2 --- /dev/null +++ b/src/comfort/tests/thermal-dataset.test.ts @@ -0,0 +1,114 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { thermalDataset, thermalDatasetAudit } from '../thermal-dataset' +import { migratedComfortDatabase } from './databaseFixture' +import { createComfortStore, comfortEvent } from '../store' +import { defaultComfortState } from '../policy' +import { createPreviewReports } from '../reports' + +const databases: ReturnType[] = [] +afterEach(() => databases.splice(0).forEach(database => database.close())) +const start = Date.parse('2026-10-20T22:00:00Z'), end = start + 8 * 3600000, now = end + 86400000 + +async function fixture(options: { environment?: 'test' | 'production', eligible?: boolean, research?: boolean, variableTarget?: boolean, side?: 'left' | 'right', sampleAt?: (index: number) => number, sampleCount?: number, reviewed?: boolean, sleepWindowEnd?: number } = {}) { + const database = migratedComfortDatabase() + databases.push(database) + const store = createComfortStore(database) + const state = defaultComfortState(), side = options.side ?? 'left' + const runId = randomUUID() + for (let index = 0; index < (options.sampleCount ?? 480); index++) { + const at = options.sampleAt?.(index) ?? start + index * 60000 + store.commit(side, state, { ...comfortEvent(at, 'preview', 'Synthetic fixture'), + eligible: options.eligible ?? true, session: 'night', + thermalContext: { targetF: options.variableTarget ? 70 + index % 5 : 70, waterF: 70, roomF: 75, humidity: 50 }, + ...(options.research ? { thermalObservation: { version: 1 as const, purpose: 'comfort_research' as const, bedroomConfiguration: 'e'.repeat(64), sleepWindowEnd: options.sleepWindowEnd ?? end } } : {}), + preview: { format: 1, runId, environment: options.environment ?? 'production', buildRevision: 'a'.repeat(40), + hardwareIdentity: 'b'.repeat(64), session: 'night', start, end, timezone: 'UTC', scheduleRevision: 'c'.repeat(64), + policy: { minimumF: 69, maximumF: 71, sensorAware: true, followBedtime: false, offsetF: 0 }, + ...(options.research ? { bedroom: { state: 'ready' as const, offsetF: 0 as const, reason: 'Fixture', sourceIds: ['temperature', 'humidity'], expiresAt: at + 60000, configuration: 'e'.repeat(64) } } : {}), + scheduledBaselineF: 70, decisionBaselineF: 70, presence: 'occupied', presenceSourceKey: 'd'.repeat(64), + mechanicallyQuiet: true, deviceFresh: true, sensorReady: true, manualPaused: false }, + }) + } + store.saveFeedback('left', { date: '2026-10-21', comfort: 'warm' }, comfortEvent(now, 'feedback', 'Fixture')) + const confirm = async () => { + const reports = createPreviewReports(database, () => now) + const item = (await reports.list(side)).items[0] + if (item?.summary.reviewable) await reports.review({ side, id: item.summary.id, digest: item.summary.digest, + expectedReviewId: item.review?.id ?? null, outcome: 'reviewed', confirmedActualNight: true, note: 'Synthetic full-night fixture' }) + } + if (options.reviewed !== false) await confirm() + return { database, store, confirm } +} +describe('thermal learning observation provenance', () => { + it('joins one actual night with a morning check-in, preserving side and thermal context', async () => { + const { database } = await fixture() + expect(thermalDataset(database, 'left', now)).toEqual([{ date: '2026-10-21', correction: -1, targetF: 70, waterF: 70, roomF: 75, humidity: 50 }]) + expect(thermalDataset(database, 'right', now)).toEqual([]) + }) + it('rejects test origins, withheld proposals, unmatched sides and ambiguous temperature curves', async () => { + for (const options of [{ environment: 'test' as const }, { eligible: false }, { variableTarget: true }, { side: 'right' as const }]) { + expect(thermalDataset((await fixture(options)).database, 'left', now)).toEqual([]) + } + }) + it('does not train on an unfinished night or substitute check-ins alone for sensor data', async () => { + const { database, store } = await fixture() + expect(thermalDataset(database, 'left', end - 1)).toEqual([]) + const report = store.learning?.('left', null, 'UTC', now) + expect(report).toMatchObject({ nights: 1, suggestedOffsetF: null }) + database.prepare('DELETE FROM comfort_events').run() + expect(thermalDataset(database, 'left', now)).toEqual([]) + }) + it('learns from separately qualified thermal observations while heart-rate proposals remain withheld', async () => { + const { database } = await fixture({ eligible: false, research: true }) + expect(thermalDataset(database, 'left', now)).toHaveLength(1) + const rows = database.prepare('SELECT event FROM comfort_events WHERE json_extract(event,\'$.kind\')=\'preview\'').all() as { event: string }[] + expect(rows.every(row => JSON.parse(row.event).eligible === false)).toBe(true) + }) + it('rejects four-hour fragments even with a real owner review', async () => { + const { database } = await fixture({ sampleCount: 250 }) + const result = thermalDatasetAudit(database, 'left', now) + expect(result.nights).toEqual([]) + expect(result.collection.excluded[0].reason).toContain('six hours') + }) + it('rejects missing edges, isolated late samples, and a long interior gap', async () => { + for (const sampleAt of [ + (index: number) => start + 40 * 60000 + index * 60000, + (index: number) => start + index * 60000 + (index > 230 ? 40 * 60000 : 0), + (index: number) => start + index * 30000 + (index === 479 ? 4 * 3600000 : 0), + ]) { + const { database } = await fixture({ sampleAt, reviewed: false }) + expect(thermalDataset(database, 'left', now)).toEqual([]) + } + }) + it('requires a current owner review and rejects edits after that review', async () => { + const { database, confirm } = await fixture({ reviewed: false }) + expect(thermalDatasetAudit(database, 'left', now).collection.excluded[0].reason).toContain('Review this night') + await confirm() + expect(thermalDataset(database, 'left', now)).toHaveLength(1) + database.prepare('UPDATE comfort_events SET event=json_set(event,\'$.message\',\'Corrected source\') WHERE observed_at = ?').run(start) + expect(thermalDataset(database, 'left', now)).toEqual([]) + await confirm() + expect(thermalDataset(database, 'left', now)).toHaveLength(1) + database.prepare('UPDATE comfort_reviews SET created_at = ?').run(now + 1) + expect(thermalDataset(database, 'left', now)).toEqual([]) + }) + it('uses the captured pre-alarm window and never admits wake warming', async () => { + const { database } = await fixture({ research: true, eligible: false, sampleCount: 450, sleepWindowEnd: end - 30 * 60000 }) + expect(thermalDataset(database, 'left', now)).toHaveLength(1) + database.prepare('UPDATE comfort_events SET event=json_set(event,\'$.thermalObservation.sleepWindowEnd\',?)').run(start + 5 * 3600000) + expect(thermalDataset(database, 'left', now)).toEqual([]) + }) + it('rejects sparse observations and source changes even when they have enough sample rows', async () => { + for (const modification of [ + 'DELETE FROM comfort_events WHERE observed_at > ' + (start + 60 * 60000) + ' AND json_extract(event,\'$.kind\')=\'preview\'', + 'UPDATE comfort_events SET event=json_set(event,\'$.at\',json_extract(event,\'$.at\')+900000) WHERE observed_at=' + (start + 48 * 300000), + 'UPDATE comfort_events SET event=json_set(event,\'$.preview.presenceSourceKey\',\'' + 'f'.repeat(64) + '\') WHERE observed_at=' + start, + 'UPDATE comfort_events SET event=json_set(event,\'$.thermalObservation.bedroomConfiguration\',\'' + 'f'.repeat(64) + '\') WHERE observed_at=' + start, + ]) { + const { database } = await fixture({ eligible: false, research: true }) + database.exec(modification) + expect(thermalDataset(database, 'left', now)).toEqual([]) + } + }) +}) diff --git a/src/comfort/tests/thermal-forecast-input.test.ts b/src/comfort/tests/thermal-forecast-input.test.ts new file mode 100644 index 000000000..693d31329 --- /dev/null +++ b/src/comfort/tests/thermal-forecast-input.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { comfortEvent } from '../store' +import { previousForecastInput } from '../thermal-forecast-input' +import { thermalConfiguration } from '../thermal-dataset' + +function fixture() { + const at = Date.parse('2026-10-30T22:05:00Z') + const context = { targetF: 70, waterF: 71, roomF: 73, humidity: 50 } + const previous = { ...comfortEvent(at, 'preview', 'Synthetic observation'), eligible: false, session: 'night', thermalContext: context, + thermalObservation: { version: 1 as const, purpose: 'comfort_research' as const, bedroomConfiguration: 'e'.repeat(64) }, + preview: { format: 1 as const, runId: '00000000-0000-4000-8000-000000000001', environment: 'production' as const, + buildRevision: 'a'.repeat(40), hardwareIdentity: 'b'.repeat(64), session: 'night', start: at - 300_000, + end: at + 8 * 3_600_000, timezone: 'UTC', scheduleRevision: 'c'.repeat(64), + policy: { minimumF: 69, maximumF: 71, sensorAware: true, followBedtime: false, roomAware: true, offsetF: 0 }, + bedroom: { state: 'ready' as const, offsetF: 0 as const, reason: 'Fixture', sourceIds: ['temperature', 'humidity'], expiresAt: at + 60_000, configuration: 'e'.repeat(64) }, + scheduledBaselineF: 70, decisionBaselineF: 70, presence: 'occupied' as const, presenceSourceKey: 'd'.repeat(64), + mechanicallyQuiet: true, deviceFresh: true, sensorReady: false, manualPaused: false } } + const current = { at: at + 300_000, session: 'night', context: { ...context, waterF: 71.5 }, + configuration: thermalConfiguration(previous.preview, 'e'.repeat(64)) } + return { previous, current } +} +describe('live water forecast input', () => { + it('uses an actual matching predecessor without requiring HR labels', () => { + const { previous, current } = fixture() + expect(previousForecastInput(previous, current, previous.at)).toEqual({ ...current.context, change5mF: 0.5 }) + }) + it('rejects a different session, source, target, clock or database timestamp', () => { + const { previous, current } = fixture() + for (const changed of [{ ...current, session: 'another' }, { ...current, configuration: 'f'.repeat(64) }, + { ...current, at: current.at + 60_000 }, { ...current, at: previous.at - 1 }, + { ...current, context: { ...current.context, targetF: 71 } }]) { + expect(previousForecastInput(previous, changed, previous.at)).toBeNull() + } + expect(previousForecastInput(previous, current, previous.at + 1)).toBeNull() + }) + it('rejects manually paused, stale, disturbed or test-origin predecessors', () => { + const { previous, current } = fixture() + for (const changes of [{ manualPaused: true }, { deviceFresh: false }, { mechanicallyQuiet: false }, { environment: 'test' }]) { + expect(previousForecastInput({ ...previous, preview: { ...previous.preview, ...changes } }, current, previous.at)).toBeNull() + } + expect(previousForecastInput({ ...previous, thermalObservation: undefined }, current, previous.at)).toBeNull() + expect(previousForecastInput({ ...previous, preview: { ...previous.preview, bedroom: undefined } }, current, previous.at)).toBeNull() + expect(previousForecastInput({ ...previous, preview: { ...previous.preview, bedroom: { ...previous.preview.bedroom, expiresAt: previous.at - 1 } } }, current, previous.at)).toBeNull() + expect(previousForecastInput({ ...previous, thermalObservation: { ...previous.thermalObservation, sleepWindowEnd: current.at } }, current, previous.at)).toBeNull() + expect(previousForecastInput(null, current, previous.at)).toBeNull() + }) +}) diff --git a/src/comfort/tests/thermal-forecast-integration.test.ts b/src/comfort/tests/thermal-forecast-integration.test.ts new file mode 100644 index 000000000..13a682f21 --- /dev/null +++ b/src/comfort/tests/thermal-forecast-integration.test.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { migratedComfortDatabase } from './databaseFixture' +import { forecastRecordings } from './forecastFixture' +import { createComfortStore, comfortEvent } from '../store' +import { defaultComfortState } from '../policy' +import { createPreviewReports } from '../reports' +import { createThermalForecasts } from '../thermal-forecast-store' +import { thermalDatasetAudit } from '../thermal-dataset' + +describe('reviewed SQL recordings through water forecasting', () => { + it('fits only after actual source review, reloads its registry and withdraws edited references', async () => { + const database = migratedComfortDatabase() + try { + const recordings = forecastRecordings(24) + const finalObservation = recordings.at(-1)?.samples.at(-1)?.at + if (finalObservation === undefined) throw new Error('Synthetic fixture is incomplete') + const now = finalObservation + 86_400_000 + const store = createComfortStore(database), state = defaultComfortState() + // These heat-equation series are test fixtures, never a source of personal model weights. + for (const recording of recordings) { + const start = recording.samples[0].at, end = start + 8 * 3_600_000, runId = randomUUID() + for (let minute = 0; minute < 480; minute++) { + const at = start + minute * 60_000, context = recording.samples[Math.floor(minute / 5)].context + store.commit('left', state, { ...comfortEvent(at, 'preview', 'Synthetic forecast integration fixture'), + eligible: true, session: recording.date, thermalContext: context, + preview: { format: 1, runId, environment: 'production', buildRevision: 'a'.repeat(40), + hardwareIdentity: 'b'.repeat(64), session: recording.date, start, end, timezone: 'UTC', scheduleRevision: 'c'.repeat(64), + policy: { minimumF: 69, maximumF: 71, sensorAware: true, followBedtime: false, offsetF: 0 }, + scheduledBaselineF: 70, decisionBaselineF: 70, presence: 'occupied', presenceSourceKey: 'd'.repeat(64), + mechanicallyQuiet: true, deviceFresh: true, sensorReady: true, manualPaused: false }, + }) + } + store.saveFeedback('left', { date: new Date(end).toISOString().slice(0, 10), comfort: 'comfortable' }, comfortEvent(now, 'feedback', 'Synthetic fixture')) + } + const registry = createThermalForecasts(database, () => now) + expect(registry.status('left').completeNights).toBe(0) + const reports = createPreviewReports(database, () => now) + for (const item of (await reports.list('left')).items) { + expect(item.summary.reviewable).toBe(true) + await reports.review({ side: 'left', id: item.summary.id, digest: item.summary.digest, + expectedReviewId: null, outcome: 'reviewed', confirmedActualNight: true, note: 'Synthetic fixture review' }) + } + const audit = thermalDatasetAudit(database, 'left', now) + expect(audit.recordings).toHaveLength(24) + expect(audit.recordings.every(recording => recording.samples.length === 96)).toBe(true) + expect(audit.recordings.every(recording => recording.samples.every(sample => sample.at % 300_000 === 240_000))).toBe(true) + const before = store.read('left') + const readiness = registry.status('left') + expect(readiness).toMatchObject({ canTrain: true, completeNights: 24 }) + const fitted = registry.train('left', readiness.datasetHash) + expect(fitted.model?.current).toBe(true) + expect(fitted.model?.horizons.every(horizon => horizon.validation.passes)).toBe(true) + expect(createThermalForecasts(database, () => now).status('left').model?.id).toBe(fitted.model?.id) + expect(registry.status('right').model).toBeNull() + expect(store.read('left')).toEqual(before) + database.prepare('UPDATE comfort_events SET event=json_set(event, \'$.message\', \'Edited after review\') WHERE observed_at=? AND side=\'left\'').run(recordings[0].samples[0].at) + expect(registry.status('left').model?.current).toBe(false) + expect(registry.status('left').completeNights).toBe(23) + } + finally { database.close() } + }, 30_000) +}) diff --git a/src/comfort/tests/thermal-forecast-store.test.ts b/src/comfort/tests/thermal-forecast-store.test.ts new file mode 100644 index 000000000..5201ed106 --- /dev/null +++ b/src/comfort/tests/thermal-forecast-store.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { migratedComfortDatabase } from './databaseFixture' +import { forecastRecordings } from './forecastFixture' +import type { ThermalDatasetAudit } from '../thermal-dataset' + +const source = vi.hoisted(() => ({ audit: null as ThermalDatasetAudit | null })) +vi.mock('../thermal-dataset', () => ({ thermalDatasetAudit: () => structuredClone(source.audit) })) +const { createThermalForecasts } = await import('../thermal-forecast-store') +const databases: ReturnType[] = [] +const now = Date.parse('2026-11-01') +afterEach(() => databases.splice(0).forEach(database => database.close())) +function audit(count = 24): ThermalDatasetAudit { + const recordings = forecastRecordings(count) + return { recordings, nights: [], collection: { feedbackNights: count, excluded: [], policy: 'complete-reviewed-nights-v1' }, + proofs: recordings.map((recording, index) => ({ date: recording.date, configuration: 'b'.repeat(64), reportId: String(index).padStart(64, '0'), digest: 'c'.repeat(64), reviewId: index + 1 })) } +} +beforeEach(() => { + source.audit = audit() +}) +function fixture() { + const database = migratedComfortDatabase() + databases.push(database) + return { database, models: createThermalForecasts(database, () => now) } +} + +describe('private water forecast registry', () => { + it('viewing is inert and explicit fitting persists an artifact without changing controls', () => { + const { database, models } = fixture() + const status = models.status('left') + expect(status).toMatchObject({ completeNights: 24, canTrain: true, model: null }) + expect(models.train('left', status.datasetHash).model).toMatchObject({ current: true, mode: 'research_preview' }) + models.train('left', status.datasetHash) + expect(database.prepare('SELECT COUNT(*) AS count FROM thermal_forecasts').get()).toEqual({ count: 1 }) + expect(database.prepare('SELECT COUNT(*) AS count FROM comfort_states').get()).toEqual({ count: 0 }) + expect(models.status('right').model).toBeNull() + expect(createThermalForecasts(database, () => now).status('left').model?.current).toBe(true) + }) + it('new nights preserve an unchanged reference model, but edited or deleted references invalidate it', () => { + const { models } = fixture() + const before = models.status('left') + models.train('left', before.datasetHash) + source.audit = audit(25) + expect(models.status('left').model?.current).toBe(true) + expect(() => models.train('left', before.datasetHash)).toThrow(/changed/) + source.audit.proofs[0].digest = 'd'.repeat(64) + expect(models.status('left').model?.current).toBe(false) + source.audit = audit() + source.audit.recordings.shift() + expect(models.status('left').model?.current).toBe(false) + }) + it('rejects incomplete cohorts, different configurations and artifact corruption', () => { + const { database, models } = fixture() + models.train('left', models.status('left').datasetHash) + source.audit = audit(19) + expect(models.status('left').canTrain).toBe(false) + expect(() => models.train('left', models.status('left').datasetHash)).toThrow(/Twenty/) + source.audit = audit() + source.audit.proofs[0].configuration = 'e'.repeat(64) + expect(models.status('left').completeNights).toBe(23) + expect(models.preview('left', null, 'f'.repeat(64)).predictions).toEqual([]) + database.prepare('UPDATE thermal_forecasts SET record = json_set(record, \'$.model.horizons[0].coefficients[0]\', 100)').run() + expect(() => models.status('left')).toThrow(/integrity/) + }) + it('withholds future and stale artifacts', () => { + const { database, models } = fixture() + models.train('left', models.status('left').datasetHash) + expect(createThermalForecasts(database, () => now - 1).status('left').model?.current).toBe(false) + expect(createThermalForecasts(database, () => now + 46 * 86_400_000).status('left').model?.current).toBe(false) + }) +}) diff --git a/src/comfort/tests/thermal-forecast.test.ts b/src/comfort/tests/thermal-forecast.test.ts new file mode 100644 index 000000000..74c66523b --- /dev/null +++ b/src/comfort/tests/thermal-forecast.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { forecastExamples, predictThermalForecast, trainThermalForecast } from '../thermal-forecast' +import { forecastRecordings } from './forecastFixture' + +const metadata = { side: 'left' as const, createdAt: Date.parse('2026-11-01'), datasetHash: 'a'.repeat(64), configuration: 'b'.repeat(64) } + +describe('preceding-only water-response forecasts', () => { + it('holds whole nights apart for fitting, calibration and final testing', () => { + const recordings = forecastRecordings() + const model = trainThermalForecast(recordings, metadata) + expect(model.trainingDates).toHaveLength(12) + expect(model.calibrationDates).toHaveLength(6) + expect(model.validationDates).toHaveLength(6) + expect(new Set([...model.trainingDates, ...model.calibrationDates, ...model.validationDates]).size).toBe(24) + expect(model.horizons.map(horizon => horizon.minutes)).toEqual([15, 30]) + expect(model.horizons.every(horizon => horizon.validation.modelMaeF < horizon.validation.persistenceMaeF)).toBe(true) + expect(model.horizons.every(horizon => horizon.validation.passes)).toBe(true) + expect(predictThermalForecast(model, forecastExamples(recordings[22], 30).examples[3].input).predictions).toHaveLength(2) + }) + it('later test-night changes cannot leak into coefficients, support or calibration bands', () => { + const original = forecastRecordings(), modified = structuredClone(original) + modified.slice(-6).forEach(night => night.samples.forEach((sample) => { + sample.context.waterF += 4 + })) + const before = trainThermalForecast(original, metadata), after = trainThermalForecast(modified, metadata) + expect(after.support).toEqual(before.support) + expect(after.horizons.map(horizon => [horizon.coefficients, horizon.errorBandF, horizon.simpleCoefficient])) + .toEqual(before.horizons.map(horizon => [horizon.coefficients, horizon.errorBandF, horizon.simpleCoefficient])) + expect(after.horizons.map(horizon => horizon.validation)).not.toEqual(before.horizons.map(horizon => horizon.validation)) + }) + it('future target changes remove a window instead of becoming an input feature', () => { + const night = forecastRecordings()[0] + const before = forecastExamples(night, 30) + const current = before.examples[2] + const changed = night.samples.find(sample => sample.at === current.at + 300_000) + if (!changed) throw new Error('Fixture lacks the requested future sample') + changed.context.targetF += 1 + const after = forecastExamples(night, 30) + expect(after.examples.some(example => example.at === current.at)).toBe(false) + expect(Object.keys(before.examples[0].input).sort()).toEqual(['change5mF', 'humidity', 'roomF', 'targetF', 'waterF']) + }) + it('rejects fragments, duplicate nights, temporal overlap and future observations', () => { + expect(() => trainThermalForecast(forecastRecordings(19), metadata)).toThrow(/Twenty/) + const duplicate = forecastRecordings() + duplicate[1].date = duplicate[0].date + expect(() => trainThermalForecast(duplicate, metadata)).toThrow(/distinct/) + const overlap = forecastRecordings() + overlap[1].samples = structuredClone(overlap[0].samples) + expect(() => trainThermalForecast(overlap, metadata)).toThrow(/overlap/) + const fragment = forecastRecordings()[0] + fragment.samples = fragment.samples.slice(0, 60) + expect(() => forecastExamples(fragment, 15)).toThrow(/complete/) + expect(() => trainThermalForecast(forecastRecordings(), { ...metadata, createdAt: Date.UTC(2026, 8, 3) })).toThrow(/future/) + }) + it('rejects gaps and missing data without interpolation or padding', () => { + const night = forecastRecordings()[0] + night.samples.splice(30, 4) + expect(() => forecastExamples(night, 30)).toThrow(/gap/) + const malformed = forecastRecordings()[0] + malformed.samples[15].context.waterF = Number.NaN + expect(() => forecastExamples(malformed, 15)).toThrow() + }) + it('abstains outside observed targets, without a predecessor, or after failed validation', () => { + const recordings = forecastRecordings(), model = trainThermalForecast(recordings, metadata) + const input = forecastExamples(recordings[20], 30).examples[4].input + expect(predictThermalForecast(model, null).predictions).toEqual([]) + expect(predictThermalForecast(model, { ...input, targetF: 90 }).predictions).toEqual([]) + model.horizons[0].validation.passes = false + expect(predictThermalForecast(model, input).predictions).toEqual([]) + }) + it('does not turn a constant water series into a claim of improved forecasting', () => { + const recordings = forecastRecordings() + recordings.forEach(night => night.samples.forEach((sample) => { + sample.context.waterF = sample.context.targetF + })) + const model = trainThermalForecast(recordings, metadata) + expect(model.horizons.every(horizon => horizon.validation.persistenceMaeF === 0 && !horizon.validation.passes)).toBe(true) + }) + it('withholds both forecasts when one horizon improves by less than a tenth of a degree', () => { + const recordings = forecastRecordings(24, 0.018), model = trainThermalForecast(recordings, metadata) + expect(model.horizons[0].validation.passes).toBe(false) + expect(model.horizons[1].validation.passes).toBe(true) + expect(predictThermalForecast(model, forecastExamples(recordings[20], 30).examples[4].input).predictions).toEqual([]) + }) +}) diff --git a/src/comfort/tests/thermal-learning.test.ts b/src/comfort/tests/thermal-learning.test.ts new file mode 100644 index 000000000..3f139c923 --- /dev/null +++ b/src/comfort/tests/thermal-learning.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { evaluateThermalLearning, type ThermalNight } from '../thermal-learning' + +const today = '2026-10-29' +const context = { targetF: 70, waterF: 70, roomF: 78, humidity: 50 } +const examples = (count = 24): ThermalNight[] => Array.from({ length: count }, (_, index) => ({ + ...context, date: `2026-10-${String(index + 1).padStart(2, '0')}`, + roomF: index % 2 ? 78 : 65, correction: index % 2 ? -1 : 1, +})) + +describe('contextual comfort research model', () => { + it('requires distinct nights, not hundreds of within-night sensor points', () => { + expect(evaluateThermalLearning(examples(19), context, today)).toMatchObject({ nights: 19, suggestedOffsetF: null, validationNights: 0 }) + expect(evaluateThermalLearning(Array(700).fill(examples(1)[0]), context, today).nights).toBe(0) + }) + it('holds out later nights and predicts a bounded cooling suggestion for a known warm context', () => { + expect(evaluateThermalLearning(examples(), context, today)).toMatchObject({ + mode: 'research_preview', nights: 24, trainingNights: 18, validationNights: 6, + modelErrorF: 0, baselineErrorF: 1, suggestedOffsetF: -1, + }) + }) + it('does not use validation labels as training data', () => { + const changed = examples().map((night, index) => index >= 18 ? { ...night, correction: -night.correction as -1 | 1 } : night) + expect(evaluateThermalLearning(changed, context, today)).toMatchObject({ modelErrorF: 2, suggestedOffsetF: null }) + }) + it('keeps the simpler baseline when all labels say warm', () => { + const nights = examples().map(night => ({ ...night, correction: -1 as const })) + expect(evaluateThermalLearning(nights, context, today)).toMatchObject({ baselineErrorF: 0, suggestedOffsetF: null }) + }) + it('withholds for absent, invalid or out-of-distribution current inputs', () => { + for (const current of [null, { ...context, roomF: 99 }, { ...context, waterF: NaN }]) { + expect(evaluateThermalLearning(examples(), current, today).suggestedOffsetF).toBeNull() + } + }) + it('rejects future dates and malformed measurements instead of filling them', () => { + const invalid = [ + { ...examples(1)[0], date: '2026-11-01' }, + { ...examples(1)[0], date: '2026-02-31' }, + { ...examples(1)[0], waterF: Infinity }, + ] + expect(evaluateThermalLearning(invalid, context, today).nights).toBe(0) + }) +}) diff --git a/src/comfort/tests/thermal-model-store.test.ts b/src/comfort/tests/thermal-model-store.test.ts new file mode 100644 index 000000000..3fca552d3 --- /dev/null +++ b/src/comfort/tests/thermal-model-store.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { migratedComfortDatabase } from './databaseFixture' +import type { ThermalDatasetAudit } from '../thermal-dataset' + +const source = vi.hoisted(() => ({ audit: null as ThermalDatasetAudit | null })) +vi.mock('../thermal-dataset', () => ({ thermalDatasetAudit: () => structuredClone(source.audit) })) +const { createThermalModels } = await import('../thermal-model-store') +const databases: ReturnType[] = [] +afterEach(() => databases.splice(0).forEach(database => database.close())) +beforeEach(() => { + source.audit = { + recordings: [], + nights: Array.from({ length: 24 }, (_, index) => ({ date: `2026-10-${String(index + 1).padStart(2, '0')}`, + targetF: 70, waterF: 71, humidity: 45, roomF: index % 2 ? 76 : 68, correction: index % 2 ? -1 : 1 })), + proofs: Array.from({ length: 24 }, (_, index) => ({ date: `2026-10-${String(index + 1).padStart(2, '0')}`, + reportId: String(index).padStart(64, '0'), digest: 'a'.repeat(64), reviewId: index + 1, configuration: 'b'.repeat(64) })), + collection: { feedbackNights: 24, excluded: [], policy: 'complete-reviewed-nights-v1' }, + } +}) +function fixture() { + const database = migratedComfortDatabase() + databases.push(database) + return { database, models: createThermalModels(database, () => Date.parse('2026-11-01T12:00:00Z')) } +} +function audit() { + if (!source.audit) throw new Error('Test audit is unavailable') + return source.audit +} + +describe('durable thermal model registry', () => { + it('viewing readiness never trains; an explicit fit persists once and survives a new service instance', () => { + const { database, models } = fixture() + const before = models.status('left') + expect(before).toMatchObject({ completeNights: 24, canTrain: true, model: null }) + const after = models.train('left', before.datasetHash) + expect(after.model).toMatchObject({ current: true, mode: 'research_preview', validation: { passesFeedbackCheck: true } }) + expect(createThermalModels(database).status('left').model?.id).toBe(after.model?.id) + models.train('left', before.datasetHash) + expect(database.prepare('SELECT COUNT(*) AS count FROM comfort_models').get()).toEqual({ count: 1 }) + expect(database.prepare('SELECT COUNT(*) AS count FROM comfort_states').get()).toEqual({ count: 0 }) + expect(models.preview('left', { targetF: 70, waterF: 71, roomF: 68, humidity: 45 }, 'b'.repeat(64)).offsetF).toBe(1) + }) + it('source edits, review changes and wrong configurations invalidate a model and stale requests', () => { + const { models } = fixture() + const before = models.status('left') + models.train('left', before.datasetHash) + expect(models.preview('left', audit().nights[0], 'c'.repeat(64)).offsetF).toBeNull() + audit().proofs[0].digest = 'd'.repeat(64) + expect(models.status('left').model?.current).toBe(false) + expect(models.preview('left', audit().nights[0], 'b'.repeat(64)).offsetF).toBeNull() + expect(() => models.train('left', before.datasetHash)).toThrow(/changed/) + }) + it('keeps sides isolated, refuses insufficient complete nights and excludes older configurations', () => { + const { models } = fixture() + models.train('left', models.status('left').datasetHash) + expect(models.status('right').model).toBeNull() + audit().proofs.slice(0, 10).forEach((proof) => { + proof.configuration = 'e'.repeat(64) + }) + const current = models.status('left') + expect(current).toMatchObject({ completeNights: 14, canTrain: false }) + expect(current.excluded).toHaveLength(10) + expect(() => models.train('left', current.datasetHash)).toThrow(/Twenty complete/) + }) + it('fails visibly on corrupted artifacts instead of accepting modified validation results', () => { + const { database, models } = fixture() + models.train('left', models.status('left').datasetHash) + database.prepare('UPDATE comfort_models SET record = json_set(record, \'$.coefficients[0]\', 100)').run() + expect(() => models.status('left')).toThrow(/integrity/) + expect(() => models.preview('left', audit().nights[0], 'b'.repeat(64))).toThrow(/integrity/) + }) + it('refuses a model from the future after a controller clock rollback', () => { + const { database, models } = fixture() + models.train('left', models.status('left').datasetHash) + const earlier = createThermalModels(database, () => Date.parse('2026-10-31T12:00:00Z')) + expect(earlier.status('left').model?.current).toBe(false) + expect(earlier.preview('left', { targetF: 70, waterF: 71, roomF: 68, humidity: 45 }, 'b'.repeat(64)).offsetF).toBeNull() + expect(() => earlier.train('left', earlier.status('left').datasetHash)).toThrow(/clock/) + }) +}) diff --git a/src/comfort/tests/thermal-model.test.ts b/src/comfort/tests/thermal-model.test.ts new file mode 100644 index 000000000..80c23ea02 --- /dev/null +++ b/src/comfort/tests/thermal-model.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { predictThermalModel, trainThermalModel } from '../thermal-model' +import type { ThermalNight } from '../thermal-learning' + +const metadata = { side: 'left' as const, createdAt: Date.parse('2026-11-01T12:00:00Z'), datasetHash: 'a'.repeat(64), configuration: 'b'.repeat(64) } +const examples = (count = 24): ThermalNight[] => Array.from({ length: count }, (_, index) => ({ + date: `2026-10-${String(index + 1).padStart(2, '0')}`, targetF: 70, waterF: 71, humidity: 45, + roomF: index % 2 === 0 ? 68 : 76, correction: index % 2 === 0 ? 1 : -1, +})) +const context = ({ targetF, waterF, roomF, humidity }: ThermalNight) => ({ targetF, waterF, roomF, humidity }) + +describe('on-Pod comfort ridge model', () => { + it('fits a contextual relationship and compares unseen whole nights with both baselines', () => { + const model = trainThermalModel(examples(), metadata) + expect(model.trainingDates).toHaveLength(18) + expect(model.validationDates).toHaveLength(6) + expect(model.trainingDates.some(date => model.validationDates.includes(date))).toBe(false) + expect(model.validation.passesFeedbackCheck).toBe(true) + expect(model.validation.modelMae).toBeLessThan(model.validation.holdMae * 0.9) + expect(model.validation.modelMae).toBeLessThan(model.validation.averageMae * 0.9) + expect(predictThermalModel(model, context(examples()[1]))).toMatchObject({ offsetF: -1 }) + expect(predictThermalModel(model, context(examples()[0]))).toMatchObject({ offsetF: 1 }) + expect(model.mode).toBe('research_preview') + }) + it('never lets held-out labels or features affect fitting or feature scaling', () => { + const input = examples() + const changed = input.map((night, index) => index < 18 ? night : { ...night, correction: 0 as const, roomF: 95 }) + const first = trainThermalModel(input, metadata), second = trainThermalModel(changed, metadata) + expect(second.coefficients).toEqual(first.coefficients) + expect(second.support).toEqual(first.support) + expect(second.validation.passesFeedbackCheck).toBe(false) + expect(second.validation.supportedNights).toBe(0) + }) + it('abstains for missing and unfamiliar measurements and for models without a baseline improvement', () => { + const model = trainThermalModel(examples(), metadata) + expect(predictThermalModel(model, null).offsetF).toBeNull() + expect(predictThermalModel(model, { ...context(examples()[0]), roomF: 95 }).offsetF).toBeNull() + expect(predictThermalModel(model, { ...context(examples()[0]), waterF: NaN }).offsetF).toBeNull() + const constant = trainThermalModel(examples().map(night => ({ ...night, correction: 0 })), metadata) + expect(constant.validation.passesFeedbackCheck).toBe(false) + expect(predictThermalModel(constant, context(examples()[0])).offsetF).toBeNull() + }) + it('rejects duplicate nights, impossible dates and inadequate training sets', () => { + expect(() => trainThermalModel(examples(19), metadata)).toThrow(/20 complete/) + expect(() => trainThermalModel([...examples(), examples()[0]], metadata)).toThrow(/distinct/) + expect(() => trainThermalModel(examples().map((night, index) => index === 0 ? { ...night, date: '2026-02-30' } : night), metadata)).toThrow(/valid/) + }) +}) diff --git a/src/comfort/tests/thermal-observation.test.ts b/src/comfort/tests/thermal-observation.test.ts new file mode 100644 index 000000000..d4a6d3a97 --- /dev/null +++ b/src/comfort/tests/thermal-observation.test.ts @@ -0,0 +1,64 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { thermalObservation } from '../thermal-observation' +import { capturePreviewEvidence } from '../report-evidence' +import { defaultComfortState, type ComfortContext, type ComfortState } from '../policy' +import { missingComfortSignals } from '../signals' + +function fixture() { + const now = Date.parse('2026-10-30T08:00:00Z') + const state: ComfortState = { ...defaultComfortState(), config: { mode: 'preview', minimumF: 68, maximumF: 72, roomAware: true, sensorAware: true } } + const context: ComfortContext = { + now, window: { session: 'night', segment: 'night:baseline', start: now - 3600000, end: now + 6 * 3600000, + baselineF: 70, alarmAt: now + 5 * 3600000, phases: { revision: 'a'.repeat(64), startF: 70, changes: [] } }, + device: { observedAt: now, powered: true, waterAdequate: true, priming: false, vibrating: false, baseMoving: false, targetF: 70 }, + presence: { observedAt: now, available: true, occupied: true, entry: { measuredAt: now, sourceKey: 'b'.repeat(64), state: 'present' } }, + eitherAway: false, routinesPaused: false, alarmOverride: false, owner: 'schedule', targetKnown: true, + signals: missingComfortSignals('Low signal'), + bedroom: { state: 'ready', reason: 'Room readings available', sourceIds: ['temperature', 'humidity'], offsetF: 0, + expiresAt: now + 60000, configuration: 'c'.repeat(64) }, + thermalContext: { targetF: 70, waterF: 71, roomF: 74, humidity: 45 }, + } + const capture = () => capturePreviewEvidence(state, context, 70, 'America/Los_Angeles', randomUUID(), + { environment: 'production', buildRevision: 'd'.repeat(40), hardwareIdentity: 'e'.repeat(64) }) + return { state, context, capture } +} + +describe('thermal research admission stays independent from actuator admission', () => { + it('admits observed thermal context without using HR or inferred body temperature', () => { + const { state, context, capture } = fixture() + expect(thermalObservation(state, context, capture())).toEqual({ version: 1, purpose: 'comfort_research', + bedroomConfiguration: 'c'.repeat(64), sleepWindowEnd: context.now + 4.5 * 3600000 }) + }) + it.each([ + ['off', (state: ComfortState) => { state.config.mode = 'off' }], + ['active', (state: ComfortState) => { state.config.mode = 'active' }], + ['manual pause', (state: ComfortState) => { state.pausedUntil = Number.MAX_SAFE_INTEGER }], + ['room response off', (state: ComfortState) => { state.config.roomAware = false }], + ] as const)('withholds research when %s', (_name, change) => { + const { state, context, capture } = fixture() + change(state) + expect(thermalObservation(state, context, capture())).toBeNull() + }) + it.each([ + ['power off', (context: ComfortContext) => { context.device.powered = false }], + ['low water', (context: ComfortContext) => { context.device.waterAdequate = false }], + ['priming', (context: ComfortContext) => { context.device.priming = true }], + ['base unknown', (context: ComfortContext) => { context.device.baseMoving = null }], + ['vibration', (context: ComfortContext) => { context.device.vibrating = true }], + ['stale hardware', (context: ComfortContext) => { context.device.observedAt = context.now - 16000 }], + ['empty bed', (context: ComfortContext) => { context.presence.occupied = false }], + ['unattributed presence', (context: ComfortContext) => { context.presence.entry = undefined }], + ['alarm warming', (context: ComfortContext) => { if (context.window) context.window.alarmAt = context.now + 1800000 }], + ['alarm change', (context: ComfortContext) => { context.alarmOverride = true }], + ['unexpected target', (context: ComfortContext) => { context.targetKnown = false }], + ['away', (context: ComfortContext) => { context.eitherAway = true }], + ['routine pause', (context: ComfortContext) => { context.routinesPaused = true }], + ['stale room', (context: ComfortContext) => { if (context.bedroom) context.bedroom.expiresAt = context.now }], + ['mismatched thermal target', (context: ComfortContext) => { if (context.thermalContext) context.thermalContext.targetF = 71 }], + ] as const)('excludes %s rather than label it a usable comfort example', (_name, change) => { + const { state, context, capture } = fixture() + change(context) + expect(thermalObservation(state, context, capture())).toBeNull() + }) +}) diff --git a/src/comfort/thermal-dataset.ts b/src/comfort/thermal-dataset.ts new file mode 100644 index 000000000..0af2a7cf2 --- /dev/null +++ b/src/comfort/thermal-dataset.ts @@ -0,0 +1,141 @@ +import type Database from 'better-sqlite3' +import type { Side } from '@/src/hardware/types' +import { comfortEventSchema } from './contract' +import { comfortLocalDate } from './policy' +import { median } from './signals' +import type { ThermalContext, ThermalNight } from './thermal-learning' +import { currentBedroomGuidance } from '@/src/bedroom/fusion' +import { previewReportId } from './report-evidence' +import { MAX_REPORT_ROWS, reportDigest, type PreviewSourceRow } from './report-summary' +import { previewReviewSchema } from './report-contract' +import { createHash } from 'node:crypto' +import type { PreviewEvidence } from './report-contract' + +const MINIMUM_THERMAL_WINDOWS = 72 +const MAXIMUM_THERMAL_GAP_MS = 10 * 60_000 +const MINIMUM_NIGHT_MS = 6 * 3_600_000 +const MAXIMUM_NIGHT_MS = 12 * 3_600_000 +const MAXIMUM_EDGE_GAP_MS = 30 * 60_000 + +export interface ThermalDatasetAudit { + nights: ThermalNight[] + recordings: ThermalRecording[] + proofs: { date: string, reportId: string, digest: string, reviewId: number, configuration: string }[] + collection: { feedbackNights: number, excluded: { date: string, reason: string }[], policy: 'complete-reviewed-nights-v1' } +} + +export interface ThermalRecording { + date: string + samples: { at: number, context: ThermalContext }[] +} + +export function thermalConfiguration(evidence: PreviewEvidence, bedroomConfiguration: string | null): string { + const { minimumF, maximumF, sensorAware, followBedtime } = evidence.policy + return createHash('sha256').update(JSON.stringify([evidence.hardwareIdentity, evidence.presenceSourceKey, + evidence.scheduleRevision, { minimumF, maximumF, sensorAware, followBedtime, roomAware: evidence.policy.roomAware === true }, bedroomConfiguration])).digest('hex') +} + +/** Training requires the owner's review to match the retained source, including later corrections. */ +function currentNightReview(database: Database.Database, side: Side, id: string, now: number): { digest: string, reviewId: number } | null { + const row = database.prepare('SELECT id, created_at AS at, review FROM comfort_reviews WHERE side = ? AND report_id = ? ORDER BY id DESC LIMIT 1') + .get(side, id) as { id: number, at: number, review: string } | undefined + if (!row || row.at > now) return null + try { + const review = previewReviewSchema.parse({ ...JSON.parse(row.review), id: row.id, at: row.at }) + if (review.outcome !== 'reviewed' || !review.confirmedActualNight) return null + const source = database.prepare('SELECT id, observed_at AS at, event FROM comfort_events WHERE side = ? AND report_id = ? ORDER BY id LIMIT ?') + .all(side, id, MAX_REPORT_ROWS + 1) as PreviewSourceRow[] + return source.length > 0 && source.length <= MAX_REPORT_ROWS && review.digest === reportDigest(source) + ? { digest: review.digest, reviewId: row.id } + : null + } + catch { return null } +} + +/** Join one morning check-in to actual, quiet occupied observations from that side. */ +export function thermalDatasetAudit(database: Database.Database, side: Side, now: number): ThermalDatasetAudit { + const reports = database.prepare('SELECT local_date AS date, comfort FROM comfort_feedback WHERE side = ? ORDER BY local_date DESC LIMIT 90') + .all(side) as { date: string, comfort: string }[] + const rows = database.prepare('SELECT observed_at AS at, report_id AS reportId, event FROM comfort_events WHERE side = ? AND observed_at >= ? AND observed_at <= ? AND json_extract(event, \'$.thermalContext\') IS NOT NULL ORDER BY observed_at LIMIT 50000') + .all(side, now - 90 * 86400000, now) as { at: number, reportId: string | null, event: string }[] + const nights = new Map }>() + const ambiguous = new Set() + for (const row of rows) { + let value: unknown + try { + value = JSON.parse(row.event) + } + catch { + continue + } + const parsed = comfortEventSchema.safeParse(value) + if (!parsed.success) continue + const event = parsed.data, preview = event.preview, context = event.thermalContext + if (!preview || !context || event.kind !== 'preview' || event.session !== preview.session || event.at !== row.at + || previewReportId(preview) !== row.reportId || preview.environment !== 'production' || !preview.buildRevision || !preview.hardwareIdentity + || preview.end >= now || event.at < preview.start || event.at > preview.end + || preview.presence !== 'occupied' || !preview.presenceSourceKey || !preview.mechanicallyQuiet + || !preview.deviceFresh || preview.manualPaused || (!event.eligible && !event.thermalObservation)) continue + if (event.thermalObservation && (!currentBedroomGuidance(preview.bedroom, event.at) + || preview.bedroom?.configuration !== event.thermalObservation.bedroomConfiguration)) continue + const date = comfortLocalDate(preview.end, preview.timezone) + const sleepEnd = event.thermalObservation?.sleepWindowEnd ?? preview.end + if (sleepEnd <= preview.start || sleepEnd > preview.end || event.at >= sleepEnd) continue + // A sensor, schedule, deployment or assignment change makes one night label ambiguous. + const identity = JSON.stringify([preview.session, preview.start, preview.end, preview.timezone, + preview.hardwareIdentity, preview.buildRevision, preview.scheduleRevision, preview.presenceSourceKey, + event.thermalObservation?.bedroomConfiguration ?? preview.bedroom?.configuration ?? null, sleepEnd]) + const configuration = thermalConfiguration(preview, event.thermalObservation?.bedroomConfiguration ?? preview.bedroom?.configuration ?? null) + const group = nights.get(date) ?? { identity, configuration, id: previewReportId(preview), start: preview.start, end: sleepEnd, + minimumTarget: context.targetF, maximumTarget: context.targetF, samples: new Map() } + if (group.identity !== identity) ambiguous.add(date) + group.minimumTarget = Math.min(group.minimumTarget, context.targetF) + group.maximumTarget = Math.max(group.maximumTarget, context.targetF) + // At most one observation per five minutes so high-rate recording cannot dominate a night. + group.samples.set(Math.floor(event.at / 300000), { at: event.at, context }) + nights.set(date, group) + } + const excluded: ThermalDatasetAudit['collection']['excluded'] = [] + const proofs: ThermalDatasetAudit['proofs'] = [] + const recordings: ThermalRecording[] = [] + const accepted = reports.flatMap((feedback): ThermalNight[] => { + const reject = (reason: string): ThermalNight[] => { + excluded.push({ date: feedback.date, reason }) + return [] + } + const night = nights.get(feedback.date) + if (!night) return reject('No qualified overnight thermal observations match this check-in.') + if (ambiguous.has(feedback.date)) return reject('The sensor, schedule, software or room configuration changed during this night.') + if (!['warm', 'cold', 'comfortable'].includes(feedback.comfort)) return reject('The comfort check-in is unavailable.') + const duration = night.end - night.start + if (duration < MINIMUM_NIGHT_MS || duration > MAXIMUM_NIGHT_MS) return reject('The overnight window is outside the six-to-twelve-hour training range.') + if (night.samples.size < MINIMUM_THERMAL_WINDOWS) return reject('Fewer than six hours of thermal observations were recorded.') + // Buckets limit sample weight; only actual timestamps establish coverage and gaps. + const samples = [...night.samples.values()].sort((first, second) => first.at - second.at) + const firstSample = samples[0], lastSample = samples.at(-1) + if (!firstSample || !lastSample || lastSample.at - firstSample.at < MINIMUM_NIGHT_MS + || firstSample.at - night.start > MAXIMUM_EDGE_GAP_MS || night.end - lastSample.at > MAXIMUM_EDGE_GAP_MS + || lastSample.at - firstSample.at < duration * 0.9 + || samples.some((sample, index) => index > 0 && sample.at - samples[index - 1].at > MAXIMUM_THERMAL_GAP_MS)) { + return reject('The recording does not cover the complete overnight window without large gaps.') + } + const observedMs = samples.reduce((total, sample, index) => total + (index === 0 ? 0 : Math.min(300000, sample.at - samples[index - 1].at)), 0) + if (observedMs < duration * 0.9) return reject('Less than 90% of the overnight window has thermal observations.') + const contexts = samples.map(sample => sample.context) + const targets = contexts.map(context => context.targetF) + // A large overnight curve or wake warming makes one whole-night comfort label ambiguous. + if (night.maximumTarget - night.minimumTarget > 2) return reject('Several different temperature targets make one morning check-in ambiguous.') + const review = currentNightReview(database, side, night.id, now) + if (!review) return reject('Review this night and confirm it was an actual overnight session before training.') + proofs.push({ date: feedback.date, reportId: night.id, configuration: night.configuration, ...review }) + recordings.push({ date: feedback.date, samples }) + return [{ date: feedback.date, correction: feedback.comfort === 'warm' ? -1 : feedback.comfort === 'cold' ? 1 : 0, + targetF: median(targets), waterF: median(contexts.map(context => context.waterF)), + roomF: median(contexts.map(context => context.roomF)), humidity: median(contexts.map(context => context.humidity)) }] + }) + return { nights: accepted, recordings, proofs, collection: { feedbackNights: reports.length, excluded, policy: 'complete-reviewed-nights-v1' } } +} + +export function thermalDataset(database: Database.Database, side: Side, now: number): ThermalNight[] { + return thermalDatasetAudit(database, side, now).nights +} diff --git a/src/comfort/thermal-forecast-input.ts b/src/comfort/thermal-forecast-input.ts new file mode 100644 index 000000000..e84de6d28 --- /dev/null +++ b/src/comfort/thermal-forecast-input.ts @@ -0,0 +1,25 @@ +import { comfortEventSchema } from './contract' +import { thermalConfiguration } from './thermal-dataset' +import { forecastInputSchema } from './thermal-forecast' +import type { ThermalContext } from './thermal-learning' +import { currentBedroomGuidance } from '@/src/bedroom/fusion' + +/** Read a preceding observed delta from the same quiet session; no interpolation across missing data. */ +export function previousForecastInput(previous: unknown, current: { at: number, session: string, configuration: string, context: ThermalContext }, recordedAt: number | null) { + const parsed = comfortEventSchema.safeParse(previous) + if (!parsed.success) return null + const event = parsed.data, evidence = event.preview, thermal = event.thermalContext + if (!Number.isSafeInteger(current.at) || event.at !== recordedAt || !evidence || !thermal || event.kind !== 'preview' || event.session !== current.session + || evidence.session !== current.session || evidence.environment !== 'production' + || event.at < evidence.start || event.at >= evidence.end || current.at >= evidence.end + || evidence.presence !== 'occupied' || !evidence.deviceFresh || !evidence.mechanicallyQuiet || evidence.manualPaused + || (!event.eligible && !event.thermalObservation) || thermal.targetF !== current.context.targetF + || Math.abs(current.at - event.at - 300_000) > 30_000 + || thermalConfiguration(evidence, event.thermalObservation?.bedroomConfiguration ?? evidence.bedroom?.configuration ?? null) !== current.configuration) return null + if (event.thermalObservation && (!currentBedroomGuidance(evidence.bedroom, event.at) + || evidence.bedroom?.configuration !== event.thermalObservation.bedroomConfiguration + || current.at >= (event.thermalObservation.sleepWindowEnd ?? evidence.end))) return null + const input = forecastInputSchema.safeParse({ ...current.context, + change5mF: (current.context.waterF - thermal.waterF) * 300_000 / (current.at - event.at) }) + return input.success ? input.data : null +} diff --git a/src/comfort/thermal-forecast-store.ts b/src/comfort/thermal-forecast-store.ts new file mode 100644 index 000000000..2392494ee --- /dev/null +++ b/src/comfort/thermal-forecast-store.ts @@ -0,0 +1,116 @@ +import type Database from 'better-sqlite3' +import { z } from 'zod' +import { durableTransaction } from '@/src/db/durable' +import type { Side } from '@/src/hardware/types' +import { thermalDatasetAudit, type ThermalDatasetAudit } from './thermal-dataset' +import { thermalModelHash } from './thermal-model' +import { FORECAST_MINIMUM_NIGHTS, forecastExamples, predictThermalForecast, thermalForecastSchema, thermalForecastShape, trainThermalForecast, type ForecastInput, type ThermalForecast } from './thermal-forecast' +import { PreviewReportError } from './report-summary' + +const hash = z.string().regex(/^[a-f0-9]{64}$/) +const proofSchema = z.object({ date: z.string(), reportId: hash, digest: hash, reviewId: z.number().int().positive(), configuration: hash }).strict() +const artifactSchema = z.object({ model: thermalForecastSchema, proofs: z.array(proofSchema).min(20).max(90) }).strict() +export const forecastStatusSchema = z.object({ + apiVersion: z.literal(1), side: z.enum(['left', 'right']), generatedAt: z.number().int().positive(), + requiredNights: z.literal(20), completeNights: z.number().int().nonnegative(), datasetHash: hash, + canTrain: z.boolean(), excluded: z.array(z.object({ date: z.string(), reason: z.string() })), reason: z.string(), + model: thermalForecastShape.omit({ support: true, horizons: true }).extend({ + id: hash, current: z.boolean(), horizons: z.array(thermalForecastSchema.shape.horizons.element.omit({ coefficients: true, simpleCoefficient: true })), + }).nullable(), +}).strict() + +/** Reuse the reviewed full-night gate; retain whole-series provenance instead of aggregate labels. */ +function dataset(audit: ThermalDatasetAudit) { + const latest = [...audit.proofs].sort((first, second) => second.date.localeCompare(first.date))[0] + const excluded = [...audit.collection.excluded] + const recordings = audit.recordings.filter((recording) => { + const proof = audit.proofs.find(item => item.date === recording.date) + if (proof?.configuration !== latest?.configuration) { + excluded.push({ date: recording.date, reason: 'This night used a different sensor, routine or room configuration.' }) + return false + } + try { + if ([15, 30].every(minutes => forecastExamples(recording, minutes as 15 | 30).examples.length >= 12)) return true + } + catch { /* Invalid recordings remain visible as excluded, never imputed. */ } + excluded.push({ date: recording.date, reason: 'Not enough stable 15- and 30-minute forecast examples in this complete night.' }) + return false + }).sort((first, second) => first.date.localeCompare(second.date)) + const included = new Set(recordings.map(recording => recording.date)) + const proofs = audit.proofs.filter(proof => included.has(proof.date)).sort((first, second) => first.date.localeCompare(second.date)) + return { recordings, proofs, excluded, configuration: latest?.configuration ?? null, + latestAt: recordings.at(-1)?.samples.at(-1)?.at ?? 0, + hash: thermalModelHash({ version: 'water-response-ridge-v1', recordings, proofs }) } +} + +/** Explicit fitting persists an inert artifact; status and forecasting never write controls. */ +export function createThermalForecasts(database: Database.Database, clock: () => number = Date.now) { + function inspect(side: Side) { + const now = clock() + if (!Number.isSafeInteger(now) || now <= 0) throw new PreviewReportError('Controller clock is unavailable.', 'PRECONDITION_FAILED') + const audit = thermalDatasetAudit(database, side, now) + return { now, audit, data: dataset(audit) } + } + function latest(side: Side) { + const row = database.prepare('SELECT id, record FROM thermal_forecasts WHERE side = ? ORDER BY created_at DESC, rowid DESC LIMIT 1') + .get(side) as { id: string, record: string } | undefined + if (!row) return null + const artifact = artifactSchema.parse(JSON.parse(row.record)) + if (artifact.model.side !== side || thermalModelHash(artifact) !== row.id) throw new Error('Forecast artifact failed its integrity check.') + return { id: row.id, artifact } + } + function evidenceCurrent(model: ThermalForecast, data: ReturnType, now: number) { + const dates = new Set([...model.trainingDates, ...model.calibrationDates, ...model.validationDates]) + const recordings = data.recordings.filter(recording => dates.has(recording.date)) + const proofs = data.proofs.filter(proof => dates.has(proof.date)) + return model.createdAt <= now && model.sourceLastAt <= model.createdAt && now - model.sourceLastAt <= 45 * 86_400_000 && model.configuration === data.configuration + && model.datasetHash === thermalModelHash({ version: 'water-response-ridge-v1', recordings, proofs }) + } + function status(side: Side) { + return database.transaction(() => { + const { now, data } = inspect(side), stored = latest(side) + const model = stored?.artifact.model + const current = model ? evidenceCurrent(model, data, now) : false + return forecastStatusSchema.parse({ apiVersion: 1, side, generatedAt: now, requiredNights: FORECAST_MINIMUM_NIGHTS, + completeNights: data.recordings.length, datasetHash: data.hash, canTrain: data.recordings.length >= FORECAST_MINIMUM_NIGHTS && now - data.latestAt <= 45 * 86_400_000, + excluded: data.excluded, model: model + ? { ...thermalForecastShape.omit({ support: true, horizons: true }).strip().parse(model), + id: stored?.id, current, horizons: model.horizons.map(horizon => thermalForecastSchema.shape.horizons.element.omit({ coefficients: true, simpleCoefficient: true }).strip().parse(horizon)) } + : null, + reason: !model + ? `${data.recordings.length} complete reviewed nights have stable forecast windows; ${FORECAST_MINIMUM_NIGHTS} are required.` + : !current + ? 'Reference recordings changed, the configuration changed, or the latest reference is over 45 days old. Recheck before use.' + : model.horizons.every(horizon => horizon.validation.passes) + ? 'Both horizons passed later-night water forecasting comparisons. Preview only.' + : 'The fitted forecast did not pass both later-night comparisons. Keep the routine.' }) + })() + } + function train(side: Side, expectedDatasetHash: string) { + const { now, data } = database.transaction(() => inspect(side))() + if (data.hash !== expectedDatasetHash) throw new PreviewReportError('Forecast recordings changed. Check the nights again.', 'CONFLICT') + if (!data.configuration || data.recordings.length < FORECAST_MINIMUM_NIGHTS) throw new PreviewReportError('Twenty complete reviewed nights with stable forecast windows are required.', 'PRECONDITION_FAILED') + if (now - data.latestAt > 45 * 86_400_000) throw new PreviewReportError('New complete recordings are required; the most recent reference is over 45 days old.', 'PRECONDITION_FAILED') + const existing = latest(side) + if (existing && existing.artifact.model.createdAt > now) throw new PreviewReportError('Controller clock precedes the saved forecast.', 'PRECONDITION_FAILED') + if (existing?.artifact.model.datasetHash === data.hash) return status(side) + const model = trainThermalForecast(data.recordings, { side, createdAt: now, datasetHash: data.hash, configuration: data.configuration }) + const artifact = artifactSchema.parse({ model, proofs: data.proofs }) + durableTransaction(database, () => { + if (inspect(side).data.hash !== data.hash) throw new PreviewReportError('Forecast recordings changed before saving.', 'CONFLICT') + database.prepare('INSERT INTO thermal_forecasts (id, side, created_at, record) VALUES (?, ?, ?, ?)') + .run(thermalModelHash(artifact), side, now, JSON.stringify(artifact)) + }) + return status(side) + } + function preview(side: Side, input: ForecastInput | null, configuration: string | null) { + return database.transaction(() => { + const { now, data } = inspect(side), stored = latest(side) + if (!stored || !evidenceCurrent(stored.artifact.model, data, now) || stored.artifact.model.configuration !== configuration) { + return { predictions: [], reason: 'A current fitted water forecast and matching live configuration are required.' } + } + return predictThermalForecast(stored.artifact.model, input) + })() + } + return { status, train, preview } +} diff --git a/src/comfort/thermal-forecast.ts b/src/comfort/thermal-forecast.ts new file mode 100644 index 000000000..0132517a4 --- /dev/null +++ b/src/comfort/thermal-forecast.ts @@ -0,0 +1,190 @@ +import { z } from 'zod' +import { thermalContextSchema } from './thermal-learning' +import type { ThermalRecording } from './thermal-dataset' + +export const FORECAST_MINIMUM_NIGHTS = 20 +const FEATURES = 5 +const finite = z.number().finite() +const hash = z.string().regex(/^[a-f0-9]{64}$/) +const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) +const vector = z.array(finite).length(FEATURES) +export const forecastInputSchema = thermalContextSchema.extend({ change5mF: finite.min(-10).max(10) }).strict() +export type ForecastInput = z.infer +export interface ForecastExample { at: number, input: ForecastInput, changeF: number } +export interface ForecastNight { date: string, examples: ForecastExample[] } + +const horizonSchema = z.object({ + minutes: z.union([z.literal(15), z.literal(30)]), coefficients: vector, + simpleCoefficient: finite.min(0).max(1), errorBandF: finite.nonnegative(), + validation: z.object({ + modelMaeF: finite.nonnegative(), persistenceMaeF: finite.nonnegative(), simpleMaeF: finite.nonnegative(), + intervalCoverage: finite.min(0).max(1), supportedFraction: finite.min(0).max(1), + passes: z.boolean(), reason: z.string(), + }).strict(), +}).strict() +export const thermalForecastShape = z.object({ + version: z.literal('water-response-ridge-v1'), purpose: z.literal('water-temperature-forecast'), + mode: z.literal('research_preview'), side: z.enum(['left', 'right']), createdAt: z.number().int().positive(), + sourceLastAt: z.number().int().positive(), + datasetHash: hash, configuration: hash, + trainingDates: z.array(date).min(10).max(46), calibrationDates: z.array(date).min(5).max(23), + validationDates: z.array(date).min(5).max(23), + support: z.array(z.object({ date, targetF: finite, values: vector }).strict()).min(10).max(4500), + horizons: z.array(horizonSchema).length(2), +}).strict() +export const thermalForecastSchema = thermalForecastShape + .refine(model => model.horizons[0].minutes === 15 && model.horizons[1].minutes === 30, 'Both distinct horizons must be present in order.') + .refine(model => model.sourceLastAt < model.createdAt, 'Reference recordings must precede fitting.') + .refine((model) => { + const dates = [...model.trainingDates, ...model.calibrationDates, ...model.validationDates] + return new Set(dates).size === dates.length && dates.every((value, index) => index === 0 || value > dates[index - 1]) + && model.support.every(sample => model.trainingDates.includes(sample.date)) + }, 'Whole-night splits must be disjoint, chronological and contain all support references.') +export type ThermalForecast = z.infer +export const thermalForecastPredictionSchema = z.object({ + predictions: z.array(z.object({ minutes: finite, waterF: finite, lowerF: finite, upperF: finite }).strict()), + reason: z.string(), +}).strict() + +/** Only preceding measurements are features; future measurements supply labels under a held target. */ +export function forecastExamples(recording: ThermalRecording, minutes: 15 | 30): ForecastNight { + const samples = recording.samples + if (!date.safeParse(recording.date).success || !Number.isFinite(Date.parse(recording.date)) + || new Date(recording.date).toISOString().slice(0, 10) !== recording.date + || samples.length < 72 || samples.length > 300) throw new Error('Forecast fitting requires complete reviewed nights.') + for (const [index, sample] of samples.entries()) { + thermalContextSchema.parse(sample.context) + if (!Number.isSafeInteger(sample.at) || sample.at <= 0 || (index > 0 + && (sample.at <= samples[index - 1].at || sample.at - samples[index - 1].at > 600_000))) { + throw new Error('Forecast recording has invalid timestamps or a large gap.') + } + } + const duration = samples[samples.length - 1].at - samples[0].at + if (duration < 6 * 3_600_000 || duration > 12 * 3_600_000) throw new Error('Forecast recording is not a complete six-to-twelve-hour night.') + const examples: ForecastExample[] = [] + let lastAnchor = 0 + for (let index = 1; index < samples.length; index++) { + const current = samples[index], previous = samples[index - 1] + if (current.at - lastAnchor < 900_000 || Math.abs(current.at - previous.at - 300_000) > 30_000 + || current.context.targetF !== previous.context.targetF) continue + const futureIndex = samples.findIndex((sample, candidate) => candidate > index + && Math.abs(sample.at - current.at - minutes * 60_000) <= 45_000) + if (futureIndex < 0) continue + const following = samples.slice(index, futureIndex + 1) + if (following.some((sample, step) => sample.context.targetF !== current.context.targetF + || Math.abs(sample.context.roomF - current.context.roomF) > 2 + || Math.abs(sample.context.humidity - current.context.humidity) > 10 + || (step > 0 && Math.abs(sample.at - following[step - 1].at - 300_000) > 30_000))) continue + const parsed = forecastInputSchema.safeParse({ ...current.context, + change5mF: (current.context.waterF - previous.context.waterF) * 300_000 / (current.at - previous.at) }) + if (!parsed.success) continue + examples.push({ at: current.at, input: parsed.data, changeF: samples[futureIndex].context.waterF - current.context.waterF }) + lastAnchor = current.at + } + return { date: recording.date, examples } +} + +/** Fixed physical scales avoid fitting normalization to later calibration or test nights. */ +function features(input: ForecastInput): number[] { + return [1, (input.targetF - input.waterF) / 5, (input.roomF - input.waterF) / 10, + input.change5mF / 2, (input.humidity - 50) / 20] +} +const mean = (values: number[]) => values.reduce((total, value) => total + value, 0) / values.length +const predict = (coefficients: number[], input: ForecastInput) => features(input).reduce((total, value, index) => total + value * coefficients[index], 0) +const percentile90 = (values: number[]) => [...values].sort((first, second) => first - second)[Math.ceil(values.length * 0.9) - 1] + +/** Each night has equal total weight, regardless of its number of forecast origins. */ +function fit(nights: ForecastNight[]): number[] { + const matrix = Array.from({ length: FEATURES }, () => Array(FEATURES + 1).fill(0)) + for (const night of nights) { + for (const example of night.examples) { + const values = features(example.input), weight = 1 / night.examples.length + for (let row = 0; row < FEATURES; row++) { + for (let column = 0; column < FEATURES; column++) matrix[row][column] += weight * values[row] * values[column] + matrix[row][FEATURES] += weight * values[row] * example.changeF + } + } + } + for (let index = 1; index < FEATURES; index++) matrix[index][index] += 0.01 + for (let column = 0; column < FEATURES; column++) { + let pivot = column + for (let row = column + 1; row < FEATURES; row++) if (Math.abs(matrix[row][column]) > Math.abs(matrix[pivot][column])) pivot = row + ;[matrix[column], matrix[pivot]] = [matrix[pivot], matrix[column]] + const divisor = matrix[column][column] + if (!Number.isFinite(divisor) || Math.abs(divisor) < 1e-10) throw new Error('Forecast fitting matrix is singular.') + for (let entry = column; entry <= FEATURES; entry++) matrix[column][entry] /= divisor + for (let row = 0; row < FEATURES; row++) { + if (row === column) continue + const factor = matrix[row][column] + for (let entry = column; entry <= FEATURES; entry++) matrix[row][entry] -= factor * matrix[column][entry] + } + } + return matrix.map(row => row[FEATURES]) +} + +function supportMatches(support: ThermalForecast['support'], input: ForecastInput): boolean { + const current = features(input) + return new Set(support.filter(sample => Math.abs(sample.targetF - input.targetF) <= 0.25 + && Math.hypot(...sample.values.slice(1).map((value, index) => value - current[index + 1])) <= 1.5) + .map(sample => sample.date)).size >= 3 +} + +/** Calibration nights set the band; final test nights never influence coefficients, support or bands. */ +export function trainThermalForecast(recordings: ThermalRecording[], metadata: Pick): ThermalForecast { + if (recordings.length < FORECAST_MINIMUM_NIGHTS || recordings.length > 90 + || new Set(recordings.map(recording => recording.date)).size !== recordings.length) throw new Error('Twenty distinct complete reviewed nights are required.') + const ordered = [...recordings].sort((first, second) => first.date.localeCompare(second.date)) + for (const [index, night] of ordered.entries()) { + forecastExamples(night, 30) + const previousEnd = index > 0 ? ordered[index - 1].samples.at(-1)?.at : undefined + if (previousEnd !== undefined && night.samples[0].at <= previousEnd) throw new Error('Distinct nights must not overlap in time.') + } + if (ordered.some(night => night.samples.some(sample => sample.at >= metadata.createdAt))) throw new Error('A recording is unfinished or in the future.') + const heldOut = Math.max(5, Math.ceil(ordered.length / 4)) + const training = ordered.slice(0, -2 * heldOut), calibration = ordered.slice(-2 * heldOut, -heldOut), validation = ordered.slice(-heldOut) + const support = training.flatMap(recording => forecastExamples(recording, 30).examples + .map(example => ({ date: recording.date, targetF: example.input.targetF, values: features(example.input) }))) + const horizons = ([15, 30] as const).map((minutes) => { + const groups = [training, calibration, validation].map(group => group.map(recording => forecastExamples(recording, minutes))) + if (groups.some(group => group.some(night => night.examples.length < 12))) throw new Error('Every night needs at least twelve stable, preceding-only forecast examples for each horizon.') + const [fitNights, calibrationNights, testNights] = groups + const coefficients = fit(fitNights) + let numerator = 0, denominator = 0 + for (const night of fitNights) for (const example of night.examples) { + const difference = example.input.targetF - example.input.waterF + numerator += difference * example.changeF / night.examples.length + denominator += difference ** 2 / night.examples.length + } + const simpleCoefficient = Math.min(1, Math.max(0, denominator > 1e-10 ? numerator / denominator : 0)) + const errorBandF = Math.max(0.25, percentile90(calibrationNights.map(night => percentile90(night.examples.map(example => Math.abs(predict(coefficients, example.input) - example.changeF)))))) + const metric = (score: (example: ForecastExample) => number) => mean(testNights.map(night => mean(night.examples.map(score)))) + const modelMaeF = metric(example => Math.abs(predict(coefficients, example.input) - example.changeF)) + const persistenceMaeF = metric(example => Math.abs(example.changeF)) + const simpleMaeF = metric(example => Math.abs(simpleCoefficient * (example.input.targetF - example.input.waterF) - example.changeF)) + const intervalCoverage = metric(example => Number(Math.abs(predict(coefficients, example.input) - example.changeF) <= errorBandF)) + const supportedFraction = metric(example => Number(supportMatches(support, example.input))) + const baseline = Math.min(persistenceMaeF, simpleMaeF) + const passes = supportedFraction >= 0.9 && intervalCoverage >= 0.8 && modelMaeF <= baseline * 0.9 && baseline - modelMaeF >= 0.1 + return { minutes, coefficients, simpleCoefficient, errorBandF, + validation: { modelMaeF, persistenceMaeF, simpleMaeF, intervalCoverage, supportedFraction, passes, + reason: passes + ? 'Water forecasts improved on both baselines in later test nights. This does not establish comfort or safe control.' + : 'Forecast accuracy, coverage or supported conditions did not meet the comparison gates. Keep the routine.' } } + }) + return thermalForecastSchema.parse({ ...metadata, version: 'water-response-ridge-v1', purpose: 'water-temperature-forecast', mode: 'research_preview', + sourceLastAt: ordered[ordered.length - 1].samples.at(-1)?.at, + trainingDates: training.map(night => night.date), calibrationDates: calibration.map(night => night.date), validationDates: validation.map(night => night.date), support, horizons }) +} + +/** A forecast describes water under an unchanged target; it is never an actuator request or body temperature. */ +export function predictThermalForecast(model: ThermalForecast, input: ForecastInput | null) { + const parsed = thermalForecastSchema.safeParse(model), current = forecastInputSchema.safeParse(input) + if (!parsed.success || parsed.data.horizons.some(horizon => !horizon.validation.passes)) return { predictions: [], reason: 'Both forecast horizons must pass independent whole-night comparisons.' } + if (!current.success || !supportMatches(parsed.data.support, current.data)) return { predictions: [], reason: 'Fresh conditions must match at least three training nights, including the current target.' } + const predictions = parsed.data.horizons.map((horizon) => { + const waterF = current.data.waterF + predict(horizon.coefficients, current.data) + return { minutes: horizon.minutes, waterF, lowerF: waterF - horizon.errorBandF, upperF: waterF + horizon.errorBandF } + }) + if (predictions.some(value => !Number.isFinite(value.waterF) || value.lowerF < 50 || value.upperF > 115)) return { predictions: [], reason: 'The forecast leaves the supported water-temperature range.' } + return { predictions, reason: 'Assumes the target stays unchanged and room conditions remain similar. Bands are empirical forecast errors, not medical confidence intervals. No adjustment was made.' } +} diff --git a/src/comfort/thermal-learning.ts b/src/comfort/thermal-learning.ts new file mode 100644 index 000000000..364a99fa7 --- /dev/null +++ b/src/comfort/thermal-learning.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' + +export const thermalContextSchema = z.object({ + targetF: z.number().min(55).max(110), waterF: z.number().min(40).max(120), + roomF: z.number().min(40).max(100), humidity: z.number().min(0).max(100), +}).strict() +export type ThermalContext = z.infer +export interface ThermalNight extends ThermalContext { date: string, correction: -1 | 0 | 1 } +export const thermalCollectionSchema = z.object({ + feedbackNights: z.number().int().nonnegative(), + excluded: z.array(z.object({ date: z.string(), reason: z.string() })), + policy: z.literal('complete-reviewed-nights-v1'), +}) +export const thermalLearningSchema = z.object({ + model: z.literal('context-neighbors-v1'), mode: z.literal('research_preview'), + nights: z.number().int().nonnegative(), requiredNights: z.literal(20), + trainingNights: z.number().int().nonnegative(), validationNights: z.number().int().nonnegative(), + modelErrorF: z.number().nullable(), baselineErrorF: z.number().nullable(), + suggestedOffsetF: z.number().min(-1).max(1).nullable(), reason: z.string(), + collection: thermalCollectionSchema.optional(), +}) +export type ThermalLearning = z.infer + +function distance(first: ThermalContext, second: ThermalContext) { + // Scaling defines a similar thermal context; elapsed time and sleep stages are deliberately not guessed. + return Math.sqrt(((first.targetF - second.targetF) / 3) ** 2 + + ((first.roomF - second.roomF) / 3) ** 2 + ((first.humidity - second.humidity) / 15) ** 2 + + (((first.waterF - first.targetF) - (second.waterF - second.targetF)) / 3) ** 2) +} +function predict(training: ThermalNight[], context: ThermalContext): number | null { + const neighbors = training.map(night => ({ night, distance: distance(night, context) })) + .filter(item => item.distance <= 2).sort((first, second) => first.distance - second.distance).slice(0, 7) + if (neighbors.length < 3) return null + const weights = neighbors.map(item => 1 / (0.25 + item.distance)) + return neighbors.reduce((total, item, index) => total + item.night.correction * weights[index], 0) + / weights.reduce((total, weight) => total + weight, 0) +} + +/** Whole-night chronological holdout. This advisory model never sends an actuator command. */ +export function evaluateThermalLearning(input: ThermalNight[], context: ThermalContext | null, today: string): ThermalLearning { + const dates = new Map() + for (const night of input) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(night.date) || !Number.isFinite(Date.parse(night.date)) + || new Date(night.date).toISOString().slice(0, 10) !== night.date || night.date >= today + || ![-1, 0, 1].includes(night.correction) || !thermalContextSchema.safeParse({ + targetF: night.targetF, waterF: night.waterF, roomF: night.roomF, humidity: night.humidity, + }).success) continue + dates.set(night.date, [...dates.get(night.date) ?? [], night]) + } + // Ambiguous duplicate nights are excluded, never used on both sides of the split. + const nights = [...dates.values()].filter(group => group.length === 1).map(group => group[0]) + .sort((first, second) => first.date.localeCompare(second.date)) + const report: ThermalLearning = { model: 'context-neighbors-v1', mode: 'research_preview', nights: nights.length, + requiredNights: 20, trainingNights: 0, validationNights: 0, modelErrorF: null, baselineErrorF: null, + suggestedOffsetF: null, reason: 'Learning from complete, reviewed nights with room readings, reported water temperature and a morning check-in. Each night needs at least six hours and 90% coverage of its overnight window. At least 20 matched nights are required; collecting data does not activate temperature changes.' } + if (nights.length < 20) return report + const validationCount = Math.max(5, Math.ceil(nights.length / 4)) + const training = nights.slice(0, -validationCount), validation = nights.slice(-validationCount) + report.trainingNights = training.length + report.validationNights = validation.length + const predictions = validation.map(night => predict(training, night)) + if (predictions.some(value => value === null)) return { ...report, reason: 'Held-out nights include thermal conditions with insufficient training coverage. No recommendation.' } + const meanCorrection = training.reduce((total, night) => total + night.correction, 0) / training.length + const error = (values: number[]) => values.reduce((total, value, index) => total + Math.abs(value - validation[index].correction), 0) / validation.length + report.modelErrorF = error(predictions as number[]) + report.baselineErrorF = Math.min(error(validation.map(() => 0)), error(validation.map(() => meanCorrection))) + if (report.modelErrorF > report.baselineErrorF * 0.9 || report.baselineErrorF - report.modelErrorF < 0.05) { + return { ...report, reason: 'The contextual model has not beaten the simpler hold-temperature or average-feedback baseline on later nights. Keep the current routine.' } + } + if (!context || !thermalContextSchema.safeParse(context).success) return { ...report, reason: 'Validation passed on comfort labels. Waiting for fresh thermal context; no recommendation.' } + const proposal = predict(training, context) + if (proposal === null) return { ...report, reason: 'Current room or bed conditions fall outside the model’s supported examples. Keep the current routine.' } + return { ...report, suggestedOffsetF: Math.max(-1, Math.min(1, Math.round(proposal))), + reason: 'Research suggestion only, within 1°F. Validation measures prediction of comfort feedback, not improved sleep. Your saved routine remains in control.' } +} diff --git a/src/comfort/thermal-model-store.ts b/src/comfort/thermal-model-store.ts new file mode 100644 index 000000000..b58407e22 --- /dev/null +++ b/src/comfort/thermal-model-store.ts @@ -0,0 +1,92 @@ +import type Database from 'better-sqlite3' +import { z } from 'zod' +import type { Side } from '@/src/hardware/types' +import { durableTransaction } from '@/src/db/durable' +import { thermalDatasetAudit, type ThermalDatasetAudit } from './thermal-dataset' +import { THERMAL_MODEL_MINIMUM_NIGHTS, predictThermalModel, thermalModelHash, thermalModelSchema, trainThermalModel } from './thermal-model' +import type { ThermalContext } from './thermal-learning' +import { PreviewReportError } from './report-summary' + +export const thermalModelStatusSchema = z.object({ + apiVersion: z.literal(1), side: z.enum(['left', 'right']), generatedAt: z.number().int().positive(), + requiredNights: z.literal(20), completeNights: z.number().int().nonnegative(), feedbackNights: z.number().int().nonnegative(), + datasetHash: z.string().regex(/^[a-f0-9]{64}$/), canTrain: z.boolean(), + excluded: z.array(z.object({ date: z.string(), reason: z.string() })), + model: thermalModelSchema.omit({ coefficients: true, support: true }).extend({ id: z.string(), current: z.boolean() }).nullable(), + reason: z.string(), +}) + +/** Keep one physical, schedule and calibration context per fit; household profiles use a separate boundary. */ +function snapshot(audit: ThermalDatasetAudit) { + const latest = [...audit.proofs].sort((first, second) => second.date.localeCompare(first.date))[0] + const proofs = audit.proofs.filter(proof => proof.configuration === latest?.configuration).sort((first, second) => first.date.localeCompare(second.date)) + const included = new Set(proofs.map(proof => proof.date)) + const nights = audit.nights.filter(night => included.has(night.date)).sort((first, second) => first.date.localeCompare(second.date)) + const excluded = [...audit.collection.excluded, ...audit.proofs.filter(proof => !included.has(proof.date)) + .map(proof => ({ date: proof.date, reason: 'This night used a different calibration, routine or room configuration.' }))] + return { proofs, nights, excluded, configuration: latest?.configuration ?? null, + hash: thermalModelHash({ policy: audit.collection.policy, nights, proofs }) } +} + +/** Training is explicitly requested, never triggered by viewing a status page, and never sends commands. */ +export function createThermalModels(database: Database.Database, now: () => number = Date.now) { + function inspect(side: Side) { + const at = now() + if (!Number.isSafeInteger(at) || at <= 0) throw new PreviewReportError('Controller clock is unavailable.', 'PRECONDITION_FAILED') + const audit = thermalDatasetAudit(database, side, at) + return { at, audit, data: snapshot(audit) } + } + function latest(side: Side) { + const row = database.prepare('SELECT id, record FROM comfort_models WHERE side = ? ORDER BY created_at DESC, rowid DESC LIMIT 1') + .get(side) as { id: string, record: string } | undefined + if (!row) return null + const model = thermalModelSchema.parse(JSON.parse(row.record)) + if (model.side !== side || thermalModelHash(model) !== row.id) throw new Error('Stored thermal model failed its integrity check.') + return { id: row.id, model } + } + function status(side: Side) { + return database.transaction(() => { + const { at, audit, data } = inspect(side) + const stored = latest(side) + const current = stored !== null && stored.model.createdAt <= at && stored.model.datasetHash === data.hash + const model = stored ? { ...thermalModelSchema.omit({ coefficients: true, support: true }).strip().parse(stored.model), id: stored.id, current } : null + return thermalModelStatusSchema.parse({ apiVersion: 1, side, generatedAt: at, requiredNights: THERMAL_MODEL_MINIMUM_NIGHTS, + completeNights: data.nights.length, feedbackNights: audit.collection.feedbackNights, datasetHash: data.hash, + canTrain: data.nights.length >= THERMAL_MODEL_MINIMUM_NIGHTS, excluded: data.excluded, model, + reason: stored && !current + ? 'The recording set or its reviews changed. Recheck and retrain before using this model.' + : stored + ? stored.model.validation.reason + : `No fitted temperature model yet. ${data.nights.length} complete, reviewed nights match; ${THERMAL_MODEL_MINIMUM_NIGHTS} are required. Health history alone cannot supply missing bed temperatures or comfort labels.` }) + })() + } + function train(side: Side, expectedDatasetHash: string) { + // The audit and fit are bound to one snapshot; a changed review or check-in refuses a stale Train action. + const { at, data } = database.transaction(() => inspect(side))() + if (data.hash !== expectedDatasetHash) throw new PreviewReportError('Training data changed. Check the nights again.', 'CONFLICT') + if (!data.configuration || data.nights.length < THERMAL_MODEL_MINIMUM_NIGHTS) { + throw new PreviewReportError('Twenty complete reviewed nights with matching thermal data and feedback are required.', 'PRECONDITION_FAILED') + } + const existing = latest(side) + if (existing?.model.createdAt && existing.model.createdAt > at) throw new PreviewReportError('Controller clock precedes the saved model. Correct the clock before training.', 'PRECONDITION_FAILED') + if (existing?.model.datasetHash === data.hash) return status(side) + const model = trainThermalModel(data.nights, { side, createdAt: at, configuration: data.configuration, datasetHash: data.hash }) + durableTransaction(database, () => { + if (inspect(side).data.hash !== data.hash) throw new PreviewReportError('Training data changed before saving.', 'CONFLICT') + database.prepare('INSERT INTO comfort_models (id, side, created_at, dataset_hash, record) VALUES (?, ?, ?, ?, ?)') + .run(thermalModelHash(model), side, at, data.hash, JSON.stringify(model)) + }) + return status(side) + } + function preview(side: Side, context: ThermalContext | null, configuration: string | null) { + return database.transaction(() => { + const { at, data } = inspect(side) + const stored = latest(side) + if (!stored || stored.model.createdAt > at || stored.model.datasetHash !== data.hash || configuration !== stored.model.configuration) { + return { offsetF: null, rawDirection: null, reason: 'A current fitted model and matching sensor/routine configuration are required.' } + } + return predictThermalModel(stored.model, context) + })() + } + return { status, train, preview } +} diff --git a/src/comfort/thermal-model.ts b/src/comfort/thermal-model.ts new file mode 100644 index 000000000..e5abda379 --- /dev/null +++ b/src/comfort/thermal-model.ts @@ -0,0 +1,125 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' +import { thermalContextSchema, type ThermalContext, type ThermalNight } from './thermal-learning' + +export const THERMAL_MODEL_MINIMUM_NIGHTS = 20 +const FEATURE_COUNT = 5 +const hash = z.string().regex(/^[a-f0-9]{64}$/) +const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) +const finite = z.number().finite() + +export const thermalModelSchema = z.object({ + version: z.literal('comfort-ridge-v1'), purpose: z.literal('morning-comfort-direction'), + mode: z.literal('research_preview'), side: z.enum(['left', 'right']), + createdAt: z.number().int().positive(), datasetHash: hash, configuration: hash, + trainingDates: z.array(date).min(15).max(90), validationDates: z.array(date).min(5).max(30), + coefficients: z.array(finite).length(FEATURE_COUNT), + support: z.array(thermalContextSchema).min(15).max(90), + validation: z.object({ + modelMae: finite.nonnegative(), holdMae: finite.nonnegative(), averageMae: finite.nonnegative(), + residual90: finite.nonnegative(), supportedNights: z.number().int().nonnegative(), + passesFeedbackCheck: z.boolean(), reason: z.string(), + }).strict(), +}).strict() +export type ThermalModel = z.infer +export const thermalPredictionSchema = z.object({ + offsetF: z.number().min(-1).max(1).nullable(), rawDirection: finite.nullable(), + reason: z.string(), +}).strict() + +/** Fixed scales and regularization are chosen before evaluation; no held-out night affects fitting. */ +function features(context: ThermalContext): number[] { + return [1, (context.targetF - 72) / 3, (context.waterF - context.targetF) / 3, + (context.roomF - 72) / 3, (context.humidity - 45) / 15] +} + +/** A five-parameter ridge regression is small enough to fit and evaluate on the embedded Pod. */ +function fit(nights: ThermalNight[]): number[] { + const matrix = Array.from({ length: FEATURE_COUNT }, () => Array(FEATURE_COUNT + 1).fill(0)) + for (const night of nights) { + const values = features(night) + for (let row = 0; row < FEATURE_COUNT; row++) { + for (let column = 0; column < FEATURE_COUNT; column++) matrix[row][column] += values[row] * values[column] + matrix[row][FEATURE_COUNT] += values[row] * night.correction + } + } + for (let index = 1; index < FEATURE_COUNT; index++) matrix[index][index] += 1 + // Partial pivoting avoids unstable division when environmental features are correlated. + for (let column = 0; column < FEATURE_COUNT; column++) { + let pivot = column + for (let row = column + 1; row < FEATURE_COUNT; row++) { + if (Math.abs(matrix[row][column]) > Math.abs(matrix[pivot][column])) pivot = row + } + ;[matrix[column], matrix[pivot]] = [matrix[pivot], matrix[column]] + const divisor = matrix[column][column] + if (!Number.isFinite(divisor) || Math.abs(divisor) < 1e-10) throw new Error('Thermal training matrix is singular.') + for (let entry = column; entry <= FEATURE_COUNT; entry++) matrix[column][entry] /= divisor + for (let row = 0; row < FEATURE_COUNT; row++) { + if (row === column) continue + const factor = matrix[row][column] + for (let entry = column; entry <= FEATURE_COUNT; entry++) matrix[row][entry] -= factor * matrix[column][entry] + } + } + return matrix.map(row => row[FEATURE_COUNT]) +} + +function supported(support: ThermalContext[], context: ThermalContext): boolean { + const current = features(context) + return support.filter((sample) => { + const values = features(sample) + return Math.hypot(...values.slice(1).map((value, index) => value - current[index + 1])) <= 2 + }).length >= 3 +} + +function predict(coefficients: number[], context: ThermalContext): number { + return Math.max(-1, Math.min(1, features(context).reduce((total, value, index) => total + value * coefficients[index], 0))) +} + +export function thermalModelHash(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +/** The newest quarter stays unseen, including during final fitting. Labels are directions, not ideal °F. */ +export function trainThermalModel(input: ThermalNight[], metadata: Pick): ThermalModel { + if (input.length < THERMAL_MODEL_MINIMUM_NIGHTS || input.length > 90) throw new Error('At least 20 complete reviewed nights are required.') + const dates = new Set() + for (const night of input) { + thermalContextSchema.parse({ targetF: night.targetF, waterF: night.waterF, roomF: night.roomF, humidity: night.humidity }) + if (!date.safeParse(night.date).success || !Number.isFinite(Date.parse(night.date)) + || new Date(night.date).toISOString().slice(0, 10) !== night.date || dates.has(night.date) + || ![-1, 0, 1].includes(night.correction)) throw new Error('Training nights must be valid, distinct and reviewed.') + dates.add(night.date) + } + const nights = [...input].sort((first, second) => first.date.localeCompare(second.date)) + const heldOutCount = Math.max(5, Math.ceil(nights.length / 4)) + const training = nights.slice(0, -heldOutCount), heldOut = nights.slice(-heldOutCount) + const coefficients = fit(training) + const support = training.map(({ targetF, waterF, roomF, humidity }) => ({ targetF, waterF, roomF, humidity })) + const average = training.reduce((total, night) => total + night.correction, 0) / training.length + const residuals = heldOut.map(night => Math.abs(predict(coefficients, night) - night.correction)) + const mean = (values: number[]) => values.reduce((total, value) => total + value, 0) / values.length + const modelMae = mean(residuals), holdMae = mean(heldOut.map(night => Math.abs(night.correction))) + const averageMae = mean(heldOut.map(night => Math.abs(average - night.correction))) + const baseline = Math.min(holdMae, averageMae) + const supportedNights = heldOut.filter(night => supported(support, night)).length + const passesFeedbackCheck = supportedNights === heldOut.length && modelMae <= baseline * 0.9 && baseline - modelMae >= 0.05 + const sortedResiduals = [...residuals].sort((first, second) => first - second) + return thermalModelSchema.parse({ ...metadata, version: 'comfort-ridge-v1', purpose: 'morning-comfort-direction', mode: 'research_preview', + trainingDates: training.map(night => night.date), validationDates: heldOut.map(night => night.date), coefficients, support, + validation: { modelMae, holdMae, averageMae, residual90: sortedResiduals[Math.ceil(sortedResiduals.length * 0.9) - 1], supportedNights, + passesFeedbackCheck, reason: supportedNights !== heldOut.length + ? 'Later nights include conditions not represented in training.' + : passesFeedbackCheck + ? 'Predicted morning feedback better than both simple baselines on later nights. Sleep benefit and overnight timing remain unproven.' + : 'The model did not beat both holding temperature and average feedback. Keep the routine.' } }) +} + +export function predictThermalModel(model: ThermalModel, context: ThermalContext | null) { + const parsed = thermalModelSchema.safeParse(model) + if (!parsed.success || !parsed.data.validation.passesFeedbackCheck) return { offsetF: null, rawDirection: null, reason: 'This model has not passed its held-out feedback comparison.' } + if (!context || !thermalContextSchema.safeParse(context).success) return { offsetF: null, rawDirection: null, reason: 'Waiting for fresh qualified room and bed measurements.' } + if (!supported(model.support, context)) return { offsetF: null, rawDirection: null, reason: 'Current conditions are outside the supported training examples.' } + const rawDirection = predict(model.coefficients, context) + if (Math.abs(rawDirection) <= model.validation.residual90) return { offsetF: null, rawDirection, reason: 'The suggested direction is smaller than the model’s held-out error. No recommendation.' } + return { offsetF: Math.round(rawDirection), rawDirection, reason: 'Local model suggestion within 1°F. Preview only; the controller has not applied this suggestion.' } +} diff --git a/src/comfort/thermal-observation.ts b/src/comfort/thermal-observation.ts new file mode 100644 index 000000000..a76e3ffbd --- /dev/null +++ b/src/comfort/thermal-observation.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' +import type { ComfortContext, ComfortState } from './policy' +import type { PreviewEvidence } from './report-contract' +import { currentBedroomGuidance } from '@/src/bedroom/fusion' +import { thermalContextSchema } from './thermal-learning' + +export const thermalObservationSchema = z.object({ + version: z.literal(1), purpose: z.literal('comfort_research'), + bedroomConfiguration: z.string().regex(/^[a-f0-9]{64}$/), + sleepWindowEnd: z.number().int().positive().optional(), +}).strict() + +/** Record observed thermal conditions independently of HR readiness, never control eligibility. */ +export function thermalObservation(state: ComfortState, context: ComfortContext, preview: PreviewEvidence | undefined) { + const { now, window, device } = context + if (state.config.mode !== 'preview' || !state.config.roomAware || !preview + || preview.environment !== 'production' || !preview.buildRevision || !preview.hardwareIdentity + || !preview.deviceFresh || !preview.mechanicallyQuiet || preview.manualPaused + || preview.presence !== 'occupied' || !preview.presenceSourceKey + || !window || now < window.start || now >= window.end + || (window.alarmAt !== null && (!Number.isFinite(window.alarmAt) || now >= window.alarmAt - 30 * 60_000)) + || context.eitherAway !== false || context.routinesPaused !== false || context.alarmOverride !== false + || context.targetKnown !== true || device.powered !== true || device.waterAdequate !== true + || !currentBedroomGuidance(context.bedroom, now)) return null + const thermal = thermalContextSchema.safeParse(context.thermalContext) + if (!thermal.success || thermal.data.targetF !== device.targetF + || thermal.data.targetF < state.config.minimumF || thermal.data.targetF > state.config.maximumF) return null + const receipt = thermalObservationSchema.safeParse({ version: 1, purpose: 'comfort_research', + bedroomConfiguration: context.bedroom?.configuration, + sleepWindowEnd: Math.min(window.end, window.alarmAt === null ? window.end : window.alarmAt - 30 * 60_000) }) + return receipt.success ? receipt.data : null +} diff --git a/src/components/AppShell/AppShell.tsx b/src/components/AppShell/AppShell.tsx index f12514e21..45a926127 100644 --- a/src/components/AppShell/AppShell.tsx +++ b/src/components/AppShell/AppShell.tsx @@ -3,9 +3,9 @@ import type { ReactNode } from 'react' import { ToastProvider } from '@/src/components/ds' import { useSwipeNavigation } from '@/src/hooks/useSwipeNavigation' -import { useBaseAvailable } from './useBaseAvailable' import { Sidebar } from './Sidebar' import { TabBar } from './TabBar' +import { MobileToolbar } from './MobileToolbar' /** * One responsive layout. ≥ 900px: 224px sidebar + content (28/32 padding). @@ -15,8 +15,7 @@ import { TabBar } from './TabBar' * overlay the content column. */ export function AppShell({ children }: { children: ReactNode }) { - const hasBase = useBaseAvailable() - const { onTouchStart, onTouchEnd } = useSwipeNavigation(hasBase) + const { onTouchStart, onTouchEnd } = useSwipeNavigation() return (
@@ -24,8 +23,14 @@ export function AppShell({ children }: { children: ReactNode }) {
+ + {process.env.NEXT_PUBLIC_DEMO === '1' && ( +

+ Demo · Sample data. Controls do not operate your Pod. +

+ )} {children}
diff --git a/src/components/AppShell/ConnectionGate.test.tsx b/src/components/AppShell/ConnectionGate.test.tsx new file mode 100644 index 000000000..1467c0307 --- /dev/null +++ b/src/components/AppShell/ConnectionGate.test.tsx @@ -0,0 +1,70 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { ConnectionGate } from './ConnectionGate' + +const fetchMock = vi.fn() +const response = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) +beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_DEMO', '') + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('BroadcastChannel', undefined) + fetchMock.mockReset() +}) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +it('unmounts owner views on an account change and ignores an older in-flight refresh', async () => { + fetchMock.mockResolvedValueOnce(response({ configured: true, authenticated: true })) + render(
Owner history
) + await screen.findByTestId('private-owner') + let finishRefresh: (value: Response) => void = () => {} + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { + finishRefresh = resolve + })) + fireEvent(window, new Event('focus')) + fireEvent(window, new StorageEvent('storage', { key: 'sleepypod-account-change', newValue: 'changed' })) + expect(screen.queryByTestId('private-owner')).toBeNull() + await act(async () => { + finishRefresh(response({ configured: true, authenticated: true })) + }) + expect(screen.queryByTestId('private-owner')).toBeNull() + expect(screen.getByRole('alert').textContent).toContain('personal account') +}) + +it('does not mount owner data when an account switch happens during owner login', async () => { + fetchMock.mockResolvedValueOnce(response({ configured: true, authenticated: false })) + render(
Owner history
) + await screen.findByLabelText('Connection key') + fetchMock.mockResolvedValueOnce(response({ browserScopeRevision: 'current-browser-revision' })) + let finishLogin: (value: Response) => void = () => {} + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { + finishLogin = resolve + })) + fireEvent.change(screen.getByLabelText('Connection key'), { target: { value: 'example-owner-key-that-is-long-enough' } }) + fireEvent.click(screen.getByRole('button', { name: 'Connect to your Pod' })) + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)) + fireEvent(window, new StorageEvent('storage', { key: 'sleepypod-account-change', newValue: 'changed' })) + await act(async () => { + finishLogin(response({ authenticated: true })) + }) + expect(screen.queryByTestId('private-owner')).toBeNull() + expect(screen.getByRole('alert').textContent).toContain('account changed') +}) + +it('offers personal connection instead of an owner-key login after shared activation', async () => { + fetchMock.mockResolvedValueOnce(response({ configured: true, authenticated: false, personalConnectionRequired: true })) + render(
Owner history
) + expect((await screen.findByRole('link', { name: 'Open my personal workspace' })).getAttribute('href')).toBe('/household/index.html') + expect(screen.queryByLabelText('Connection key')).toBeNull() + expect(screen.queryByTestId('private-owner')).toBeNull() +}) + +it('does not mount cached owner data when privacy storage is unavailable', async () => { + fetchMock.mockResolvedValueOnce(response({ configured: true, authenticated: true, privacyAvailable: false })) + render(
Owner history
) + await screen.findByRole('alert') + expect(screen.queryByTestId('private-owner')).toBeNull() +}) diff --git a/src/components/AppShell/ConnectionGate.tsx b/src/components/AppShell/ConnectionGate.tsx new file mode 100644 index 000000000..11b6f11af --- /dev/null +++ b/src/components/AppShell/ConnectionGate.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react' + +/** Keep device queries and streaming components unmounted until the owner has connected. */ +export function ConnectionGate({ children }: { children: ReactNode }) { + const [state, setState] = useState<'checking' | 'connected' | 'locked' | 'unconfigured' | 'personal'>('checking') + const [token, setToken] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const accountGeneration = useRef(0) + const personalSessionActive = useRef(false) + const demo = process.env.NEXT_PUBLIC_DEMO === '1' + + useEffect(() => { + if (demo) return + let alive = true + const refresh = async () => { + const generation = accountGeneration.current + try { + const response = await fetch('/api/auth/session', { cache: 'no-store' }) + if (!response.ok) throw new Error('Controller unavailable') + const result = await response.json() as { configured: boolean, authenticated: boolean, personalSessionActive?: boolean, personalConnectionRequired?: boolean, privacyAvailable?: boolean } + if (result.privacyAvailable === false) throw new Error('Privacy state unavailable') + if (alive && generation === accountGeneration.current) { + personalSessionActive.current = result.personalSessionActive === true + setState(result.personalConnectionRequired ? 'personal' : result.authenticated ? 'connected' : result.configured ? 'locked' : 'unconfigured') + } + } + catch { + if (alive && generation === accountGeneration.current) { + setState('locked') + setError('Could not reach the controller. Check your connection.') + } + } + } + void refresh() + const personalConnectionChanged = () => { + accountGeneration.current += 1 + personalSessionActive.current = true + // Unmount private caches and subscriptions immediately when another tab + // switches into a personal account; the next focus also rechecks cookies. + setToken('') + setState('locked') + setError('A personal account was opened in this browser. Reconnect explicitly to use owner controls.') + } + const changedInStorage = (event: StorageEvent) => { + if (event.key === 'sleepypod-account-change') personalConnectionChanged() + } + const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel('sleepypod-account') + if (channel) channel.onmessage = personalConnectionChanged + window.addEventListener('storage', changedInStorage) + const interval = setInterval(() => refresh(), 30_000) + window.addEventListener('focus', refresh) + return () => { + alive = false + clearInterval(interval) + window.removeEventListener('focus', refresh) + window.removeEventListener('storage', changedInStorage) + channel?.close() + } + }, [demo]) + + async function connect(event: FormEvent) { + event.preventDefault() + setBusy(true) + setError('') + const generation = accountGeneration.current + try { + const currentResponse = await fetch('/api/auth/session', { cache: 'no-store' }) + if (!currentResponse.ok) throw new Error('Could not read the current browser account.') + const current = await currentResponse.json() as { browserScopeRevision?: string | null, personalConnectionRequired?: boolean } + if (current.personalConnectionRequired) { + setState('personal') + setToken('') + return + } + if (generation !== accountGeneration.current) throw new Error('The browser account changed. Reconnect explicitly.') + const response = await fetch('/api/auth/session', { + method: 'POST', headers: { 'Content-Type': 'application/json', ...(current.browserScopeRevision ? { 'X-Sleepypod-Browser-Revision': current.browserScopeRevision } : {}) }, body: JSON.stringify({ token, exitPersonalSession: personalSessionActive.current }), + }) + if (!response.ok) { + const failure = await response.json().catch(() => null) as { code?: string } | null + if (failure?.code === 'personal_connection_required') { + setState('personal') + setToken('') + return + } + throw new Error(response.status === 401 ? 'That connection key was not accepted.' : 'Could not connect to the controller.') + } + if (generation !== accountGeneration.current) throw new Error('The browser account changed while connecting. Try again explicitly.') + setToken('') + try { + localStorage.setItem('sleepypod-account-change', `${Date.now()}:owner`) + } + catch { /* Cookie/focus checks still apply when storage is unavailable. */ } + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel('sleepypod-account') + channel.postMessage('owner-account-changed') + channel.close() + } + setState('connected') + } + catch (failure) { setError(failure instanceof Error ? failure.message : 'Connection failed.') } + finally { setBusy(false) } + } + + if (demo || state === 'connected') return children + return ( +
+
+

sleepypod

+

Connect to your Pod

+ {state === 'personal' && ( + <> +

This bed uses personal accounts to keep each sleeper’s settings and sleep data private. Open your personal workspace to connect.

+ {/* eslint-disable-next-line @next/next/no-html-link-for-pages -- This isolated static document must replace the owner React shell, not navigate within it. */} + Open my personal workspace + + )} + {state === 'checking' + ?

Checking your connection…

+ : state === 'personal' + ? null + : state === 'unconfigured' + ?

This controller needs an owner connection key. Complete controller setup before connecting.

+ : ( + <> +

Enter your Pod connection key. Your browser keeps a private, temporary session.

+ + + + )} + {error &&

{error}

} +
+
+ ) +} diff --git a/src/components/AppShell/MobileToolbar.tsx b/src/components/AppShell/MobileToolbar.tsx new file mode 100644 index 000000000..d9013d5e7 --- /dev/null +++ b/src/components/AppShell/MobileToolbar.tsx @@ -0,0 +1,37 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { Settings } from 'lucide-react' +import { cn } from '@/lib/utils' +import { langFromPath } from './navItems' +import { usePodHealth } from './usePodHealth' +import { NotificationInbox } from './NotificationInbox' + +/** Secondary destinations stay within reach without crowding the three phone tabs. */ +export function SettingsLink({ className, summary }: { className?: string, summary?: string }) { + const lang = langFromPath(usePathname()) + return ( + + + {summary && } + + ) +} + +export function MobileToolbar() { + const { footer } = usePodHealth() + return ( +
+ sleepypod +
+ + +
+
+ ) +} diff --git a/src/components/AppShell/NotificationInbox.tsx b/src/components/AppShell/NotificationInbox.tsx new file mode 100644 index 000000000..53320ab9c --- /dev/null +++ b/src/components/AppShell/NotificationInbox.tsx @@ -0,0 +1,192 @@ +'use client' + +import { Bell, Check } from 'lucide-react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { trpc } from '@/src/utils/trpc' +import { Alert, Button, Modal, useToast } from '@/src/components/ds' +import { foregroundDelivery, isUnreadAlert } from '@/src/notifications/foreground' + +const queryOptions = { + refetchInterval: 5000, refetchIntervalInBackground: false, refetchOnWindowFocus: true, + staleTime: 0, gcTime: 0, retry: false, trpc: { abortOnUnmount: true }, +} as const + +/** One owner-session inbox; no local storage, hardware writes, or background push. */ +export function NotificationInbox() { + const [open, setOpen] = useState(false) + const [beforeId, setBeforeId] = useState() + const [writeError, setWriteError] = useState() + const latest = trpc.notifications.list.useQuery({}, queryOptions) + const history = trpc.notifications.list.useQuery({ beforeId }, { ...queryOptions, enabled: open && beforeId !== undefined }) + const acknowledge = trpc.notifications.acknowledge.useMutation({ retry: false }) + const selected = beforeId === undefined ? latest : history + const page = selected.data + const cursor = useRef(undefined) + const trigger = useRef(null) + const bodyId = useId() + const { show } = useToast() + const close = useCallback(() => setOpen(false), []) + + useEffect(() => { + if (!latest.data || latest.isError) return + const delivered = foregroundDelivery(latest.data, cursor.current) + cursor.current = delivered.cursor + if (delivered.items.length && document.visibilityState === 'visible') { + show({ id: 'automation-inbox', kind: 'info', tone: 'cool', icon: Bell, + title: delivered.items.length === 1 ? delivered.items[0].title : `${delivered.items.length} new automation alerts`, + detail: 'Open Alerts to read and acknowledge.', + }) + } + }, [latest.data, latest.isError, show]) + + // Keep keyboard focus inside the shared sheet/dialog, then return to its trigger. + useEffect(() => { + if (!open) return + const dialog = document.getElementById(bodyId)?.closest('[role="dialog"]') + const buttons = () => Array.from(dialog?.querySelectorAll('button:not([disabled])') ?? []) + buttons()[0]?.focus() + const trap = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return + const controls = buttons() + const first = controls[0] + const last = controls.at(-1) + if ((event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) { + event.preventDefault() + const target = event.shiftKey ? last : first + target?.focus() + } + } + document.addEventListener('keydown', trap) + const restore = trigger.current + return () => { + document.removeEventListener('keydown', trap) + restore?.focus() + } + }, [open, bodyId]) + + async function markRead(ids: number[]) { + setWriteError(undefined) + try { + const result = await acknowledge.mutateAsync({ ids }) + if (result.missingIds.length) setWriteError('Some alerts are no longer available. The inbox has been refreshed.') + } + catch { + setWriteError('Could not confirm the read status. Refreshing the inbox; you can safely try again.') + } + // Reconcile even when the reply was lost; never optimistically claim success. + const refreshedLatest = await latest.refetch() + const refreshedHistory = beforeId !== undefined ? await history.refetch() : undefined + const confirmed = [refreshedLatest, refreshedHistory].flatMap(result => result && !result.isError ? result.data?.items ?? [] : []) + if (ids.every(id => confirmed.some(item => item.id === id && item.acknowledgedAt !== null))) setWriteError(undefined) + } + + const unread = page?.items.filter(item => isUnreadAlert(item, page.generatedAt)) ?? [] + const count = latest.data?.unreadCount ?? 0 + const unavailable = latest.isError || selected.isError + return ( + <> + + +
+

Alerts arrive here while the app is open. Background push is not available. Unread alerts expire after 15 minutes; history is kept for up to 30 days or 1,000 alerts.

+ {page?.delivery.foregroundInbox === false &&

Connect to your Pod to receive real automation alerts.

} + {unavailable && ( + { + void latest.refetch() + if (beforeId !== undefined) void history.refetch() + }} + > + Retry + + )} + > + Cannot refresh alerts. Any entries shown are from the last successful check. + + )} + {writeError && {writeError}} + {!page && !selected.isError &&

Loading alerts…

} + {page && ( + <> +
+ + Last checked + {' '} + {new Date(page.generatedAt).toLocaleTimeString()} + + +
+ {!page.items.length && !unavailable &&

No automation alerts yet. Preview rules only record proposed actions in Activity.

} +
    + {page.items.map(item => ( +
  • +
    +

    {item.title}

    + {item.acknowledgedAt !== null ? 'Read' : isUnreadAlert(item, page.generatedAt) ? 'Unread' : 'Expired'} +
    +

    {item.message}

    +
    + + {isUnreadAlert(item, page.generatedAt) && ( + + )} +
    +
  • + ))} +
+
+ {beforeId !== undefined && } + {page.nextBeforeId !== null && ( + + )} +
+ + )} +
+
+ + ) +} diff --git a/src/components/AppShell/OwnerSessionControls.tsx b/src/components/AppShell/OwnerSessionControls.tsx new file mode 100644 index 000000000..873d5c708 --- /dev/null +++ b/src/components/AppShell/OwnerSessionControls.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useState } from 'react' + +export function OwnerSessionControls() { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + if (process.env.NEXT_PUBLIC_DEMO === '1') return null + + async function disconnect() { + setBusy(true) + setError('') + try { + const currentResponse = await fetch('/api/auth/session', { cache: 'no-store' }) + if (!currentResponse.ok) throw new Error('Could not read this browser session. Try again.') + const current = await currentResponse.json() as { browserScopeRevision?: string | null } + const response = await fetch('/api/auth/session', { method: 'DELETE', headers: current.browserScopeRevision ? { 'X-Sleepypod-Browser-Revision': current.browserScopeRevision } : {} }) + if (!response.ok) throw new Error('Could not end this browser session. Try again.') + try { + localStorage.setItem('sleepypod-account-change', `${Date.now()}:signed-out`) + } + catch { /* Other tabs also recheck authorization when focused. */ } + if (typeof BroadcastChannel !== 'undefined') { + const channel = new BroadcastChannel('sleepypod-account') + channel.postMessage('signed-out') + channel.close() + } + // A full reload discards private query caches and closes live subscriptions. + window.location.reload() + } + catch (failure) { + setError(failure instanceof Error ? failure.message : 'Could not disconnect.') + setBusy(false) + } + } + + return ( +
+

Browser connection

+

End this browser session. Your Pod continues its local routines.

+ + {error &&

{error}

} +
+ ) +} diff --git a/src/components/AppShell/TabBar.tsx b/src/components/AppShell/TabBar.tsx index 0c3dbd53c..edcc1b5a2 100644 --- a/src/components/AppShell/TabBar.tsx +++ b/src/components/AppShell/TabBar.tsx @@ -2,29 +2,44 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' +import { useEffect, useRef } from 'react' import { cn } from '@/lib/utils' import { useScheduleActive } from '@/src/hooks/useScheduleActive' -import { activeNavId, langFromPath, NAV_ITEMS } from './navItems' -import { useBaseAvailable } from './useBaseAvailable' -import { usePodHealth } from './usePodHealth' +import { activeNavId, langFromPath, PHONE_NAV_ITEMS } from './navItems' /** Phone bottom tab bar (< 900px). Respects the safe-area inset. */ export function TabBar({ className }: { className?: string }) { - const hasBase = useBaseAvailable() - const items = NAV_ITEMS.filter(item => item.id !== 'base' || hasBase) + const items = PHONE_NAV_ITEMS const pathname = usePathname() const lang = langFromPath(pathname) const active = activeNavId(pathname) const { isActive: scheduleActive } = useScheduleActive() - const { footer } = usePodHealth() + const navigation = useRef(null) + + // Large text and safe-area insets change the bar height; keep the stage above it. + useEffect(() => { + const element = navigation.current + if (!element || typeof ResizeObserver === 'undefined') return + const measure = () => { + const height = element.getBoundingClientRect().height + if (height > 0) document.documentElement.style.setProperty('--phone-nav-height', `${height}px`) + } + const observer = new ResizeObserver(measure) + observer.observe(element) + measure() + return () => { + observer.disconnect() + document.documentElement.style.removeProperty('--phone-nav-height') + } + }, []) return (