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 { 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)

View file

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

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 * 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)

View file

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