Fix Daikin driver: use node:http instead of fetch() — undici gets 0-byte bodies

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 12:12:38 +00:00
parent 373f66ef5c
commit 1a06a860f5

View file

@ -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)