diff --git a/backend/src/lib/drivers/daikin.js b/backend/src/lib/drivers/daikin.js index eba5695..9e34084 100644 --- a/backend/src/lib/drivers/daikin.js +++ b/backend/src/lib/drivers/daikin.js @@ -15,12 +15,19 @@ // 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. +// +// Uses node:http, NOT fetch() — confirmed live 2026-08-25 that this adapter's embedded +// webserver sends a connection-close-delimited body (no Content-Length, HTTP/1.0-style) +// and undici's fetch() reliably reads it as 0 bytes even though the connection itself +// succeeds fast (~150ms). wget/curl and node:http both read it correctly by consuming +// until the socket closes. Do not "simplify" this back to fetch() — it silently breaks. +import http from 'node:http' import { pool } from '../../db.js' export const DEVICE_TYPE = 'daikin' const SWEEP_CONCURRENCY = 50 -const SWEEP_TIMEOUT_MS = 500 +const SWEEP_TIMEOUT_MS = 800 // 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) — @@ -76,6 +83,21 @@ function cidrToHosts(cidr) { return hosts } +// Connection: close forces the adapter to actually close the socket at the end of the +// body (rather than us guessing when a keep-alive stream is "done") — read to 'end'. +function httpGet(url, timeoutMs) { + return new Promise((resolve, reject) => { + const req = http.get(url, { headers: { Connection: 'close' }, timeout: timeoutMs }, res => { + let body = '' + res.setEncoding('utf8') + res.on('data', chunk => { body += chunk }) + res.on('end', () => resolve({ statusCode: res.statusCode, body })) + }) + req.on('timeout', () => req.destroy(new Error(`timeout after ${timeoutMs}ms`))) + req.on('error', reject) + }) +} + async function mapWithConcurrency(items, limit, fn) { const results = new Array(items.length) let idx = 0 @@ -91,9 +113,9 @@ async function mapWithConcurrency(items, limit, fn) { 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()) + const { statusCode, body } = await httpGet(`http://${ip}/common/basic_info`, timeoutMs) + if (statusCode !== 200) return null + const info = parseKV(body) if (info.ret !== 'OK' || info.type !== 'aircon' || !info.mac) return null return { external_ref: info.mac, @@ -154,11 +176,11 @@ export async function getStatus(externalRef) { 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) }), + httpGet(`http://${device.device_ip}/aircon/get_control_info`, 5000), + httpGet(`http://${device.device_ip}/aircon/get_sensor_info`, 5000), ]) - const control = parseKV(await controlRes.text()) - const sensor = parseKV(await sensorRes.text()) + const control = parseKV(controlRes.body) + const sensor = parseKV(sensorRes.body) if (control.ret !== 'OK') throw new Error(`Unit returned ${control.ret || 'no response'}`) return { @@ -184,8 +206,8 @@ export async function setTarget(externalRef, { onOff, mode, tempC, fanSpeed } = 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()) + const curRes = await httpGet(`http://${device.device_ip}/aircon/get_control_info`, 5000) + const current = parseKV(curRes.body) if (current.ret !== 'OK') throw new Error(`Unit returned ${current.ret || 'no response'}`) const params = new URLSearchParams({ @@ -196,8 +218,8 @@ export async function setTarget(externalRef, { onOff, mode, tempC, fanSpeed } = 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()) + const setRes = await httpGet(`http://${device.device_ip}/aircon/set_control_info?${params}`, 5000) + const result = parseKV(setRes.body) return result.ret === 'OK' } catch (err) { console.error(`[daikin driver] setTarget failed for ${externalRef}:`, err.message)