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>
164 lines
7.4 KiB
TypeScript
164 lines
7.4 KiB
TypeScript
import type {
|
|
Zone, ZoneStatus, Device, DevicePhoto, ActivityEntry, NewbookSite, AppConfig,
|
|
MhiGateway, MhiStatus, ActiveAcTemplate, DaikinStatus,
|
|
} from './types'
|
|
|
|
const BASE = '/hvac/api'
|
|
|
|
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
|
...opts,
|
|
})
|
|
if (res.status === 401) {
|
|
;(window.top ?? window).location.href = '/login'
|
|
throw new Error('Unauthenticated')
|
|
}
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
|
throw new Error(err.error || `Request failed: ${res.status}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
// Zones
|
|
export function fetchZones(): Promise<Zone[]> {
|
|
return request('/zones')
|
|
}
|
|
export function updateZone(id: number, body: Record<string, unknown>): Promise<Zone> {
|
|
return request(`/zones/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
|
}
|
|
export function createZone(body: { name: string; zone_type: 'room' | 'public_area'; occupied_temp?: number; vacant_temp?: number }): Promise<Zone> {
|
|
return request('/zones', { method: 'POST', body: JSON.stringify(body) })
|
|
}
|
|
export function fetchNewbookSites(): Promise<{ sites: NewbookSite[] }> {
|
|
return request('/zones/newbook-sites')
|
|
}
|
|
export function saveExcludedSites(excludedSiteIds: string[]): Promise<{ ok: boolean }> {
|
|
return request('/zones/newbook-sites', { method: 'PUT', body: JSON.stringify({ excluded_site_ids: excludedSiteIds }) })
|
|
}
|
|
export function testNewbookConnection(): Promise<{ ok: boolean; message?: string; error?: string }> {
|
|
return request('/zones/newbook-test', { method: 'POST', body: JSON.stringify({}) })
|
|
}
|
|
export function syncZonesFromNewbook(): Promise<{ ok: boolean; created: number; updated: number; total: number }> {
|
|
return request('/zones/sync-newbook', { method: 'POST', body: JSON.stringify({}) })
|
|
}
|
|
|
|
// Status / activity
|
|
export function fetchStatus(): Promise<{
|
|
mqtt_connected: boolean
|
|
weather_peak_c: number | null
|
|
weather_tier: string | null
|
|
active_ac_template: ActiveAcTemplate
|
|
zones: ZoneStatus[]
|
|
}> {
|
|
return request('/status')
|
|
}
|
|
export function fetchActivity(zoneId?: number, limit = 100): Promise<ActivityEntry[]> {
|
|
const params = new URLSearchParams()
|
|
if (zoneId) params.set('zone_id', String(zoneId))
|
|
params.set('limit', String(limit))
|
|
return request(`/status/activity?${params.toString()}`)
|
|
}
|
|
|
|
// Override
|
|
export function overrideZone(id: number, tempC: number): Promise<{ ok: boolean; successful: number; total: number; auto_mode: boolean; error?: string }> {
|
|
return request(`/zones/${id}/override`, { method: 'POST', body: JSON.stringify({ temp_c: tempC }) })
|
|
}
|
|
|
|
// Devices
|
|
export function fetchDevices(): Promise<Device[]> {
|
|
return request('/devices')
|
|
}
|
|
export function discoverDevices(deviceType: string): Promise<{ ok: boolean; devices?: unknown[]; found?: number; new?: number; note?: string; error?: string }> {
|
|
return request('/devices/discover', { method: 'POST', body: JSON.stringify({ device_type: deviceType }) })
|
|
}
|
|
export function updateDevice(id: number, body: { zone_id?: number | null; location?: string }): Promise<Device> {
|
|
return request(`/devices/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
|
}
|
|
export function deleteDevice(id: number): Promise<{ ok: boolean }> {
|
|
return request(`/devices/${id}`, { method: 'DELETE' })
|
|
}
|
|
export function createMaintenanceAsset(id: number): Promise<{ ok: boolean; notImplemented?: boolean; error?: string }> {
|
|
return request(`/devices/${id}/create-maintenance-asset`, { method: 'POST', body: JSON.stringify({}) })
|
|
}
|
|
|
|
// Device photos — multipart, so no JSON content-type header
|
|
export async function uploadDevicePhoto(deviceId: number, file: File, photoType: 'device' | 'serial_plate'): Promise<DevicePhoto> {
|
|
const form = new FormData()
|
|
form.append('photo_type', photoType)
|
|
form.append('file', file)
|
|
const res = await fetch(`${BASE}/devices/${deviceId}/photos`, { method: 'POST', credentials: 'include', body: form })
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
|
throw new Error(err.error || `Upload failed: ${res.status}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
export function fetchDevicePhotos(deviceId: number): Promise<DevicePhoto[]> {
|
|
return request(`/devices/${deviceId}/photos`)
|
|
}
|
|
export function deleteDevicePhoto(id: number): Promise<{ ok: boolean }> {
|
|
return request(`/photos/${id}`, { method: 'DELETE' })
|
|
}
|
|
export function photoUrl(filePath: string): string {
|
|
return `${BASE}/uploads${filePath}`
|
|
}
|
|
|
|
// MHI Modbus gateways (manual aircon control)
|
|
export function fetchMhiGateways(): Promise<MhiGateway[]> {
|
|
return request('/mhi-gateways')
|
|
}
|
|
export function createMhiGateway(body: { name: string; host: string; port?: number; slaveId?: number; addressBase?: number }): Promise<MhiGateway> {
|
|
return request('/mhi-gateways', { method: 'POST', body: JSON.stringify(body) })
|
|
}
|
|
export function updateMhiGateway(id: number, body: Partial<{ name: string; host: string; port: number; slaveId: number; addressBase: number }>): Promise<MhiGateway> {
|
|
return request(`/mhi-gateways/${id}`, { method: 'PATCH', body: JSON.stringify(body) })
|
|
}
|
|
export function testMhiGatewayConnection(id: number): Promise<{ ok: boolean; note?: string; error?: string }> {
|
|
return request(`/mhi-gateways/${id}/test-connection`, { method: 'POST', body: JSON.stringify({}) })
|
|
}
|
|
export function deleteMhiGateway(id: number): Promise<{ ok: boolean }> {
|
|
return request(`/mhi-gateways/${id}`, { method: 'DELETE' })
|
|
}
|
|
export async function importMhiRegisterMap(id: number, file: File): Promise<MhiGateway> {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
const res = await fetch(`${BASE}/mhi-gateways/${id}/import`, { method: 'POST', credentials: 'include', body: form })
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
|
throw new Error(err.error || `Import failed: ${res.status}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
export function assignMhiUnit(gatewayId: number, unitIndex: number, body: { zone_id: number | null; location: string }): Promise<Device> {
|
|
return request(`/mhi-gateways/${gatewayId}/units/${unitIndex}/assign`, { method: 'POST', body: JSON.stringify(body) })
|
|
}
|
|
|
|
// MHI manual control (device-level — mhi_modbus rows only)
|
|
export function fetchMhiStatus(deviceId: number): Promise<MhiStatus> {
|
|
return request(`/devices/${deviceId}/mhi-status`)
|
|
}
|
|
export function sendMhiControl(deviceId: number, body: { onOff?: boolean; mode?: string; tempC?: number; fanSpeed?: string }): Promise<{ ok: boolean }> {
|
|
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')
|
|
}
|
|
export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> {
|
|
return request(`/config/${key}`, { method: 'PUT', body: JSON.stringify({ value }) })
|
|
}
|
|
export function fetchMqttStatus(): Promise<{ connected: boolean; note: string }> {
|
|
return request('/settings/mqtt-status')
|
|
}
|