hvac/backend/src/routes/devices.js
jtricerolph f0b4ac3c41 Add device telemetry history + per-device graph view
activity_log only ever recorded state transitions/overrides/sync/errors —
useful for "what did the app decide" but not "what was the room actually
doing" when a guest/staff complaint comes in after the fact.

Backend:
- New device_readings time-series table (room_temp, target_temp, mode,
  power_state, fan_speed, valve_position, heating_active), throttled to
  ~1 row/device/minute via recordReading() in db.js regardless of how
  often a caller fires — MQTT pushes can be far chattier than that.
- Wired into every place zone_devices' live-status cache already gets
  updated: ac-poller.js, daikin-poller.js, and mqtt.js's TRV
  (handleStatusBlob/handleInfo) and native-MQTT MHI (writeMhiState)
  handlers.
- 4-day retention by default (config key history_retention_days),
  pruned every 6h by the new history-pruner.js.
- GET /api/devices/:id/history?hours=96 to read it back.

Frontend:
- DeviceHistoryModal: room temp vs setpoint line chart, plus a
  valve-position chart (TRVs) or fan-speed step chart (AC), plus a
  diffed mode/power/fan change log — 24h/4d/7d range picker. Uses
  recharts, matching the convention already established in
  utilities/wages/reports.
- Graph icon on each device in both ZoneDetailModal and the Devices
  page opens it.

Side effect: frontend/package-lock.json was missing vite-plugin-pwa's
whole dependency tree even though package.json already declared it —
pre-existing drift, reconciled by this npm install (unrelated to the
recharts addition, just came along with it).
2026-08-25 17:28:45 +00:00

213 lines
10 KiB
JavaScript

import { mkdir, unlink, writeFile } from 'fs/promises'
import { randomUUID } from 'crypto'
import { join } from 'path'
import sharp from 'sharp'
import { requireAuth, requireCap } from '../auth.js'
import { pool, logActivity } from '../db.js'
import * as trvDriver from '../lib/drivers/trv.js'
import * as haDriver from '../lib/drivers/homeassistant.js'
import * as daikinDriver from '../lib/drivers/daikin.js'
import { createMaintenanceAsset } from '../lib/maintenance-client.js'
import { upsertDiscovered } from '../lib/device-registry.js'
const ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const PHOTO_TYPES = ['device', 'serial_plate']
// device_type -> driver implementing discover()/getStatus()/setTarget(). midea is
// still an enum value only — no driver yet. mhi_modbus DOES have a driver
// (lib/drivers/mhi-modbus.js) but it isn't wired into this generic no-args discover
// flow — Modbus has no broadcast scan, and discover() there needs a specific
// already-configured gateway, so it's only ever called from routes/mhi-gateways.js's
// test-connection route. Units are onboarded via "Gateways" (Devices page): configure
// a gateway, import its MAPS register map, then assign units to zones — never via
// this Discover button. daikin's discover() does a full-subnet HTTP sweep (no
// broadcast — see lib/drivers/daikin.js) so it fits this generic flow fine, but
// needs "Daikin subnet (CIDR)" set on the Settings page first.
const DRIVERS = {
shelly_trv: trvDriver,
home_assistant: haDriver,
daikin: daikinDriver,
}
const NOT_YET_IMPLEMENTED = ['midea']
const MHI_MODBUS_NOTE = "MHI aircon units aren't found via Discover — configure a gateway and import its register " +
'map under Devices -> Gateways, then assign units to zones there.'
const MHI_MQTT_NOTE = 'MHI aircon units on native MQTT self-register as they publish status — they appear ' +
'automatically in the device list. Assign each discovered unit to a zone; no Discover or gateway import needed.'
export async function deviceRoutes(app, opts) {
const UPLOADS_DIR = opts.uploadsDir
app.addHook('preHandler', requireAuth)
// GET /api/devices — every mapped + unassigned device, with zone name and photo count
app.get('/api/devices', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query(`
SELECT zd.*, z.name AS zone_name,
(SELECT COUNT(*)::int FROM device_photos p WHERE p.device_id = zd.id) AS photo_count
FROM zone_devices zd
LEFT JOIN zones z ON z.id = zd.zone_id
ORDER BY zd.zone_id NULLS FIRST, zd.device_type, zd.discovered_name
`)
return rows
})
// POST /api/devices/discover — { device_type } -> runs that driver's discover(),
// and inserts any newly-seen device as an unassigned row (never auto-linked to a zone).
app.post('/api/devices/discover', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
const { device_type } = req.body || {}
if (!device_type) return reply.status(400).send({ error: 'device_type required' })
if (device_type === 'mhi_modbus') {
return reply.status(200).send({ ok: true, devices: [], note: MHI_MODBUS_NOTE })
}
if (device_type === 'mhi_mqtt') {
return reply.status(200).send({ ok: true, devices: [], note: MHI_MQTT_NOTE })
}
if (NOT_YET_IMPLEMENTED.includes(device_type)) {
return reply.status(200).send({
ok: true, devices: [],
note: `${device_type} discovery isn't implemented yet — Phase 1 only covers Shelly TRVs. ` +
`This device type exists as a column enum value ready for its Phase 3 driver.`,
})
}
const driver = DRIVERS[device_type]
if (!driver) return reply.status(400).send({ error: `Unknown device_type: ${device_type}` })
const result = await driver.discover()
if (!result.ok) return reply.status(200).send(result) // e.g. broker/HA/CIDR not configured — not a hard error
const discoveredCount = await upsertDiscovered(device_type, result.devices)
return { ok: true, found: result.devices.length, new: discoveredCount, note: result.note }
})
// GET /api/devices/:id/history — time-series readings for the debugging chart.
// ?hours=96 (default, matches the default 4-day retention — see history_retention_days
// in config); hard-capped at 14 days regardless of what's asked, since that's the
// longest anyone could plausibly have kept via config even if retention was raised.
app.get('/api/devices/:id/history', { preHandler: requireCap('view') }, async (req) => {
const hours = Math.min(parseInt(req.query?.hours) || 96, 24 * 14)
const { rows } = await pool.query(
`SELECT room_temp, target_temp, mode, power_state, fan_speed, valve_position, heating_active, recorded_at
FROM device_readings
WHERE device_id = $1 AND recorded_at > NOW() - ($2 || ' hours')::interval
ORDER BY recorded_at ASC`,
[req.params.id, hours]
)
return rows
})
// PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard)
app.patch('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
const { rows: existing } = await pool.query('SELECT * FROM zone_devices WHERE id = $1', [req.params.id])
if (!existing.length) return reply.status(404).send({ error: 'Device not found' })
const d = existing[0]
const { zone_id, location } = req.body || {}
const { rows } = await pool.query(
`UPDATE zone_devices SET zone_id = $1, location = $2, updated_at = NOW() WHERE id = $3 RETURNING *`,
[zone_id !== undefined ? zone_id : d.zone_id, location !== undefined ? location : d.location, req.params.id]
)
return rows[0]
})
// POST /api/devices/:id/photos — multipart: file + photo_type (device | serial_plate)
app.post('/api/devices/:id/photos', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
const deviceId = parseInt(req.params.id)
const { rows } = await pool.query('SELECT id FROM zone_devices WHERE id = $1', [deviceId])
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
let fileData = null, photoType = 'device'
for await (const part of req.parts()) {
if (part.type === 'file') {
if (!ALLOWED_IMAGES.includes(part.mimetype)) {
return reply.status(400).send({ error: 'Only JPEG, PNG and WebP images are allowed' })
}
const chunks = []
for await (const chunk of part.file) chunks.push(chunk)
const raw = Buffer.concat(chunks)
// Auto-rotate (phone EXIF), resize to 1800px max, re-encode as JPEG — same as maintenance's task_photos.
const processed = await sharp(raw)
.rotate()
.resize(1800, 1800, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 82, progressive: true })
.toBuffer()
const filename = randomUUID() + '.jpg'
const dir = join(UPLOADS_DIR, 'devices', String(deviceId))
await mkdir(dir, { recursive: true })
await writeFile(join(dir, filename), processed)
fileData = { originalName: part.filename, savedAs: filename, size: processed.length }
} else {
const val = await part.value
if (part.fieldname === 'photo_type' && PHOTO_TYPES.includes(String(val))) photoType = String(val)
}
}
if (!fileData?.savedAs) return reply.status(400).send({ error: 'No file uploaded' })
const filePath = `/devices/${deviceId}/${fileData.savedAs}`
const { rows: ins } = await pool.query(
`INSERT INTO device_photos (device_id, file_name, file_path, mime_type, file_size, photo_type, uploaded_by)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[deviceId, fileData.originalName, filePath, 'image/jpeg', fileData.size, photoType, req.user.email]
)
return ins[0]
})
app.get('/api/devices/:id/photos', { preHandler: requireCap('view') }, async (req) => {
const { rows } = await pool.query(
'SELECT * FROM device_photos WHERE device_id = $1 ORDER BY uploaded_at DESC',
[req.params.id]
)
return rows
})
app.delete('/api/photos/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM device_photos WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
await unlink(join(UPLOADS_DIR, rows[0].file_path)).catch(() => {})
await pool.query('DELETE FROM device_photos WHERE id = $1', [req.params.id])
return { ok: true }
})
// DELETE /api/devices/:id — remove a device mapping entirely. Main use: retiring
// the old mhi_modbus rows after a gateway is switched to native MQTT (the units
// re-register as fresh mhi_mqtt devices, leaving the Modbus rows stale). Photo
// rows cascade via FK; their files are unlinked here so nothing is orphaned.
app.delete('/api/devices/:id', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
const { rows } = await pool.query(
'SELECT id, discovered_name, external_ref, zone_id FROM zone_devices WHERE id = $1',
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
const device = rows[0]
const { rows: photos } = await pool.query('SELECT file_path FROM device_photos WHERE device_id = $1', [device.id])
for (const p of photos) {
await unlink(join(UPLOADS_DIR, p.file_path)).catch(() => {})
}
await pool.query('DELETE FROM zone_devices WHERE id = $1', [device.id]) // device_photos cascade
await logActivity(device.zone_id, 'device_command', {
note: `Deleted device ${device.discovered_name || device.external_ref}`,
source: 'manual',
userEmail: req.user.email,
})
return { ok: true }
})
// POST /api/devices/:id/create-maintenance-asset — explicit stub. Real integration
// needs maintenance's own Settings -> API Keys page + POST /api/public/assets first
// (see lib/maintenance-client.js). Returns a clear "not implemented" response rather
// than faking a cross-app call.
app.post('/api/devices/:id/create-maintenance-asset', { preHandler: requireCap('manage_devices') }, async (req, reply) => {
const { rows } = await pool.query(
`SELECT zd.*, z.name AS zone_name FROM zone_devices zd LEFT JOIN zones z ON z.id = zd.zone_id WHERE zd.id = $1`,
[req.params.id]
)
if (!rows.length) return reply.status(404).send({ error: 'Device not found' })
const result = await createMaintenanceAsset(rows[0], { name: rows[0].zone_name })
return reply.status(501).send(result)
})
}