Self-hosted control app for Pod 3, 4, and 5. Runs on the Pod's stock embedded Linux — replaces the cloud-bound controller with a local web UI, scheduler, on-device biometrics, and native integrations for Home Assistant (MQTT) and Apple Home (HomeKit).
Requires a Pod running its stock embedded Linux. Run as root on the device:
curl -fsSL https://raw.githubusercontent.com/sleepypod/core/main/scripts/install | sudo bashThe script:
- Installs Node.js and pnpm (if absent)
- Downloads the latest release (pre-built) or builds from source as fallback
- Installs dependencies and detects
dac.sockpath - Runs database migrations and writes
.env - Installs and starts the
sleepypod.servicesystemd unit - Installs Python biometrics modules with isolated virtualenvs
- Optionally configures SSH on port 8822 with key-only auth
After install, these are available system-wide:
sp-status # systemctl status sleepypod.service
sp-restart # restart the service
sp-logs # journalctl -u sleepypod.service -f
sp-update # pull latest, rebuild, migrate, restart (with automatic rollback)
sp-maintenance # one-shot manual prime / reboot / status
sp-uninstall # stop services, remove systemd units, optionally wipe data- Temperature scheduling — set per-side temperature programs by day and time
- Power scheduling — automatic on/off with optional warm-up temperature
- Alarm management — vibration alarms with configurable duration (intensity/pattern accepted by the API; cover MCU clamps both on Pod 5 J55 — see
docs/hardware/alarms.md) - Biometrics — heart rate, HRV, breathing rate, sleep session tracking, and movement from the Pod's own sensors
- Daily maintenance — automated priming and system reboots on a schedule
- Local web UI — accessible on your home network, no cloud required
- MQTT bridge (opt-in) — publish state and accept commands on your home MQTT broker; auto-discoverable in Home Assistant
- HomeKit bridge (opt-in) — control the pod from the Apple Home app; local-only, no Apple servers
The Pod ships an embedded hap-nodejs bridge that publishes itself as a native HomeKit accessory over Bonjour. No Homebridge install, no Apple cloud round-trip — pairing and control happen entirely on your LAN.
graph LR
iOS[iPhone / iPad<br/>Home app] -- HAP / mDNS --> Pod[Pod<br/>sleepypod-core<br/>:51827]
Pod -- setPower / setTemperature --> DAC[Pod hardware]
Pod -. occupancy / vitals .- BIO[(biometrics.db)]
The bridge is off by default and lives behind the same iptables LAN-only policy as the rest of the app — toggle it on from Settings → HomeKit.
- Open Settings → HomeKit in the Pod web UI and flip HomeKit bridge on.
- A QR code and 8-digit setup code render in the panel.
- In iOS Home: Add Accessory → More options (or scan the QR), enter the code.
- The Pod shows up as a bridge with one tile per side per accessory.
- Use Unpair all controllers in the same panel to reset pairing without regenerating the bridge identity (automations stay intact).
Each side (left, right) gets its own set; switches that act on the whole
pod ship once.
| Accessory | Type | Reads from | Writes to |
|---|---|---|---|
Bed <side> |
Thermostat (single setpoint) | deviceStatus.<side> |
setTemperature / setPower |
Bed <side> power |
Switch | deviceStatus.<side>.powered |
setPower (preserves last setpoint) |
Bed <side> occupancy |
OccupancySensor | sleep_records (latest with leftBedAt IS NULL) |
— |
Snooze <side> |
Switch | snoozeManager |
snoozeAlarm / cancelSnooze |
Prime |
Switch | primeNotification (auto-off on completion) |
startPriming |
Pod ambient |
TemperatureSensor | bed_temp.ambient_temp (centidegrees → °C) |
— |
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.
The bridge's HomeKit identity (MAC-style username, pincode, setupId) is
deterministically derived from a hardware-rooted seed (eMMC CID →
machine-id → random fallback) via HKDF, and cached at
$DATA_DIR/homekit/identity.json (where $DATA_DIR is the picker-chosen
data dir — see scripts/README.md;
typically /persistent/sleepypod-data/homekit/identity.json on Pod 4/5).
A /persistent wipe or
firmware reflash regenerates the same identity, so iOS still recognizes
the bridge — you only re-pair, your automations and rooms stay intact. See
ADR 0020 for the full
rationale, seed chain, and what the design intentionally does not protect
against.
Headless deployments can override the auto-detected mDNS advertiser. All
other config (enable/disable, pairing) lives in device_settings and is
managed from the UI.
| Variable | Default | Description |
|---|---|---|
HOMEKIT_ADVERTISER |
auto (avahi if /run/avahi-daemon/socket exists, else ciao) |
mDNS advertiser; force avahi to coexist with the existing _sleepypod._tcp service file, or ciao for pods without avahi |
The Pod can connect outbound to an MQTT broker you already run (typically the Mosquitto add-on that ships with Home Assistant). Off by default — opt in from Settings → MQTT in the web UI.
graph LR
Pod[Pod<br/>sleepypod-core] -- mqtt:// --> Broker[(MQTT broker<br/>e.g. Mosquitto)]
Broker --> HA[Home Assistant]
Broker --> NR[Node-RED]
Broker --> Other[anything that<br/>speaks MQTT]
HA -. cmd/* .-> Broker
NR -. cmd/* .-> Broker
Broker -. cmd/* .-> Pod
The Pod is a client, not a broker. It does not embed a broker, does not listen on 1883, and does not punch holes in the LAN-only iptables policy.
- Open Settings → MQTT in the Pod web UI.
- Set Broker URL (
mqtt://broker.lan:1883ormqtts://...for TLS). - Optional: username + password. Leave blank for anonymous brokers.
- Hit Test Connection to verify reachability, then toggle Enable MQTT Bridge and Save.
- (Home Assistant users) flip on Home Assistant Discovery — climate, sensor, and switch entities show up automatically under the Pod's device page.
Headless deployments can skip the UI by setting environment variables;
the bridge resolves config in this order: device_settings row > env var > built-in default.
| UI field | Env var | Default |
|---|---|---|
| Enable bridge | MQTT_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 |
false |
| HA discovery prefix | MQTT_HA_DISCOVERY_PREFIX |
homeassistant |
| TLS | MQTT_TLS_ENABLED |
false |
| TLS allow self-signed | MQTT_TLS_INSECURE |
false |
<prefix> defaults to sleepypod. <device-id> is the slugified hostname
(override with MQTT_DEVICE_ID). All state topics are retained.
| Topic | Direction | Payload |
|---|---|---|
<prefix>/<device-id>/availability |
pod → broker | online / offline (LWT) |
<prefix>/<device-id>/state/device-status |
pod → broker | full deviceStatus JSON |
<prefix>/<device-id>/state/<side>/climate |
pod → broker | per-side temp / mode |
<prefix>/<device-id>/state/water-level |
pod → broker | low / ok / unknown |
<prefix>/<device-id>/state/biometrics/<side> |
pod → broker | latest HR / HRV / BR |
<prefix>/<device-id>/state/environment/ambient |
pod → broker | {"ts": <epoch_ms>, "temperature": <number|null>, "humidity": <number|null>} (°C, %) |
<prefix>/<device-id>/cmd/set-temperature |
broker → pod | {"side","temperature","duration?"} |
<prefix>/<device-id>/cmd/set-power |
broker → pod | {"side","powered","temperature?"} |
<prefix>/<device-id>/cmd/set-alarm |
broker → pod | {"side","vibrationIntensity","vibrationPattern","duration"} |
<prefix>/<device-id>/cmd/clear-alarm |
broker → pod | {"side"} |
<prefix>/<device-id>/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.
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 for the design rationale —
why client-not-broker, the credential storage decision, the tRPC dispatch
model, and what's deferred until protectedProcedure lands.
graph LR
subgraph HW ["Pod Hardware"]
DAC["dac.sock"]
FRANK["frankenfirmware<br/>(cwd: /persistent/biometrics)"]
end
subgraph RAWFS ["RAW frames — ADR 0018"]
TMPFS["/persistent/biometrics/*.RAW<br/>tmpfs · 500 MB · hot"]
ARCH["/persistent/biometrics-archive/<br/>eMMC · gzip cold"]
TMPFS -. "archiver timer 15m" .-> ARCH
end
subgraph TRANSPORT ["Hardware Transport"]
DT["DacTransport<br/>+ SequentialQueue"]
end
subgraph SIDECARS ["Biometrics Sidecars"]
PP["piezo-processor"]
SD["sleep-detector"]
BIODB[("biometrics.db")]
end
subgraph CORE ["sleepypod-core"]
subgraph READBUS ["Read Bus — 2s poll"]
DM["DacMonitor"]
SYNC["DeviceStateSync"]
end
subgraph WRITEBUS ["Write Bus — immediate"]
BMS["broadcastMutation<br/>Status()"]
end
API["tRPC API :3000"]
SCHED["Scheduler"]
BF["broadcastFrame()"]
WS["piezoStream<br/>WS :3001"]
DB[("sleepypod.db")]
end
subgraph CLIENTS ["Clients"]
UI["React UI"]
end
%% Hardware transport — single serialization point
DAC <--> DT
API --> DT
SCHED --> DT
DM --> DT
%% Read bus
DM --> SYNC
SYNC --> DB
DM --> BF
%% Write bus
API -->|on success| BMS
SCHED -->|on success| BMS
BMS --> BF
%% RAW pipeline — firmware writes hot tmpfs, archiver rolls cold to eMMC
FRANK -->|writes CBOR| TMPFS
%% WebSocket delivery
BF --> WS
TMPFS -->|tail CBOR| WS
WS -->|push frames| UI
%% Biometrics pipeline
TMPFS --> PP & SD
PP & SD --> BIODB
BIODB -->|query| API
%% App layer
API <--> DB
UI <-->|HTTP| API
The Pod hardware daemon (frankenfirmware) writes raw sensor data continuously as CBOR-encoded binary records into /persistent/biometrics/*.RAW, which is a 500 MB tmpfs to keep ~1 GB/day of writes off the eMMC. An archiver timer gzips files older than 15 minutes into /persistent/biometrics-archive/ on eMMC (cold storage with a pruner cap of 80% disk usage). See ADR 0018 for the firmware-integration rationale, including how SEQNO.RAW and state directories are symlinked through so the firmware sees its persistent state unchanged.
Independent Python sidecar processes tail the hot tmpfs files, extract signals, and write results to biometrics.db (durable on eMMC). The core app never touches raw data — it reads clean rows via tRPC.
sequenceDiagram
participant HW as Pod Hardware<br/>(frankenfirmware)
participant TMPFS as /persistent/biometrics<br/>(tmpfs, 500 MB)
participant ARCH as /persistent/biometrics-archive<br/>(eMMC, gzip cold)
participant PP as piezo-processor
participant SD as sleep-detector
participant BDB as biometrics.db
participant UI as Web UI
HW->>TMPFS: writes CBOR records (500Hz piezo, capSense)
loop every ~60s
PP->>TMPFS: tail + decode piezo-dual records
PP->>PP: bandpass filter, HR/HRV/breathing rate
PP->>BDB: INSERT vitals row
end
loop continuously
SD->>TMPFS: tail + decode capSense records
SD->>SD: capacitance threshold, presence/absence
SD->>BDB: INSERT sleep_record on session end
end
loop every 15 min
TMPFS->>ARCH: gzip files older than 15 min
end
UI->>BDB: tRPC getVitals / getSleepRecords
The core app also creates stub sleep records from device power transitions — independent of the sensor modules. This ensures a record exists even when biometrics modules are not running.
stateDiagram-v2
[*] --> Off
Off --> On: setPower true, stamp poweredOnAt
On --> Off: setPower false, write stub sleep record
On --> On: status updated, upsert device_state
The Pod's RTC can reset to ~2010 after a power cycle. The scheduler waits for a valid system clock before starting any cron jobs.
flowchart LR
A["instrumentation.ts<br/>register"] --> B{year >= 2024?}
B -- no --> C["wait 5s<br/>poll again"]
C --> B
B -- yes --> D[JobManager.loadSchedules]
D --> E["temperature jobs<br/>power jobs<br/>alarm jobs<br/>prime + reboot jobs"]
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript (strict) |
| UI | React 19 |
| API | tRPC v11 |
| Database | SQLite via better-sqlite3 |
| ORM | Drizzle ORM |
| Scheduler | node-schedule |
| i18n | Lingui |
| Package manager | pnpm |
| Test runner | Vitest |
| Linter | ESLint flat config + @stylistic |
| HomeKit bridge | hap-nodejs (embedded in next-server) |
| MQTT client | mqtt.js (outbound to user's broker) |
Two SQLite files with separate Drizzle connections and independent migration sets.
graph LR
subgraph sdb ["sleepypod.db — config & state"]
T1[device_state]
T2[device_settings]
T3[side_settings]
T4[temperature_schedules]
T5[power_schedules]
T6[alarm_schedules]
T7[tap_gestures]
T8[system_health]
end
subgraph bdb ["biometrics.db — time-series"]
B1[vitals]
B2[sleep_records]
B3[movement]
end
| Table | Purpose |
|---|---|
device_state |
Current temperature, power, water level per side |
device_settings |
Timezone, temperature unit, daily reboot/prime config |
side_settings |
Per-side name and away mode |
temperature_schedules |
Timed temperature change jobs |
power_schedules |
Timed on/off jobs with warm-up temperature |
alarm_schedules |
Vibration alarms with intensity, pattern, and duration |
tap_gestures |
Configurable double/triple-tap actions |
system_health |
Health status per component (core app + modules) |
run_once_sessions |
Ephemeral one-shot temperature / power runs |
| Table | Purpose |
|---|---|
vitals |
Heart rate, HRV, breathing rate — one row per ~60s interval |
vitals_quality |
Per-interval signal quality flags for the vitals row |
sleep_records |
Session boundaries, duration, exit count, presence intervals |
movement |
Per-interval movement scores |
bed_temp |
Per-side bed-surface temperature samples |
freezer_temp |
Reservoir / freezer-side temperature samples |
ambient_light |
Room ambient-light samples |
flow_readings |
Pump flow-rate samples |
water_level_readings |
Continuous water-level samples |
water_level_alerts |
Threshold crossings (low / refill events) |
calibration_profiles |
Saved sensor calibration coefficients (per ADR 0014) |
calibration_runs |
Audit log of calibration runs |
Biometrics uses WAL mode and a 5-second busy timeout so multiple sidecar processes can write concurrently without contention.
Modules are independent OS processes — any language, managed by systemd. They share biometrics.db as the data contract. A crash in a module has zero impact on the core app.
graph LR
subgraph Contract ["Schema as contract"]
S[biometrics-schema.ts]
end
subgraph Bundled
PP["piezo-processor (Python)"]
SD["sleep-detector (Python)"]
end
subgraph Future
CM["community-module (any language)"]
end
PP -->|writes to| S
SD -->|writes to| S
CM -.->|writes to| S
Each module ships a manifest.json:
{
"name": "piezo-processor",
"version": "1.0.0",
"description": "Heart rate, HRV, and breathing rate from piezo sensors",
"provides": ["vitals.heartRate", "vitals.hrv", "vitals.breathingRate"],
"writes": ["vitals"],
"service": "sleepypod-piezo-processor.service",
"language": "python"
}| Module | Input | Output | Method |
|---|---|---|---|
piezo-processor |
500 Hz piezoelectric (CBOR) | HR, HRV, breathing rate → vitals |
Bandpass filter + HeartPy peak detection + Welch PSD |
sleep-detector |
Capacitance presence (CBOR) | Session boundaries, exits → sleep_records, movement |
Threshold detection with ABSENCE_TIMEOUT_S session gating |
sleepypod-core/
├── src/
│ ├── app/ # Next.js App Router pages and layouts
│ ├── components/ # React components
│ ├── db/
│ │ ├── schema.ts # sleepypod.db schema (Drizzle)
│ │ ├── biometrics-schema.ts # biometrics.db schema (public contract)
│ │ ├── index.ts # main DB connection
│ │ ├── biometrics.ts # biometrics DB connection (WAL)
│ │ ├── migrations/ # sleepypod.db migrations
│ │ └── biometrics-migrations/ # biometrics.db migrations
│ ├── hardware/
│ │ ├── client.ts # dac.sock Unix socket client
│ │ ├── deviceStateSync.ts # status:updated → DB + stub sleep records
│ │ └── types.ts # DeviceStatus, SideStatus, etc.
│ ├── homekit/ # hap-nodejs bridge, accessories, identity
│ ├── streaming/ # piezoStream WS, bonjour announce, MQTT bridge
│ ├── services/ # autoOffWatcher, temperatureKeepalive, etc.
│ ├── modules/
│ │ └── types.ts # ModuleManifest interface
│ ├── scheduler/
│ │ ├── jobManager.ts # Orchestrates all scheduled jobs
│ │ └── scheduler.ts # node-schedule wrapper with events
│ └── server/
│ └── routers/ # tRPC routers
├── modules/
│ ├── piezo-processor/ # Python: HR/HRV/breathing from piezo
│ └── sleep-detector/ # Python: sleep sessions from capacitance
├── docs/
│ └── adr/ # Architecture Decision Records
├── scripts/
│ └── install # Full install + update script
├── instrumentation.ts # Scheduler init + graceful shutdown
├── drizzle.config.ts # Drizzle config for sleepypod.db
└── drizzle.biometrics.config.ts # Drizzle config for biometrics.db
| Variable | Default (dev) | Description |
|---|---|---|
DATABASE_URL |
file:./sleepypod.dev.db |
Path to sleepypod.db |
BIOMETRICS_DATABASE_URL |
file:./biometrics.dev.db |
Path to biometrics.db |
DAC_SOCK_PATH |
/persistent/deviceinfo/dac.sock |
Unix socket path for hardware control |
NODE_ENV |
development |
Set to production in the systemd service |
# Install dependencies
pnpm install
# Run dev server
pnpm dev
# Run tests
pnpm test
# Lint / type-check
pnpm lint
pnpm lint:fix
pnpm tsc
# Database — sleepypod.db
pnpm db:generate # generate migration from schema
pnpm db:migrate # apply migrations
pnpm db:studio # open Drizzle Studio
# Database — biometrics.db
pnpm db:biometrics:generate
pnpm db:biometrics:migrate
pnpm db:biometrics:studio
# i18n
pnpm lingui:extract # extract new user-facing strings for translationKey decisions are documented in docs/adr/:
| ADR | Decision |
|---|---|
| 0003 | TypeScript strict, React, Lingui for i18n |
| 0004 | Next.js App Router as the application framework |
| 0005 | tRPC for end-to-end type-safe API |
| 0006 | ESLint, Vitest, Conventional Commits, pnpm |
| 0010 | Drizzle ORM + SQLite for embedded constraints |
| 0012 | Plugin/sidecar architecture for biometrics |
| 0014 | Per-sensor calibration profiles |
| 0015 | Event bus: broadcast device state after mutations |
| 0017 | uv for Python module package management |
| 0018 | Tmpfs at /persistent/biometrics/ for live RAW frames + gzip cold archive on eMMC |
| 0019 | MQTT bridge for Home Assistant integration |
| 0020 | Deterministic HomeKit identity from hardware-rooted seed |
Why SQLite, not Postgres? The Pod is constrained ARM hardware. SQLite has no server process, fits under 1 MB of overhead, and handles the write volume (a few rows per minute) with headroom to spare.
Why two databases? Config/state and time-series biometrics have fundamentally different access patterns, retention, and backup needs. Keeping them separate means biometrics data can be cleared or exported without touching device config, and each DB can be tuned independently.
Why Python modules, not Node.js? Heart rate extraction from 500 Hz piezoelectric data requires FFT, bandpass filtering, and peak detection. Python's scipy/numpy ecosystem handles this naturally. A crash in a Python module has zero impact on the core app.
How does real-time data reach clients?
A WebSocket server on port 3001 (piezoStream) acts as a read-only pub/sub channel. It streams raw sensor data (piezo, bed temp, capacitance) by tailing /persistent/biometrics/*.RAW (tmpfs, per ADR 0018), and pushes deviceStatus frames via two buses:
- Read bus — DacMonitor polls hardware every 2s and broadcasts the authoritative
deviceStatusframe. This is the consistency backstop. - Write bus — After any hardware mutation succeeds (user-initiated via tRPC or automated via Scheduler),
broadcastMutationStatus()overlays the changed fields onto the last polled status and broadcasts immediately. All connected clients see the change within ~200ms.




