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:
parent
5fc576aabd
commit
373f66ef5c
12 changed files with 559 additions and 27 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
79
backend/src/lib/daikin-poller.js
Normal file
79
backend/src/lib/daikin-poller.js
Normal 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
|
||||
}
|
||||
26
backend/src/lib/device-registry.js
Normal file
26
backend/src/lib/device-registry.js
Normal 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
|
||||
}
|
||||
206
backend/src/lib/drivers/daikin.js
Normal file
206
backend/src/lib/drivers/daikin.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue