Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler

Ports the state machine, retry/backoff, and guest-override detection from
the retired homeassistant-newbook-heating-component, without depending on
Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following
standard stack conventions; LXC 128 (127 was already taken by utilities).

MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are
later phases/infra, not included here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-26 18:00:42 +00:00
commit 276c04f8c6
42 changed files with 5461 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import { requireAuth, requireCap } from '../auth.js'
import { pool, logActivity } from '../db.js'
import { batchSetZoneTemperature } from '../lib/mqtt.js'
// Manual force-temperature — the equivalent of the old integration's
// newbook.force_room_temperature service. Disables auto mode for the zone so the
// scheduler leaves it alone until staff switch auto mode back on (zones.js PATCH).
export async function overrideRoutes(app) {
app.addHook('preHandler', requireAuth)
app.post('/api/zones/:id/override', { preHandler: requireCap('control') }, async (req, reply) => {
const { temp_c } = req.body || {}
const tempC = Number(temp_c)
if (!Number.isFinite(tempC)) return reply.status(400).send({ error: 'temp_c must be a number' })
const { rows } = await pool.query('SELECT * FROM zones WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Zone not found' })
const zone = rows[0]
await pool.query('UPDATE zones SET auto_mode = FALSE WHERE id = $1', [zone.id])
const { rows: devices } = await pool.query(
`SELECT external_ref, device_ip FROM zone_devices WHERE zone_id = $1 AND device_type = 'shelly_trv'`,
[zone.id]
)
if (!devices.length) {
return reply.status(200).send({ ok: false, error: 'No TRVs mapped to this zone', auto_mode: false })
}
const results = await batchSetZoneTemperature(devices, tempC)
const successful = Object.values(results).filter(Boolean).length
await logActivity(zone.id, 'override', {
note: `Manual override to ${tempC}°C (${successful}/${devices.length} devices, auto mode disabled)`,
source: 'manual',
userEmail: req.user.email,
})
return { ok: successful > 0, successful, total: devices.length, auto_mode: false }
})
}