import { requireAuth, requireCap } from '../auth.js' import { pool, logActivity } from '../db.js' import { batchSetZoneTemperature, setTargetWithRetry } from '../lib/mqtt.js' import { commandAcDevice } from '../lib/ac-control.js' import * as mhiDriver from '../lib/drivers/mhi-modbus.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 } }) // GET /api/devices/:id/mhi-status — live status for one assigned MHI aircon unit. // mhi_mqtt: served from the push-populated status cache (mqtt.js keeps it fresh // from the gateway's retained topics). mhi_modbus: live Modbus round-trip read. app.get('/api/devices/:id/mhi-status', { preHandler: requireCap('view') }, async (req, reply) => { const { rows } = await pool.query( `SELECT * FROM zone_devices WHERE id = $1 AND device_type IN ('mhi_modbus', 'mhi_mqtt')`, [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' }) const device = rows[0] if (device.device_type === 'mhi_mqtt') { return { external_ref: device.external_ref, onOff: device.power_state ?? undefined, mode: device.current_mode ?? undefined, setpoint: device.current_target_temp != null ? Number(device.current_target_temp) : undefined, fanSpeed: device.fan_speed ?? undefined, roomTemp: device.current_room_temp != null ? Number(device.current_room_temp) : undefined, } } const status = await mhiDriver.getStatus(device.external_ref) if (!status || status.error) { return reply.status(502).send({ error: status?.error || 'Failed to read device status' }) } return status }) // POST /api/devices/:id/mhi-control — explicit staff control from the room modal's // MhiControlPanel. Body: { onOff?, mode?, tempC?, fanSpeed? } — only the fields present // are written. The actual write path (mhi_mqtt publish vs mhi_modbus register write, // plus optimistic cache update) lives in lib/ac-control.js's commandAcDevice(), shared // with the scheduler's automatic occupied/vacant AC control (lib/scheduler.js). app.post('/api/devices/:id/mhi-control', { preHandler: requireCap('control') }, async (req, reply) => { const { rows } = await pool.query( `SELECT * FROM zone_devices WHERE id = $1 AND device_type IN ('mhi_modbus', 'mhi_mqtt')`, [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'MHI device not found' }) const device = rows[0] const { onOff, mode, tempC, fanSpeed } = req.body || {} const { ok } = await commandAcDevice(device, { onOff, mode, tempC, fanSpeed }, { origin: 'manual', log: req.log }) await logActivity(device.zone_id, 'device_command', { note: `MHI manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`, source: 'manual', userEmail: req.user.email, }) return { ok } }) // GET /api/devices/:id/daikin-status — served from the poll-populated cache // (lib/daikin-poller.js), same "read the row, don't do a live round trip" shape as // the mhi_mqtt branch above — Daikin has no push mechanism either, but a live HTTP // call on every panel open would be slower and the cache is at most POLL_MS stale. app.get('/api/devices/:id/daikin-status', { preHandler: requireCap('view') }, async (req, reply) => { const { rows } = await pool.query( `SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'daikin'`, [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'Daikin device not found' }) const device = rows[0] return { external_ref: device.external_ref, onOff: device.power_state ?? undefined, mode: device.current_mode ?? undefined, setpoint: device.current_target_temp != null ? Number(device.current_target_temp) : undefined, fanSpeed: device.fan_speed ?? undefined, roomTemp: device.current_room_temp != null ? Number(device.current_room_temp) : undefined, status_updated_at: device.status_updated_at, } }) // POST /api/devices/:id/daikin-control — explicit staff control from DaikinControlPanel. // Body: { onOff?, mode?, tempC?, fanSpeed? }. Shares commandAcDevice() with the mhi // routes above and with the scheduler's automatic occupied/vacant AC control. app.post('/api/devices/:id/daikin-control', { preHandler: requireCap('control') }, async (req, reply) => { const { rows } = await pool.query( `SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'daikin'`, [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'Daikin device not found' }) const device = rows[0] const { onOff, mode, tempC, fanSpeed } = req.body || {} const { ok } = await commandAcDevice(device, { onOff, mode, tempC, fanSpeed }, { origin: 'manual', log: req.log }) await logActivity(device.zone_id, 'device_command', { note: `Daikin manual control on ${device.location || device.discovered_name || device.external_ref}: ${JSON.stringify({ onOff, mode, tempC, fanSpeed })}${ok ? '' : ' — FAILED'}`, source: 'manual', userEmail: req.user.email, }) return { ok } }) // GET /api/devices/:id/trv-status — live status for one assigned Shelly TRV, served // from the push-populated cache (mqtt.js keeps current_target_temp/current_room_temp/ // battery_pct fresh from shellies/+/status, valve_position/wifi_rssi from // shellies/+/info) — same "read the row, don't round-trip" shape as mhi_mqtt/daikin. app.get('/api/devices/:id/trv-status', { preHandler: requireCap('view') }, async (req, reply) => { const { rows } = await pool.query( `SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'shelly_trv'`, [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'TRV not found' }) const device = rows[0] return { external_ref: device.external_ref, setpoint: device.current_target_temp != null ? Number(device.current_target_temp) : undefined, roomTemp: device.current_room_temp != null ? Number(device.current_room_temp) : undefined, heatingActive: device.heating_active, valvePosition: device.valve_position, wifiRssi: device.wifi_rssi, batteryPct: device.battery_pct, targetOrigin: device.target_origin, lastSeen: device.last_seen, statusUpdatedAt: device.status_updated_at, } }) // POST /api/devices/:id/trv-control — explicit staff setpoint change from the room // modal's TrvControlPanel. Body: { tempC }. Goes through setTargetWithRetry() for the // full reliability pipeline (30s->30min backoff, HTTP wake-up fallback) — same // function the zone-level /override route above uses per-device, just for one TRV. app.post('/api/devices/:id/trv-control', { preHandler: requireCap('control') }, async (req, reply) => { const { rows } = await pool.query( `SELECT * FROM zone_devices WHERE id = $1 AND device_type = 'shelly_trv'`, [req.params.id] ) if (!rows.length) return reply.status(404).send({ error: 'TRV not found' }) const device = rows[0] const tempC = Number(req.body?.tempC) if (!Number.isFinite(tempC)) return reply.status(400).send({ error: 'tempC must be a number' }) const ok = await setTargetWithRetry({ external_ref: device.external_ref, device_ip: device.device_ip }, tempC) await logActivity(device.zone_id, 'device_command', { note: `TRV manual setpoint on ${device.location || device.discovered_name || device.external_ref}: ${tempC}°C${ok ? '' : ' — FAILED'}`, source: 'manual', userEmail: req.user.email, }) return { ok } }) }