diff --git a/backend/src/index.js b/backend/src/index.js index fd255b6..bce738c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -9,6 +9,7 @@ import { initDb } from './db.js' import { connect as connectMqtt } from './lib/mqtt.js' import { startScheduler } from './lib/scheduler.js' import { startAcPoller } from './lib/ac-poller.js' +import { startDaikinPoller } from './lib/daikin-poller.js' import { zoneRoutes } from './routes/zones.js' import { deviceRoutes } from './routes/devices.js' import { statusRoutes } from './routes/status.js' @@ -54,6 +55,7 @@ try { await startScheduler() await startAcPoller().catch(err => app.log.warn(`AC poller failed to start: ${err.message}`)) + await startDaikinPoller().catch(err => app.log.warn(`Daikin poller failed to start: ${err.message}`)) await app.listen({ port: 3001, host: '0.0.0.0' }) } catch (err) { app.log.error(err) diff --git a/backend/src/lib/ac-control.js b/backend/src/lib/ac-control.js index 2e187e8..c2fb03b 100644 --- a/backend/src/lib/ac-control.js +++ b/backend/src/lib/ac-control.js @@ -5,6 +5,7 @@ import { pool } from '../db.js' import { publishMhiCommand } from './mqtt.js' import * as mhiDriver from './drivers/mhi-modbus.js' +import * as daikinDriver from './drivers/daikin.js' // device: a zone_devices row (needs at least id, device_type, external_ref). // patch: { onOff?: boolean, mode?: string, tempC?: number, fanSpeed?: string } — only @@ -54,5 +55,33 @@ export async function commandAcDevice(device, { onOff, mode, tempC, fanSpeed } = return { ok } } + if (device.device_type === 'daikin') { + // Live HTTP round-trip, not push — same shape as mhi_modbus above (write, then + // cache the fields that were actually sent so the dashboard doesn't wait for the + // next daikin-poller.js tick to reflect a manual command). + const ok = await daikinDriver.setTarget(device.external_ref, { onOff, mode, tempC, fanSpeed }) + if (ok) { + await pool.query( + `UPDATE zone_devices SET + power_state = COALESCE($1, power_state), + current_mode = COALESCE($2, current_mode), + current_target_temp = COALESCE($3, current_target_temp), + fan_speed = COALESCE($4, fan_speed), + target_origin = $5, + updated_at = NOW() + WHERE id = $6`, + [ + onOff === undefined || onOff === null ? null : (onOff ? 'on' : 'off'), + mode ?? null, + tempC ?? null, + fanSpeed ?? null, + origin, + device.id, + ] + ) + } + return { ok } + } + return { ok: false, reason: 'unsupported_device_type' } } diff --git a/backend/src/lib/daikin-poller.js b/backend/src/lib/daikin-poller.js new file mode 100644 index 0000000..77e662d --- /dev/null +++ b/backend/src/lib/daikin-poller.js @@ -0,0 +1,79 @@ +// Background self-heal + live-status cache for Daikin aircon units. Two independent +// ticks, same shape as ac-poller.js's mhi_modbus poll but split into two cadences: +// - status tick (fast, every POLL_MS): reads the cached device_ip for every assigned +// unit and caches mode/power/fan/setpoint/room temp onto zone_devices — same columns +// ac-poller.js populates for mhi_modbus, so ZoneCard/dashboard need no changes. +// - sweep tick (slow, every SWEEP_MS): re-runs the full-subnet HTTP scan (see the long +// comment in lib/drivers/daikin.js for why this replaces UDP broadcast discovery) and +// matches replies by MAC — self-healing device_ip if a unit's DHCP lease changed, and +// auto-registering any brand-new unit as an unassigned row (never auto-mapped to a +// zone — that's still a staff action, same convention as MHI's native-MQTT ingest). +import { pool } from '../db.js' +import * as daikinDriver from './drivers/daikin.js' +import { upsertDiscovered } from './device-registry.js' + +const POLL_MS = 60 * 1000 +const SWEEP_MS = 5 * 60 * 1000 + +let pollHandle = null +let sweepHandle = null +let pollRunning = false +let sweepRunning = false + +async function pollStatusOnce() { + const { rows: devices } = await pool.query( + `SELECT id, external_ref FROM zone_devices + WHERE device_type = 'daikin' AND zone_id IS NOT NULL AND device_ip IS NOT NULL` + ) + + for (const device of devices) { + try { + const status = await daikinDriver.getStatus(device.external_ref) + if (!status || status.error) continue // unreachable this tick — leave the last-known cache in place + + await pool.query( + `UPDATE zone_devices + SET power_state = $2, current_mode = $3, fan_speed = $4, + current_target_temp = $5, current_room_temp = $6, status_updated_at = NOW() + WHERE id = $1`, + [device.id, status.onOff ?? null, status.mode ?? null, status.fanSpeed ?? null, + status.setpoint ?? null, status.roomTemp ?? null] + ) + } catch (err) { + console.error(`[daikin-poller] status poll failed for device ${device.id}:`, err.message) + } + } +} + +async function sweepOnce() { + const result = await daikinDriver.discover() + if (!result.ok) return // e.g. no CIDR configured yet in Settings — nothing to do until it is + await upsertDiscovered('daikin', result.devices) +} + +export async function startDaikinPoller() { + const pollTick = async () => { + if (pollRunning) return + pollRunning = true + try { await pollStatusOnce() } catch (err) { console.error('[daikin-poller] status tick failed:', err.message) } + pollRunning = false + } + const sweepTick = async () => { + if (sweepRunning) return + sweepRunning = true + try { await sweepOnce() } catch (err) { console.error('[daikin-poller] sweep tick failed:', err.message) } + sweepRunning = false + } + + await sweepTick() // resolve device_ip for known units before the first status poll needs it + await pollTick() + sweepHandle = setInterval(sweepTick, SWEEP_MS) + pollHandle = setInterval(pollTick, POLL_MS) +} + +export function stopDaikinPoller() { + if (pollHandle) clearInterval(pollHandle) + if (sweepHandle) clearInterval(sweepHandle) + pollHandle = null + sweepHandle = null +} diff --git a/backend/src/lib/device-registry.js b/backend/src/lib/device-registry.js new file mode 100644 index 0000000..2028794 --- /dev/null +++ b/backend/src/lib/device-registry.js @@ -0,0 +1,26 @@ +// Shared "record what a discover() sweep found" upsert — used both by the generic +// POST /api/devices/discover route (manual staff-triggered scan) and by a driver's own +// background self-healing sweep (see lib/daikin-poller.js). Never creates a zone +// assignment — that's always a staff action (see the no-auto-map rule in db.js). +// device_ip is COALESCEd on conflict rather than overwritten so a driver that doesn't +// report an IP (e.g. shelly_trv/home_assistant, which don't return one from discover()) +// never blanks out an existing value. +import { pool } from '../db.js' + +export async function upsertDiscovered(deviceType, devices) { + let inserted = 0 + for (const d of devices) { + const res = await pool.query( + `INSERT INTO zone_devices (device_type, external_ref, discovered_name, device_ip) + VALUES ($1, $2, $3, $4) + ON CONFLICT (device_type, external_ref) DO UPDATE SET + discovered_name = EXCLUDED.discovered_name, + device_ip = COALESCE(EXCLUDED.device_ip, zone_devices.device_ip), + last_seen = NOW() + RETURNING (xmax = 0) AS inserted`, + [deviceType, d.external_ref, d.discovered_name || d.external_ref, d.ip || null] + ) + if (res.rows[0].inserted) inserted++ + } + return inserted +} diff --git a/backend/src/lib/drivers/daikin.js b/backend/src/lib/drivers/daikin.js new file mode 100644 index 0000000..eba5695 --- /dev/null +++ b/backend/src/lib/drivers/daikin.js @@ -0,0 +1,206 @@ +// Driver interface implementation for Daikin BRP069/BRP072-class local-network WiFi +// adapters (confirmed live 2026-08-25 against firmware 4_2_303 — /common/basic_info +// responded with a standard key=value CSV body, mac= present, type=aircon). +// Same discover()/getStatus(externalRef)/setTarget(externalRef, {mode, tempC, fanSpeed, +// onOff}) shape as every other driver (trv.js, homeassistant.js, mhi-modbus.js). +// +// Dynamic-IP problem: these units are on WiFi/DHCP and their IP can change at any time. +// Daikin's own UDP broadcast discovery protocol (port 30050) would be the "native" fix, +// but hvac's backend runs as a Docker container on its own bridge network, not directly +// on the LXC's admin-VLAN NIC — broadcast frames sent from inside the container are not +// guaranteed to cross that NAT boundary, and that's untested/risky infra to build against +// blind. Instead: external_ref is the unit's MAC (stable identity, never the IP), and a +// periodic full-subnet HTTP sweep (lib/daikin-poller.js) re-probes every host in the +// configured CIDR and matches replies by MAC, self-healing zone_devices.device_ip +// whenever a lease changes. This only needs plain outbound HTTP, which already works +// through the container's normal NAT/routing (same path proven by the MHI Modbus TCP +// driver) — no macvlan/broadcast plumbing required. +import { pool } from '../../db.js' + +export const DEVICE_TYPE = 'daikin' + +const SWEEP_CONCURRENCY = 50 +const SWEEP_TIMEOUT_MS = 500 + +// Mode/fan-rate encodings per the widely-used community reverse-engineering of Daikin's +// local BRP-adapter API (same wire format across the BRP069/BRP072 firmware family) — +// NOT yet exercised against a live unit's set_control_info here. Verify on the first +// real control command, same "confirm against live hardware before trusting" pattern +// the MHI Modbus/MQTT work used throughout this app. +const MODE_TO_DAIKIN = { auto: 0, dry: 2, cool: 3, heat: 4, fan: 6 } +const DAIKIN_TO_MODE = { 0: 'auto', 1: 'auto', 2: 'dry', 3: 'cool', 4: 'heat', 6: 'fan', 7: 'auto' } +const FAN_TO_DAIKIN = { auto: 'A', low: '3', medium: '5', high: '6', powerful: '7' } +const DAIKIN_TO_FAN = { A: 'auto', B: 'auto', 1: 'low', 2: 'low', 3: 'low', 4: 'medium', 5: 'medium', 6: 'high', 7: 'powerful' } + +// Daikin's local API returns "key=value,key2=value2" bodies, not JSON. +function parseKV(text) { + const obj = {} + for (const pair of (text || '').split(',')) { + const eq = pair.indexOf('=') + if (eq === -1) continue + obj[pair.slice(0, eq)] = pair.slice(eq + 1) + } + return obj +} + +// basic_info's `name` field is plain percent-encoding (e.g. %33%30%34 -> "304"). +function decodeName(raw) { + if (!raw) return null + try { return decodeURIComponent(raw) } catch { return raw } +} + +function ipToInt(ip) { + return ip.split('.').reduce((acc, oct) => (acc << 8) + Number(oct), 0) >>> 0 +} +function intToIp(n) { + return [24, 16, 8, 0].map(s => (n >>> s) & 255).join('.') +} + +// Expands an IPv4 CIDR to its usable host addresses (network + broadcast excluded for +// prefixes of /31 or wider). A /22 (this LAN) is 1022 hosts — cheap enough to sweep +// fully every tick at the concurrency below (~10s), no need for a smarter scan. +function cidrToHosts(cidr) { + const [base, prefixStr] = String(cidr).split('/') + const prefix = Number(prefixStr) + if (!base || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + throw new Error(`Invalid CIDR: ${cidr}`) + } + const baseInt = ipToInt(base) + const hostBits = 32 - prefix + const size = 2 ** hostBits + const network = hostBits === 0 ? baseInt : (baseInt >>> hostBits) << hostBits + const start = hostBits <= 1 ? 0 : 1 + const end = hostBits <= 1 ? size : size - 1 + const hosts = [] + for (let i = start; i < end; i++) hosts.push(intToIp((network + i) >>> 0)) + return hosts +} + +async function mapWithConcurrency(items, limit, fn) { + const results = new Array(items.length) + let idx = 0 + async function worker() { + while (idx < items.length) { + const i = idx++ + results[i] = await fn(items[i]) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return results +} + +async function probeOne(ip, timeoutMs) { + try { + const res = await fetch(`http://${ip}/common/basic_info`, { signal: AbortSignal.timeout(timeoutMs) }) + if (!res.ok) return null + const info = parseKV(await res.text()) + if (info.ret !== 'OK' || info.type !== 'aircon' || !info.mac) return null + return { + external_ref: info.mac, + discovered_name: decodeName(info.name) || info.mac, + ip, + model: info.ver || null, + } + } catch { + return null + } +} + +export async function sweepSubnet(cidr, { concurrency = SWEEP_CONCURRENCY, timeoutMs = SWEEP_TIMEOUT_MS } = {}) { + const hosts = cidrToHosts(cidr) + const results = await mapWithConcurrency(hosts, concurrency, ip => probeOne(ip, timeoutMs)) + return results.filter(Boolean) +} + +async function getConfiguredCidr() { + const { rows } = await pool.query(`SELECT value FROM config WHERE key = 'daikin_subnet_cidr'`) + const v = rows[0]?.value + return typeof v === 'string' && v.trim() ? v.trim() : null +} + +async function loadDeviceByExternalRef(externalRef) { + const { rows } = await pool.query( + `SELECT * FROM zone_devices WHERE device_type = $1 AND external_ref = $2`, + [DEVICE_TYPE, externalRef] + ) + return rows[0] || null +} + +// Full-subnet sweep, matched by MAC — see the file header for why this replaces UDP +// broadcast discovery. Called both from the Devices page's manual "Discover" button +// and periodically by lib/daikin-poller.js. +export async function discover() { + const cidr = await getConfiguredCidr() + if (!cidr) { + return { + ok: false, devices: [], + error: 'No LAN range configured — set "Daikin subnet (CIDR)" on the Settings page first (e.g. 10.4.0.0/22).', + } + } + try { + const devices = await sweepSubnet(cidr) + return { ok: true, devices, note: `Swept ${cidr} — ${devices.length} Daikin unit(s) found.` } + } catch (err) { + return { ok: false, error: err.message, devices: [] } + } +} + +export async function getStatus(externalRef) { + const device = await loadDeviceByExternalRef(externalRef) + if (!device) return null + if (!device.device_ip) { + return { external_ref: externalRef, error: 'No IP known yet — waiting on the next discovery sweep' } + } + + try { + const [controlRes, sensorRes] = await Promise.all([ + fetch(`http://${device.device_ip}/aircon/get_control_info`, { signal: AbortSignal.timeout(5000) }), + fetch(`http://${device.device_ip}/aircon/get_sensor_info`, { signal: AbortSignal.timeout(5000) }), + ]) + const control = parseKV(await controlRes.text()) + const sensor = parseKV(await sensorRes.text()) + if (control.ret !== 'OK') throw new Error(`Unit returned ${control.ret || 'no response'}`) + + return { + external_ref: externalRef, + onOff: control.pow === '1' ? 'on' : 'off', + mode: DAIKIN_TO_MODE[control.mode] ?? 'auto', + setpoint: control.stemp != null ? Number(control.stemp) : undefined, + fanSpeed: DAIKIN_TO_FAN[control.f_rate] ?? 'auto', + roomTemp: sensor.htemp != null && sensor.htemp !== '-' ? Number(sensor.htemp) : undefined, + } + } catch (err) { + return { external_ref: externalRef, error: err.message } + } +} + +// set_control_info requires the FULL control param set on every call — Daikin's local +// API does not treat an omitted field as "leave unchanged", some firmware resets it to +// a default instead. So read the unit's current state first and only overwrite what was +// actually asked for (same read-modify-write shape as pydaikin and other open-source +// Daikin local-API clients use). +export async function setTarget(externalRef, { onOff, mode, tempC, fanSpeed } = {}) { + const device = await loadDeviceByExternalRef(externalRef) + if (!device?.device_ip) return false + + try { + const curRes = await fetch(`http://${device.device_ip}/aircon/get_control_info`, { signal: AbortSignal.timeout(5000) }) + const current = parseKV(await curRes.text()) + if (current.ret !== 'OK') throw new Error(`Unit returned ${current.ret || 'no response'}`) + + const params = new URLSearchParams({ + pow: onOff === undefined || onOff === null ? current.pow : (onOff ? '1' : '0'), + mode: mode ? String(MODE_TO_DAIKIN[mode] ?? current.mode) : current.mode, + stemp: tempC != null ? String(tempC) : current.stemp, + shum: current.shum ?? '0', + f_rate: fanSpeed ? (FAN_TO_DAIKIN[fanSpeed] ?? current.f_rate) : current.f_rate, + f_dir: current.f_dir ?? '0', + }) + const setRes = await fetch(`http://${device.device_ip}/aircon/set_control_info?${params}`, { signal: AbortSignal.timeout(5000) }) + const result = parseKV(await setRes.text()) + return result.ret === 'OK' + } catch (err) { + console.error(`[daikin driver] setTarget failed for ${externalRef}:`, err.message) + return false + } +} diff --git a/backend/src/routes/devices.js b/backend/src/routes/devices.js index 9f29330..c8a59f2 100644 --- a/backend/src/routes/devices.js +++ b/backend/src/routes/devices.js @@ -6,24 +6,29 @@ 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(). Phase 3 -// types (midea, daikin) are enum values 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. +// 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', 'daikin'] +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 ' + @@ -71,21 +76,10 @@ export async function deviceRoutes(app, opts) { 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 not reachable — not a hard error + if (!result.ok) return reply.status(200).send(result) // e.g. broker/HA/CIDR not configured — not a hard error - let discoveredCount = 0 - for (const d of result.devices) { - const res = await pool.query( - `INSERT INTO zone_devices (device_type, external_ref, discovered_name) - VALUES ($1, $2, $3) - ON CONFLICT (device_type, external_ref) DO UPDATE SET discovered_name = EXCLUDED.discovered_name, last_seen = NOW() - RETURNING (xmax = 0) AS inserted`, - [device_type, d.external_ref, d.discovered_name || d.external_ref] - ) - if (res.rows[0].inserted) discoveredCount++ - } - - return { ok: true, found: result.devices.length, new: discoveredCount } + const discoveredCount = await upsertDiscovered(device_type, result.devices) + return { ok: true, found: result.devices.length, new: discoveredCount, note: result.note } }) // PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard) diff --git a/backend/src/routes/override.js b/backend/src/routes/override.js index 95f0470..ff1ff38 100644 --- a/backend/src/routes/override.js +++ b/backend/src/routes/override.js @@ -94,4 +94,50 @@ export async function overrideRoutes(app) { 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 } + }) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4f3732d..5c5611b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,6 @@ import type { Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig, - MhiGateway, MhiStatus, ActiveAcTemplate, + MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus, } from './types' const BASE = '/hvac/api' @@ -144,6 +144,14 @@ export function sendMhiControl(deviceId: number, body: { onOff?: boolean; mode?: return request(`/devices/${deviceId}/mhi-control`, { method: 'POST', body: JSON.stringify(body) }) } +// Daikin manual control (device-level — daikin rows only) +export function fetchDaikinStatus(deviceId: number): Promise { + return request(`/devices/${deviceId}/daikin-status`) +} +export function sendDaikinControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> { + return request(`/devices/${deviceId}/daikin-control`, { method: 'POST', body: JSON.stringify(body) }) +} + // Config / settings export function fetchConfig(): Promise { return request('/config') diff --git a/frontend/src/components/DaikinControlPanel.tsx b/frontend/src/components/DaikinControlPanel.tsx new file mode 100644 index 0000000..517d362 --- /dev/null +++ b/frontend/src/components/DaikinControlPanel.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react' +import { Power, Minus, Plus, RefreshCw, Loader2 } from 'lucide-react' +import { DAIKIN_MODES, DAIKIN_FAN_SPEEDS } from '../types' +import { fetchDaikinStatus, sendDaikinControl } from '../api' + +// Manual on/off + mode + setpoint + fan speed controls for one assigned Daikin unit. +// No NewBook/scheduler integration yet — every change here is an explicit staff action +// via POST /devices/:id/daikin-control. Status is read from the poller's cache +// (lib/daikin-poller.js polls every 60s), not a live round trip — same shape as +// MhiControlPanel's mhi_mqtt branch. +export default function DaikinControlPanel({ deviceId, canControl }: { deviceId: number; canControl: boolean }) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [applying, setApplying] = useState(false) + + const [onOff, setOnOff] = useState(false) + const [mode, setMode] = useState('auto') + const [setpoint, setSetpoint] = useState(21) + const [fanSpeed, setFanSpeed] = useState('auto') + const [roomTemp, setRoomTemp] = useState(null) + + function load() { + setLoading(true); setError('') + fetchDaikinStatus(deviceId) + .then(s => { + if (s.error) { setError(s.error); return } + setOnOff(s.onOff === 'on') + if (s.mode) setMode(s.mode) + if (s.setpoint != null) setSetpoint(s.setpoint) + if (s.fanSpeed) setFanSpeed(s.fanSpeed) + setRoomTemp(s.roomTemp ?? null) + }) + .catch(e => setError(e instanceof Error ? e.message : 'Failed to read device status')) + .finally(() => setLoading(false)) + } + useEffect(load, [deviceId]) + + async function apply(patch: Partial<{ onOff: boolean; mode: string; tempC: number; fanSpeed: string }>) { + setApplying(true); setError('') + try { + const res = await sendDaikinControl(deviceId, patch) + if (!res.ok) setError('Device did not accept the command') + } catch (e) { + setError(e instanceof Error ? e.message : 'Command failed') + } finally { + setApplying(false) + } + } + + if (loading) return

Reading aircon status…

+ + return ( +
+ {error &&
{error}
} +
+ + + + +
+ + {setpoint.toFixed(1)}°C + +
+ + + + {roomTemp != null && Room: {roomTemp.toFixed(1)}°C} + + +
+
+ ) +} diff --git a/frontend/src/components/ZoneDetailModal.tsx b/frontend/src/components/ZoneDetailModal.tsx index 882c6b7..841802c 100644 --- a/frontend/src/components/ZoneDetailModal.tsx +++ b/frontend/src/components/ZoneDetailModal.tsx @@ -5,6 +5,7 @@ import { ROOM_STATE_LABELS, DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS, ca import { useAuth } from './AuthGate' import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api' import MhiControlPanel from './MhiControlPanel' +import DaikinControlPanel from './DaikinControlPanel' // The 12 fields a room can individually override — each is `null` on the zone when // inheriting the matching field from the global hvac_defaults template (Settings page), @@ -173,6 +174,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: { {d.health_state.replace('_', ' ')} {(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && } + {d.device_type === 'daikin' && } ))} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 53eb3ec..e8af462 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -23,6 +23,7 @@ export default function Settings() { const [defaultDeparture, setDefaultDeparture] = useState('10:00:00') const [maintUrl, setMaintUrl] = useState('') const [maintKey, setMaintKey] = useState('') + const [daikinCidr, setDaikinCidr] = useState('') const [configSaving, setConfigSaving] = useState(false) // Default occupied/vacant templates — every room inherits these unless it sets its @@ -57,6 +58,7 @@ export default function Settings() { setDefaultDeparture(cfg.default_departure_time ?? '10:00:00') setMaintUrl(cfg.maintenance_url ?? '') setMaintKey(cfg.maintenance_api_key ?? '') + setDaikinCidr(cfg.daikin_subnet_cidr ?? '') // Merge onto the shipped default rather than replacing outright — an hvac_defaults // row saved before weather compensation existed won't have ac_occupied_tiers yet. setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers }) @@ -130,6 +132,7 @@ export default function Settings() { updateConfig('default_departure_time', defaultDeparture), updateConfig('maintenance_url', maintUrl), updateConfig('maintenance_api_key', maintKey), + updateConfig('daikin_subnet_cidr', daikinCidr), ]) setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500) } catch (e) { @@ -425,6 +428,20 @@ export default function Settings() { +
Daikin Aircon
+

+ Daikin units are WiFi/DHCP, so their IP changes over time. Instead of a fixed IP, hvac + periodically sweeps this LAN range over HTTP and matches units by their MAC address — + set it once and it self-heals if a unit's IP changes. Run "Discover Daikin Split" on the + Devices page after saving to find units for the first time. +

+
+
+ + setDaikinCidr(e.target.value)} placeholder="10.4.0.0/22" /> +
+
+
Maintenance Integration

Not yet available — maintenance needs its own Settings → API Keys page first. diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b13e4ee..b0948eb 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -22,9 +22,9 @@ export const DEVICE_TYPE_LABELS: Record = { home_assistant: 'Home Assistant', } -// Device types with an implemented Phase 1/2 driver — everything else in -// DeviceType is an enum value only, ready for its Phase 3 driver. -export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant'] +// Device types with an implemented driver — everything else in DeviceType is an enum +// value only, ready for its driver (currently just 'midea'). +export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant', 'daikin'] // TRV temps/offsets and aircon template fields are all nullable on a zone — NULL means // "inherit the matching field from hvac_defaults" (see HvacDefaults below); a non-null @@ -172,6 +172,23 @@ export interface MhiStatus { export const MHI_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const export const MHI_FAN_SPEEDS = ['low', 'medium', 'high', 'powerful'] as const +// Daikin local-API mode/fan enums — see backend/src/lib/drivers/daikin.js for the +// encode/decode against the unit's actual wire values (unverified against live control +// commands as of 2026-08-25, same "verify on first real write" caveat as MHI's early days). +export const DAIKIN_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const +export const DAIKIN_FAN_SPEEDS = ['auto', 'low', 'medium', 'high', 'powerful'] as const + +export interface DaikinStatus { + external_ref: string + onOff?: string + mode?: string + setpoint?: number + fanSpeed?: string + roomTemp?: number + status_updated_at?: string | null + error?: string +} + export interface DevicePhoto { id: number device_id: number @@ -260,6 +277,7 @@ export interface AppConfig { default_departure_time: string maintenance_url: string maintenance_api_key: string + daikin_subnet_cidr?: string excluded_site_ids?: string[] hvac_defaults?: HvacDefaults }