Add Daikin local-API driver — MAC-matched subnet sweep instead of UDP broadcast

Daikin BRP069/BRP072-class units are WiFi/DHCP with no fixed IP. hvac's backend
runs as a Docker container on its own bridge network (not directly on the LXC's
admin-VLAN NIC), so UDP broadcast discovery risked silently failing to egress —
instead, every unit is identified by MAC (external_ref), and a background poller
periodically HTTP-sweeps the configured subnet CIDR and matches replies by MAC,
self-healing zone_devices.device_ip whenever a lease changes. Only needs plain
outbound HTTP, same path already proven by the MHI Modbus TCP driver.

- lib/drivers/daikin.js: discover()/getStatus()/setTarget() against the local
  /common/basic_info + /aircon/get_control_info + /aircon/set_control_info API
  (confirmed live against firmware 4_2_303, 2026-08-25). Mode/fan-rate encoding
  taken from community reverse-engineering, not yet verified against a live
  setTarget() call — flagged in comments for first real control test.
- lib/daikin-poller.js: 60s status poll (cached IP) + 5min subnet resweep
  (self-heal IP + auto-register new units, never auto-assigned to a zone).
- lib/device-registry.js: shared discover-upsert, extracted so both the manual
  Discover button and the background sweep record device_ip the same way.
- ac-control.js / routes/override.js: daikin-status/daikin-control endpoints
  and commandAcDevice() branch, mirroring the existing MHI control path.
- Settings page: new "Daikin subnet (CIDR)" field (daikin_subnet_cidr config
  key) — sweeps do nothing until this is set.
- Frontend: DaikinControlPanel.tsx (on/off/mode/setpoint/fan), wired into
  ZoneDetailModal; daikin moved from NOT_YET_IMPLEMENTED to a real driver.

Backend node --check and frontend tsc --noEmit both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 12:01:58 +00:00
parent 5fc576aabd
commit 373f66ef5c
12 changed files with 559 additions and 27 deletions

View file

@ -9,6 +9,7 @@ import { initDb } from './db.js'
import { connect as connectMqtt } from './lib/mqtt.js' import { connect as connectMqtt } from './lib/mqtt.js'
import { startScheduler } from './lib/scheduler.js' import { startScheduler } from './lib/scheduler.js'
import { startAcPoller } from './lib/ac-poller.js' import { startAcPoller } from './lib/ac-poller.js'
import { startDaikinPoller } from './lib/daikin-poller.js'
import { zoneRoutes } from './routes/zones.js' import { zoneRoutes } from './routes/zones.js'
import { deviceRoutes } from './routes/devices.js' import { deviceRoutes } from './routes/devices.js'
import { statusRoutes } from './routes/status.js' import { statusRoutes } from './routes/status.js'
@ -54,6 +55,7 @@ try {
await startScheduler() await startScheduler()
await startAcPoller().catch(err => app.log.warn(`AC poller failed to start: ${err.message}`)) 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' }) await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) { } catch (err) {
app.log.error(err) app.log.error(err)

View file

@ -5,6 +5,7 @@
import { pool } from '../db.js' import { pool } from '../db.js'
import { publishMhiCommand } from './mqtt.js' import { publishMhiCommand } from './mqtt.js'
import * as mhiDriver from './drivers/mhi-modbus.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). // device: a zone_devices row (needs at least id, device_type, external_ref).
// patch: { onOff?: boolean, mode?: string, tempC?: number, fanSpeed?: string } — only // 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 } 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' } return { ok: false, reason: 'unsupported_device_type' }
} }

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -6,24 +6,29 @@ import { requireAuth, requireCap } from '../auth.js'
import { pool, logActivity } from '../db.js' import { pool, logActivity } from '../db.js'
import * as trvDriver from '../lib/drivers/trv.js' import * as trvDriver from '../lib/drivers/trv.js'
import * as haDriver from '../lib/drivers/homeassistant.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 { 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 ALLOWED_IMAGES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const PHOTO_TYPES = ['device', 'serial_plate'] const PHOTO_TYPES = ['device', 'serial_plate']
// device_type -> driver implementing discover()/getStatus()/setTarget(). Phase 3 // device_type -> driver implementing discover()/getStatus()/setTarget(). midea is
// types (midea, daikin) are enum values only — no driver yet. mhi_modbus DOES // still an enum value only — no driver yet. mhi_modbus DOES have a driver
// have a driver (lib/drivers/mhi-modbus.js) but it isn't wired into this generic // (lib/drivers/mhi-modbus.js) but it isn't wired into this generic no-args discover
// no-args discover flow — Modbus has no broadcast scan, and discover() there // flow — Modbus has no broadcast scan, and discover() there needs a specific
// needs a specific already-configured gateway, so it's only ever called from // already-configured gateway, so it's only ever called from routes/mhi-gateways.js's
// routes/mhi-gateways.js's test-connection route. Units are onboarded via // test-connection route. Units are onboarded via "Gateways" (Devices page): configure
// "Gateways" (Devices page): configure a gateway, import its MAPS register map, // a gateway, import its MAPS register map, then assign units to zones — never via
// then assign units to zones — never via this Discover button. // 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 = { const DRIVERS = {
shelly_trv: trvDriver, shelly_trv: trvDriver,
home_assistant: haDriver, 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 " + 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.' '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 ' + 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}` }) if (!driver) return reply.status(400).send({ error: `Unknown device_type: ${device_type}` })
const result = await driver.discover() 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 const discoveredCount = await upsertDiscovered(device_type, result.devices)
for (const d of result.devices) { return { ok: true, found: result.devices.length, new: discoveredCount, note: result.note }
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 }
}) })
// PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard) // PATCH /api/devices/:id — zone/location assignment (a living mapping, not a one-time wizard)

View file

@ -94,4 +94,50 @@ export async function overrideRoutes(app) {
return { ok } 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 }
})
} }

View file

@ -1,6 +1,6 @@
import type { import type {
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig, Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
MhiGateway, MhiStatus, ActiveAcTemplate, MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus,
} from './types' } from './types'
const BASE = '/hvac/api' 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) }) 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<DaikinStatus> {
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 // Config / settings
export function fetchConfig(): Promise<AppConfig> { export function fetchConfig(): Promise<AppConfig> {
return request('/config') return request('/config')

View file

@ -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<string>('auto')
const [setpoint, setSetpoint] = useState(21)
const [fanSpeed, setFanSpeed] = useState<string>('auto')
const [roomTemp, setRoomTemp] = useState<number | null>(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 <p className="muted" style={{ fontSize: 12.5 }}>Reading aircon status…</p>
return (
<div style={{ marginTop: 8 }}>
{error && <div className="error-banner" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<button
className="btn btn-sm"
disabled={!canControl || applying}
onClick={() => { const next = !onOff; setOnOff(next); apply({ onOff: next }) }}
style={onOff ? { background: 'var(--gold)', borderColor: 'var(--gold)', color: 'var(--navy)' } : undefined}
>
<Power size={13} strokeWidth={1.75} /> {onOff ? 'On' : 'Off'}
</button>
<select
disabled={!canControl || applying}
value={mode}
onChange={e => { setMode(e.target.value); apply({ mode: e.target.value }) }}
>
{DAIKIN_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
</select>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
className="btn btn-sm" disabled={!canControl || applying}
onClick={() => { const next = Math.max(16, setpoint - 0.5); setSetpoint(next); apply({ tempC: next }) }}
>
<Minus size={12} strokeWidth={1.75} />
</button>
<span style={{ minWidth: 48, textAlign: 'center', fontWeight: 600, fontSize: 13 }}>{setpoint.toFixed(1)}°C</span>
<button
className="btn btn-sm" disabled={!canControl || applying}
onClick={() => { const next = Math.min(30, setpoint + 0.5); setSetpoint(next); apply({ tempC: next }) }}
>
<Plus size={12} strokeWidth={1.75} />
</button>
</div>
<select
disabled={!canControl || applying}
value={fanSpeed}
onChange={e => { setFanSpeed(e.target.value); apply({ fanSpeed: e.target.value }) }}
>
{DAIKIN_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
</select>
{roomTemp != null && <span className="muted" style={{ fontSize: 12 }}>Room: {roomTemp.toFixed(1)}°C</span>}
<button className="btn btn-sm" disabled={loading} onClick={load} title="Refresh live status">
{applying ? <Loader2 size={13} strokeWidth={1.75} className="spin" /> : <RefreshCw size={13} strokeWidth={1.75} />}
</button>
</div>
</div>
)
}

View file

@ -5,6 +5,7 @@ import { ROOM_STATE_LABELS, DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS, ca
import { useAuth } from './AuthGate' import { useAuth } from './AuthGate'
import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api' import { updateZone, overrideZone, fetchActivity, fetchConfig } from '../api'
import MhiControlPanel from './MhiControlPanel' import MhiControlPanel from './MhiControlPanel'
import DaikinControlPanel from './DaikinControlPanel'
// The 12 fields a room can individually override — each is `null` on the zone when // 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), // inheriting the matching field from the global hvac_defaults template (Settings page),
@ -173,6 +174,7 @@ export default function ZoneDetailModal({ zone, onClose, onSaved }: {
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span> <span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
</div> </div>
{(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />} {(d.device_type === 'mhi_modbus' || d.device_type === 'mhi_mqtt') && <MhiControlPanel deviceId={d.id} canControl={canControl} />}
{d.device_type === 'daikin' && <DaikinControlPanel deviceId={d.id} canControl={canControl} />}
</div> </div>
))} ))}

View file

@ -23,6 +23,7 @@ export default function Settings() {
const [defaultDeparture, setDefaultDeparture] = useState('10:00:00') const [defaultDeparture, setDefaultDeparture] = useState('10:00:00')
const [maintUrl, setMaintUrl] = useState('') const [maintUrl, setMaintUrl] = useState('')
const [maintKey, setMaintKey] = useState('') const [maintKey, setMaintKey] = useState('')
const [daikinCidr, setDaikinCidr] = useState('')
const [configSaving, setConfigSaving] = useState(false) const [configSaving, setConfigSaving] = useState(false)
// Default occupied/vacant templates — every room inherits these unless it sets its // 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') setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
setMaintUrl(cfg.maintenance_url ?? '') setMaintUrl(cfg.maintenance_url ?? '')
setMaintKey(cfg.maintenance_api_key ?? '') setMaintKey(cfg.maintenance_api_key ?? '')
setDaikinCidr(cfg.daikin_subnet_cidr ?? '')
// Merge onto the shipped default rather than replacing outright — an hvac_defaults // 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. // 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 }) 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('default_departure_time', defaultDeparture),
updateConfig('maintenance_url', maintUrl), updateConfig('maintenance_url', maintUrl),
updateConfig('maintenance_api_key', maintKey), updateConfig('maintenance_api_key', maintKey),
updateConfig('daikin_subnet_cidr', daikinCidr),
]) ])
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500) setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
} catch (e) { } catch (e) {
@ -425,6 +428,20 @@ export default function Settings() {
</button> </button>
</div> </div>
<div className="section-title">Daikin Aircon</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
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.
</p>
<div className="field-row">
<div className="field">
<label>Daikin subnet (CIDR)</label>
<input type="text" value={daikinCidr} onChange={e => setDaikinCidr(e.target.value)} placeholder="10.4.0.0/22" />
</div>
</div>
<div className="section-title">Maintenance Integration</div> <div className="section-title">Maintenance Integration</div>
<p className="field-hint" style={{ marginBottom: 10 }}> <p className="field-hint" style={{ marginBottom: 10 }}>
Not yet available — maintenance needs its own Settings → API Keys page first. Not yet available — maintenance needs its own Settings → API Keys page first.

View file

@ -22,9 +22,9 @@ export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
home_assistant: 'Home Assistant', home_assistant: 'Home Assistant',
} }
// Device types with an implemented Phase 1/2 driver — everything else in // Device types with an implemented driver — everything else in DeviceType is an enum
// DeviceType is an enum value only, ready for its Phase 3 driver. // value only, ready for its driver (currently just 'midea').
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant'] 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 // 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 // "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_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const
export const MHI_FAN_SPEEDS = ['low', 'medium', 'high', 'powerful'] 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 { export interface DevicePhoto {
id: number id: number
device_id: number device_id: number
@ -260,6 +277,7 @@ export interface AppConfig {
default_departure_time: string default_departure_time: string
maintenance_url: string maintenance_url: string
maintenance_api_key: string maintenance_api_key: string
daikin_subnet_cidr?: string
excluded_site_ids?: string[] excluded_site_ids?: string[]
hvac_defaults?: HvacDefaults hvac_defaults?: HvacDefaults
} }