From 3dd11ff45d9dd8f9e62aa56813eb9d47e6d877b4 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 5 Apr 2026 22:17:17 -0700 Subject: [PATCH 1/3] feat: add wifi, room climate, and water level to device status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit device.getStatus now returns: - wifi: { signalStrength, ssid } from /proc/net/wireless (defaults on non-Linux) - roomClimate: { temperatureF, humidity, timestamp } from latest bed temp sensor - waterLevel: { level, timestamp } from latest water level reading All enrichment is best-effort — null values on failure. Closes #157, closes #192 --- src/hardware/wifi.ts | 45 ++++++++++++++++++++++++++++++++++++ src/server/routers/device.ts | 33 +++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/hardware/wifi.ts diff --git a/src/hardware/wifi.ts b/src/hardware/wifi.ts new file mode 100644 index 00000000..9e723009 --- /dev/null +++ b/src/hardware/wifi.ts @@ -0,0 +1,45 @@ +import { execSync } from 'child_process' + +export interface WifiInfo { + signalStrength: number + ssid: string +} + +export function getWifiInfo(): WifiInfo { + try { + const signal = parseSignalStrength() + const ssid = parseSSID() + return { signalStrength: signal, ssid } + } + catch { + return { signalStrength: -1, ssid: 'unknown' } + } +} + +function parseSignalStrength(): number { + try { + const raw = execSync('cat /proc/net/wireless', { encoding: 'utf-8', timeout: 2000 }) + const lines = raw.trim().split('\n') + // Skip header lines, parse the interface line + const dataLine = lines.find(l => l.includes(':')) + if (!dataLine) return -1 + const parts = dataLine.trim().split(/\s+/) + // Format: iface | status | link | level | noise ... + const link = parseFloat(parts[2]) + if (isNaN(link)) return -1 + // link quality is 0-70 on Linux, normalize to 0-100 + return Math.round(Math.min(100, (link / 70) * 100)) + } + catch { + return -1 + } +} + +function parseSSID(): string { + try { + return execSync('iwgetid -r', { encoding: 'utf-8', timeout: 2000 }).trim() || 'unknown' + } + catch { + return 'unknown' + } +} diff --git a/src/server/routers/device.ts b/src/server/routers/device.ts index 73075f76..1f52ab3d 100644 --- a/src/server/routers/device.ts +++ b/src/server/routers/device.ts @@ -1,9 +1,10 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { publicProcedure, router } from '@/src/server/trpc' -import { db } from '@/src/db' +import { db, biometricsDb } from '@/src/db' import { deviceState } from '@/src/db/schema' -import { eq } from 'drizzle-orm' +import { bedTemp, waterLevelReadings } from '@/src/db/biometrics-schema' +import { eq, desc } from 'drizzle-orm' import { withHardwareClient } from '@/src/server/helpers' import { getPrimeCompletedAt, dismissPrimeNotification } from '@/src/hardware/primeNotification' import { snoozeAlarm, cancelSnooze, getSnoozeStatus } from '@/src/hardware/snoozeManager' @@ -17,7 +18,8 @@ import { vibrationPatternSchema, alarmDurationSchema, } from '@/src/server/validation-schemas' -import { toC } from '@/src/lib/tempUtils' +import { toC, centiDegreesToF, centiPercentToPercent } from '@/src/lib/tempUtils' +import { getWifiInfo } from '@/src/hardware/wifi' // --------------------------------------------------------------------------- // Command name → HardwareCommand mapping for the raw execute endpoint @@ -142,6 +144,28 @@ export const deviceRouter = router({ const convertTemp = (f: number) => input.unit === 'C' ? Math.round(toC(f) * 10) / 10 : f + // Best-effort enrichment — nulls on failure + let roomClimate: { temperatureF: number | null, humidity: number | null, timestamp: Date | null } = { temperatureF: null, humidity: null, timestamp: null } + let waterLevel: { level: string | null, timestamp: Date | null } = { level: null, timestamp: null } + try { + const [latestBed] = await biometricsDb.select().from(bedTemp).orderBy(desc(bedTemp.timestamp)).limit(1) + if (latestBed) { + roomClimate = { + temperatureF: latestBed.ambientTemp !== null ? centiDegreesToF(latestBed.ambientTemp) : null, + humidity: latestBed.humidity !== null ? centiPercentToPercent(latestBed.humidity) : null, + timestamp: latestBed.timestamp, + } + } + const [latestWater] = await biometricsDb.select().from(waterLevelReadings).orderBy(desc(waterLevelReadings.timestamp)).limit(1) + if (latestWater) { + waterLevel = { + level: latestWater.level, + timestamp: latestWater.timestamp, + } + } + } + catch { /* enrichment is best-effort */ } + return { ...status, leftSide: { @@ -156,6 +180,9 @@ export const deviceRouter = router({ }, ...(primeCompletedAt && { primeCompletedNotification: { timestamp: primeCompletedAt } }), snooze: { left: leftSnooze, right: rightSnooze }, + wifi: getWifiInfo(), + roomClimate, + waterLevel, } }, 'Failed to get device status') }), From b63c89f5b79e1d605b628907da0f2b2b552b3f62 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 5 Apr 2026 22:38:29 -0700 Subject: [PATCH 2/3] fix: address code review findings - Replace execSync('cat ...') with readFileSync for /proc/net/wireless - Use spawnSync for iwgetid (no shell layer) - Flatten wifi fields to wifiStrength/wifiSSID per issue #157 spec - Return temperatureC (not temperatureF) per issue #192 spec - Serialize timestamps as unix ms (number) per spec - Narrow waterLevel.level type to 'low' | 'ok' | null - Move getWifiInfo() inside enrichment try/catch --- src/hardware/wifi.ts | 20 +++++++++++--------- src/server/routers/device.ts | 21 ++++++++++++++------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/hardware/wifi.ts b/src/hardware/wifi.ts index 9e723009..2591fe7e 100644 --- a/src/hardware/wifi.ts +++ b/src/hardware/wifi.ts @@ -1,24 +1,25 @@ -import { execSync } from 'child_process' +import { readFileSync } from 'fs' +import { spawnSync } from 'child_process' export interface WifiInfo { - signalStrength: number - ssid: string + wifiStrength: number + wifiSSID: string } export function getWifiInfo(): WifiInfo { try { - const signal = parseSignalStrength() - const ssid = parseSSID() - return { signalStrength: signal, ssid } + const wifiStrength = parseSignalStrength() + const wifiSSID = parseSSID() + return { wifiStrength, wifiSSID } } catch { - return { signalStrength: -1, ssid: 'unknown' } + return { wifiStrength: -1, wifiSSID: 'unknown' } } } function parseSignalStrength(): number { try { - const raw = execSync('cat /proc/net/wireless', { encoding: 'utf-8', timeout: 2000 }) + const raw = readFileSync('/proc/net/wireless', 'utf-8') const lines = raw.trim().split('\n') // Skip header lines, parse the interface line const dataLine = lines.find(l => l.includes(':')) @@ -37,7 +38,8 @@ function parseSignalStrength(): number { function parseSSID(): string { try { - return execSync('iwgetid -r', { encoding: 'utf-8', timeout: 2000 }).trim() || 'unknown' + const result = spawnSync('iwgetid', ['-r'], { encoding: 'utf-8', timeout: 2000 }) + return result.stdout?.trim() || 'unknown' } catch { return 'unknown' diff --git a/src/server/routers/device.ts b/src/server/routers/device.ts index 1f52ab3d..e5642961 100644 --- a/src/server/routers/device.ts +++ b/src/server/routers/device.ts @@ -18,7 +18,7 @@ import { vibrationPatternSchema, alarmDurationSchema, } from '@/src/server/validation-schemas' -import { toC, centiDegreesToF, centiPercentToPercent } from '@/src/lib/tempUtils' +import { toC, centiDegreesToC, centiPercentToPercent } from '@/src/lib/tempUtils' import { getWifiInfo } from '@/src/hardware/wifi' // --------------------------------------------------------------------------- @@ -145,22 +145,28 @@ export const deviceRouter = router({ const convertTemp = (f: number) => input.unit === 'C' ? Math.round(toC(f) * 10) / 10 : f // Best-effort enrichment — nulls on failure - let roomClimate: { temperatureF: number | null, humidity: number | null, timestamp: Date | null } = { temperatureF: null, humidity: null, timestamp: null } - let waterLevel: { level: string | null, timestamp: Date | null } = { level: null, timestamp: null } + let wifiStrength: number = -1 + let wifiSSID: string = 'unknown' + let roomClimate: { temperatureC: number | null, humidity: number | null, timestamp: number | null } = { temperatureC: null, humidity: null, timestamp: null } + let waterLevel: { level: 'low' | 'ok' | null, timestamp: number | null } = { level: null, timestamp: null } try { + const wifi = getWifiInfo() + wifiStrength = wifi.wifiStrength + wifiSSID = wifi.wifiSSID + const [latestBed] = await biometricsDb.select().from(bedTemp).orderBy(desc(bedTemp.timestamp)).limit(1) if (latestBed) { roomClimate = { - temperatureF: latestBed.ambientTemp !== null ? centiDegreesToF(latestBed.ambientTemp) : null, + temperatureC: latestBed.ambientTemp !== null ? centiDegreesToC(latestBed.ambientTemp) : null, humidity: latestBed.humidity !== null ? centiPercentToPercent(latestBed.humidity) : null, - timestamp: latestBed.timestamp, + timestamp: latestBed.timestamp ? latestBed.timestamp.getTime() : null, } } const [latestWater] = await biometricsDb.select().from(waterLevelReadings).orderBy(desc(waterLevelReadings.timestamp)).limit(1) if (latestWater) { waterLevel = { level: latestWater.level, - timestamp: latestWater.timestamp, + timestamp: latestWater.timestamp ? latestWater.timestamp.getTime() : null, } } } @@ -180,7 +186,8 @@ export const deviceRouter = router({ }, ...(primeCompletedAt && { primeCompletedNotification: { timestamp: primeCompletedAt } }), snooze: { left: leftSnooze, right: rightSnooze }, - wifi: getWifiInfo(), + wifiStrength, + wifiSSID, roomClimate, waterLevel, } From e7aa0603424655f5870357cb1537fa7f7bc66629 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 5 Apr 2026 23:44:01 -0700 Subject: [PATCH 3/3] feat: add raw ADC columns to water_level_readings and return waterLevelRaw per #192 spec Migration adds raw, calibrated_empty, calibrated_full columns. getStatus now returns waterLevelRaw: { raw, calibratedEmpty, calibratedFull, timestamp } matching the issue #192 specification. --- .../0007_peaceful_blue_shield.sql | 3 + .../meta/0007_snapshot.json | 896 ++++++++++++++++++ .../biometrics-migrations/meta/_journal.json | 7 + src/db/biometrics-schema.ts | 3 + src/server/routers/device.ts | 10 +- 5 files changed, 915 insertions(+), 4 deletions(-) create mode 100644 src/db/biometrics-migrations/0007_peaceful_blue_shield.sql create mode 100644 src/db/biometrics-migrations/meta/0007_snapshot.json diff --git a/src/db/biometrics-migrations/0007_peaceful_blue_shield.sql b/src/db/biometrics-migrations/0007_peaceful_blue_shield.sql new file mode 100644 index 00000000..bd5a0bed --- /dev/null +++ b/src/db/biometrics-migrations/0007_peaceful_blue_shield.sql @@ -0,0 +1,3 @@ +ALTER TABLE `water_level_readings` ADD `raw` integer;--> statement-breakpoint +ALTER TABLE `water_level_readings` ADD `calibrated_empty` integer;--> statement-breakpoint +ALTER TABLE `water_level_readings` ADD `calibrated_full` integer; \ No newline at end of file diff --git a/src/db/biometrics-migrations/meta/0007_snapshot.json b/src/db/biometrics-migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..285740cb --- /dev/null +++ b/src/db/biometrics-migrations/meta/0007_snapshot.json @@ -0,0 +1,896 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "dcd2e360-6812-4ae0-92ed-821a67cba08b", + "prevId": "3ac0eb7e-8dfb-41b1-99a1-41b80065ef0c", + "tables": { + "ambient_light": { + "name": "ambient_light", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lux": { + "name": "lux", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_ambient_light_timestamp": { + "name": "idx_ambient_light_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bed_temp": { + "name": "bed_temp", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ambient_temp": { + "name": "ambient_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mcu_temp": { + "name": "mcu_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "humidity": { + "name": "humidity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_outer_temp": { + "name": "left_outer_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_center_temp": { + "name": "left_center_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_inner_temp": { + "name": "left_inner_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_outer_temp": { + "name": "right_outer_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_center_temp": { + "name": "right_center_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_inner_temp": { + "name": "right_inner_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_bed_temp_timestamp": { + "name": "idx_bed_temp_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calibration_profiles": { + "name": "calibration_profiles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_start": { + "name": "source_window_start", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_end": { + "name": "source_window_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "samples_used": { + "name": "samples_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_cal_type_status": { + "name": "idx_cal_type_status", + "columns": [ + "sensor_type", + "status" + ], + "isUnique": false + }, + "uq_cal_side_type_active": { + "name": "uq_cal_side_type_active", + "columns": [ + "side", + "sensor_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calibration_runs": { + "name": "calibration_runs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sensor_type": { + "name": "sensor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parameters": { + "name": "parameters", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_start": { + "name": "source_window_start", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_window_end": { + "name": "source_window_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "samples_used": { + "name": "samples_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_cal_runs_side_type": { + "name": "idx_cal_runs_side_type", + "columns": [ + "side", + "sensor_type", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "flow_readings": { + "name": "flow_readings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "left_flowrate_cd": { + "name": "left_flowrate_cd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_flowrate_cd": { + "name": "right_flowrate_cd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_pump_rpm": { + "name": "left_pump_rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_pump_rpm": { + "name": "right_pump_rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_flow_readings_timestamp": { + "name": "idx_flow_readings_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "freezer_temp": { + "name": "freezer_temp", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ambient_temp": { + "name": "ambient_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heatsink_temp": { + "name": "heatsink_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "left_water_temp": { + "name": "left_water_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_water_temp": { + "name": "right_water_temp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_freezer_temp_timestamp": { + "name": "idx_freezer_temp_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "movement": { + "name": "movement", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total_movement": { + "name": "total_movement", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_movement_side_timestamp": { + "name": "idx_movement_side_timestamp", + "columns": [ + "side", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sleep_records": { + "name": "sleep_records", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entered_bed_at": { + "name": "entered_bed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "left_bed_at": { + "name": "left_bed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sleep_duration_seconds": { + "name": "sleep_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "times_exited_bed": { + "name": "times_exited_bed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "present_intervals": { + "name": "present_intervals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "not_present_intervals": { + "name": "not_present_intervals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sleep_records_side_entered": { + "name": "idx_sleep_records_side_entered", + "columns": [ + "side", + "entered_bed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vitals": { + "name": "vitals", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heart_rate": { + "name": "heart_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hrv": { + "name": "hrv", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "breathing_rate": { + "name": "breathing_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_vitals_side_timestamp": { + "name": "idx_vitals_side_timestamp", + "columns": [ + "side", + "timestamp" + ], + "isUnique": false + }, + "uq_vitals_side_timestamp": { + "name": "uq_vitals_side_timestamp", + "columns": [ + "side", + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vitals_quality": { + "name": "vitals_quality", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "vitals_id": { + "name": "vitals_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quality_score": { + "name": "quality_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "flags": { + "name": "flags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hr_raw": { + "name": "hr_raw", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_vq_vitals_id": { + "name": "idx_vq_vitals_id", + "columns": [ + "vitals_id" + ], + "isUnique": false + }, + "idx_vq_side_ts": { + "name": "idx_vq_side_ts", + "columns": [ + "side", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "water_level_alerts": { + "name": "water_level_alerts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_water_level_alerts_dismissed": { + "name": "idx_water_level_alerts_dismissed", + "columns": [ + "dismissed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "water_level_readings": { + "name": "water_level_readings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "calibrated_empty": { + "name": "calibrated_empty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "calibrated_full": { + "name": "calibrated_full", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_water_level_timestamp": { + "name": "idx_water_level_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/biometrics-migrations/meta/_journal.json b/src/db/biometrics-migrations/meta/_journal.json index c886afab..03e305a1 100644 --- a/src/db/biometrics-migrations/meta/_journal.json +++ b/src/db/biometrics-migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1774803436479, "tag": "0006_tired_gressill", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1775457794262, + "tag": "0007_peaceful_blue_shield", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/biometrics-schema.ts b/src/db/biometrics-schema.ts index 793b0755..4615da59 100644 --- a/src/db/biometrics-schema.ts +++ b/src/db/biometrics-schema.ts @@ -81,6 +81,9 @@ export const waterLevelReadings = sqliteTable('water_level_readings', { id: integer('id').primaryKey({ autoIncrement: true }), timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(), level: text('level', { enum: ['low', 'ok'] }).notNull(), + raw: integer('raw'), + calibratedEmpty: integer('calibrated_empty'), + calibratedFull: integer('calibrated_full'), }, t => [ uniqueIndex('idx_water_level_timestamp').on(t.timestamp), ]) diff --git a/src/server/routers/device.ts b/src/server/routers/device.ts index e5642961..4ecafd5d 100644 --- a/src/server/routers/device.ts +++ b/src/server/routers/device.ts @@ -148,7 +148,7 @@ export const deviceRouter = router({ let wifiStrength: number = -1 let wifiSSID: string = 'unknown' let roomClimate: { temperatureC: number | null, humidity: number | null, timestamp: number | null } = { temperatureC: null, humidity: null, timestamp: null } - let waterLevel: { level: 'low' | 'ok' | null, timestamp: number | null } = { level: null, timestamp: null } + let waterLevelRaw: { raw: number | null, calibratedEmpty: number | null, calibratedFull: number | null, timestamp: number | null } = { raw: null, calibratedEmpty: null, calibratedFull: null, timestamp: null } try { const wifi = getWifiInfo() wifiStrength = wifi.wifiStrength @@ -164,8 +164,10 @@ export const deviceRouter = router({ } const [latestWater] = await biometricsDb.select().from(waterLevelReadings).orderBy(desc(waterLevelReadings.timestamp)).limit(1) if (latestWater) { - waterLevel = { - level: latestWater.level, + waterLevelRaw = { + raw: latestWater.raw ?? null, + calibratedEmpty: latestWater.calibratedEmpty ?? null, + calibratedFull: latestWater.calibratedFull ?? null, timestamp: latestWater.timestamp ? latestWater.timestamp.getTime() : null, } } @@ -189,7 +191,7 @@ export const deviceRouter = router({ wifiStrength, wifiSSID, roomClimate, - waterLevel, + waterLevelRaw, } }, 'Failed to get device status') }),