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
|
|
@ -1,6 +1,6 @@
|
|||
import type {
|
||||
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
||||
MhiGateway, MhiStatus, ActiveAcTemplate,
|
||||
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/hvac/api'
|
||||
|
|
@ -144,6 +144,14 @@ export function sendMhiControl(deviceId: number, body: { onOff?: boolean; mode?:
|
|||
return request(`/devices/${deviceId}/mhi-control`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
// Daikin manual control (device-level — daikin rows only)
|
||||
export function fetchDaikinStatus(deviceId: number): Promise<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
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
|
|
|
|||
105
frontend/src/components/DaikinControlPanel.tsx
Normal file
105
frontend/src/components/DaikinControlPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default function Settings() {
|
|||
const [defaultDeparture, setDefaultDeparture] = useState('10:00:00')
|
||||
const [maintUrl, setMaintUrl] = useState('')
|
||||
const [maintKey, setMaintKey] = useState('')
|
||||
const [daikinCidr, setDaikinCidr] = useState('')
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
|
||||
// Default occupied/vacant templates — every room inherits these unless it sets its
|
||||
|
|
@ -57,6 +58,7 @@ export default function Settings() {
|
|||
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
|
||||
setMaintUrl(cfg.maintenance_url ?? '')
|
||||
setMaintKey(cfg.maintenance_api_key ?? '')
|
||||
setDaikinCidr(cfg.daikin_subnet_cidr ?? '')
|
||||
// Merge onto the shipped default rather than replacing outright — an hvac_defaults
|
||||
// row saved before weather compensation existed won't have ac_occupied_tiers yet.
|
||||
setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers })
|
||||
|
|
@ -130,6 +132,7 @@ export default function Settings() {
|
|||
updateConfig('default_departure_time', defaultDeparture),
|
||||
updateConfig('maintenance_url', maintUrl),
|
||||
updateConfig('maintenance_api_key', maintKey),
|
||||
updateConfig('daikin_subnet_cidr', daikinCidr),
|
||||
])
|
||||
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
|
||||
} catch (e) {
|
||||
|
|
@ -425,6 +428,20 @@ export default function Settings() {
|
|||
</button>
|
||||
</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>
|
||||
<p className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Not yet available — maintenance needs its own Settings → API Keys page first.
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ export const DEVICE_TYPE_LABELS: Record<DeviceType, string> = {
|
|||
home_assistant: 'Home Assistant',
|
||||
}
|
||||
|
||||
// Device types with an implemented Phase 1/2 driver — everything else in
|
||||
// DeviceType is an enum value only, ready for its Phase 3 driver.
|
||||
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant']
|
||||
// Device types with an implemented driver — everything else in DeviceType is an enum
|
||||
// value only, ready for its driver (currently just 'midea').
|
||||
export const IMPLEMENTED_DEVICE_TYPES: DeviceType[] = ['shelly_trv', 'mhi_mqtt', 'home_assistant', 'daikin']
|
||||
|
||||
// TRV temps/offsets and aircon template fields are all nullable on a zone — NULL means
|
||||
// "inherit the matching field from hvac_defaults" (see HvacDefaults below); a non-null
|
||||
|
|
@ -172,6 +172,23 @@ export interface MhiStatus {
|
|||
export const MHI_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const
|
||||
export const MHI_FAN_SPEEDS = ['low', 'medium', 'high', 'powerful'] as const
|
||||
|
||||
// Daikin local-API mode/fan enums — see backend/src/lib/drivers/daikin.js for the
|
||||
// encode/decode against the unit's actual wire values (unverified against live control
|
||||
// commands as of 2026-08-25, same "verify on first real write" caveat as MHI's early days).
|
||||
export const DAIKIN_MODES = ['cool', 'heat', 'fan', 'auto', 'dry'] as const
|
||||
export const DAIKIN_FAN_SPEEDS = ['auto', 'low', 'medium', 'high', 'powerful'] as const
|
||||
|
||||
export interface DaikinStatus {
|
||||
external_ref: string
|
||||
onOff?: string
|
||||
mode?: string
|
||||
setpoint?: number
|
||||
fanSpeed?: string
|
||||
roomTemp?: number
|
||||
status_updated_at?: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DevicePhoto {
|
||||
id: number
|
||||
device_id: number
|
||||
|
|
@ -260,6 +277,7 @@ export interface AppConfig {
|
|||
default_departure_time: string
|
||||
maintenance_url: string
|
||||
maintenance_api_key: string
|
||||
daikin_subnet_cidr?: string
|
||||
excluded_site_ids?: string[]
|
||||
hvac_defaults?: HvacDefaults
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue