From 1a06a860f527e6b6c11485f99145765d4c4d3fba Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 25 Aug 2026 12:12:38 +0000 Subject: [PATCH] =?UTF-8?q?Fix=20Daikin=20driver:=20use=20node:http=20inst?= =?UTF-8?q?ead=20of=20fetch()=20=E2=80=94=20undici=20gets=200-byte=20bodie?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-tested against the real unit (10.4.2.6, 2026-08-25): a full-subnet sweep came back with 0 units found despite the device being reachable and fast (~150ms). Root cause isolated via a series of in-container diagnostics, not a timeout or CIDR-math bug — Node's fetch() (undici) reliably read a 0-byte body from this adapter's embedded webserver, which sends a connection-close-delimited response (no Content-Length, HTTP/1.0-style) rather than a Content-Length/chunked body. wget, curl, and node:http all read the real ~400-byte body fine; fetch() alone did not. Switched every request in lib/drivers/daikin.js (probe, get_control_info, get_sensor_info, set_control_info) from fetch() to a small node:http-based httpGet() helper with Connection: close, and left a comment warning not to "simplify" this back to fetch(). Bumped SWEEP_TIMEOUT_MS 500ms -> 800ms for headroom (confirmed real round-trip is ~150-180ms, so this was never the actual problem, but a little slack is cheap). node --check clean. Co-Authored-By: Claude Sonnet 5 --- backend/src/lib/drivers/daikin.js | 46 +++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) 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)