hvac/frontend/src/api.ts
jtricerolph 373f66ef5c 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>
2026-08-25 12:01:58 +00:00

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