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

@ -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 { 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 }: {
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
</div>
{(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>
))}