UNPKG

iobroker.roborock

Version:
1,242 lines (1,059 loc) 43.9 kB
import { Parser } from "binary-parser"; import * as crc32 from "crc-32"; import * as crypto from "node:crypto"; import * as dgram from "node:dgram"; import { isIP, Socket, SocketConstructorOpts } from "node:net"; import * as ping from "ping"; import type { Roborock } from "../main"; const UDP_DISCOVERY_PORT = 58866; const TCP_CONNECTION_PORT = 58867; // The static key used for broadcast discovery decryption const BROADCAST_TOKEN = Buffer.from("qWKYcdQWrbm9hPqe", "utf8"); function nextSocketRandom(): number { return Math.floor(Math.random() * 1_000_000 + 1_000) >>> 0; } // -------------------- // Interfaces & Types // -------------------- type LocalEndpointSource = "udp" | "network_info" | "probe"; interface LocalDevice { ip: string; version: string; connectNonce?: number; ackNonce?: number; lastSeenAt?: number; lastConnectAttemptAt?: number; staleSince?: number; endpointSource?: LocalEndpointSource; } class EnhancedSocket extends Socket { connected: boolean; chunkBuffer: Buffer; receivedBytes: number; sentBytes: number; lastReceivedAt?: number; lastSentAt?: number; lastPingAt?: number; pingOutstanding: number; constructor(options?: SocketConstructorOpts) { super(options); this.connected = false; this.chunkBuffer = Buffer.alloc(0); this.receivedBytes = 0; this.sentBytes = 0; this.pingOutstanding = 0; (this as any).on("connect", () => { const now = Date.now(); this.connected = true; this.lastReceivedAt = now; this.lastSentAt = now; this.lastPingAt = undefined; this.pingOutstanding = 0; }); (this as any).on("close", () => { this.connected = false; }); (this as any).on("error", () => { this.connected = false; }); (this as any).on("end", () => { this.connected = false; }); } } // -------------------- // Binary Parsers // -------------------- // Parser for just the version field (first 3 bytes) const versionParser = new Parser().string("version", { length: 3 }); // Parser for v1.0 packet fields (excluding version) const v1_0_Parser = new Parser() .endianess("big") .uint32("seq") .uint16("protocol") .uint16("payloadLen") .buffer("payload", { length: "payloadLen" }) .uint32("crc32"); // Parser for L01 Discovery packet fields (excluding version) const vL01_Parser = new Parser() .endianess("big") .buffer("field1", { length: 4 }) // Unknown field at offset 3 .buffer("field2", { length: 2 }) // Unknown field at offset 7 .uint16("payloadLen") .buffer("payload", { length: "payloadLen" }) .uint32("crc32"); // -------------------- // Local API Class // -------------------- export class local_api { adapter: Roborock; deviceSockets: Record<string, EnhancedSocket>; cloudDevices: Set<string>; localDevices: Record<string, LocalDevice> = {}; localDevicesInterval: NodeJS.Timeout | null = null; private reconnectPlanned = new Set<string>(); private connectPromises = new Map<string, Promise<void>>(); private sessionAckWaiters = new Map<string, { resolve: () => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); private discoveryServer: dgram.Socket | null = null; private discoveryTimer: NodeJS.Timeout | null = null; private gracePeriodTimer: NodeJS.Timeout | null = null; private discoveryRestartTimer: NodeJS.Timeout | null = null; private discoveryWindowPromise: Promise<void> | null = null; private resolveDiscoveryWindow: (() => void) | null = null; private discoveryStopping = false; private endpointRefreshPromises = new Map<string, Promise<boolean>>(); private endpointRefreshLastStartedAt = new Map<string, number>(); private tcpKeepaliveInterval: ioBroker.Interval | undefined = undefined; private static readonly TCP_KEEPALIVE_MS = 10_000; private static readonly TCP_KEEPALIVE_GRACE_MS = 1_000; private static readonly TCP_KEEPALIVE_CHECK_MS = 1_000; private static readonly UDP_DISCOVERY_RESTART_MS = 30_000; private static readonly ENDPOINT_REFRESH_MIN_INTERVAL_MS = 60_000; private static readonly STALE_ENDPOINT_CONNECT_MIN_INTERVAL_MS = 60_000; constructor(adapter: Roborock) { this.adapter = adapter; this.deviceSockets = {}; this.cloudDevices = new Set(); this.localDevices = {}; } /** * Initiates a TCP client connection for the given device. * Used for local control if an IP is available. */ async initiateClient(duid: string, suppressLog: boolean = false, timeoutMs = 5000): Promise<void> { let promise = this.connectPromises.get(duid); if (!promise) { promise = this._performConnection(duid, timeoutMs) .finally(() => { // We do NOT delete from the map here immediately if we want to cache the "success" state? // No, we only cache the "Connecting" state. this.connectPromises.delete(duid); }); this.connectPromises.set(duid, promise); } try { await promise; } catch (err: unknown) { if (suppressLog === true) { // Don't log internally, don't retry immediately. Let caller handle summary log. this.cloudDevices.add(duid); throw err; } const logLevel = suppressLog ? "debug" : "warn"; this.adapter.rLog("TCP", duid, "Error", undefined, undefined, `TCP connect failed for ${duid}: ${this.adapter.errorMessage(err)}`, logLevel); if (this.isEndpointRefreshError(err)) { this.markEndpointStale(duid, `connect failed: ${this.adapter.errorMessage(err)}`); } else { this.scheduleReconnect(duid, "connect failed", !!suppressLog); } this.cloudDevices.add(duid); } } private async _performConnection(duid: string, timeoutMs: number): Promise<void> { const ip = this.getIpForDuid(duid); if (!ip) { this.adapter.rLog("Local", duid, "Debug", "N/A", undefined, `No local IP -> falling back to MQTT`, "debug"); this.cloudDevices.add(duid); return; // Resolves void } const dev = this.localDevices[duid]; if (dev) { dev.lastConnectAttemptAt = Date.now(); } // Check if already connected const existing = this.deviceSockets?.[duid]; if (existing?.connected) { if (dev) { dev.staleSince = undefined; dev.lastSeenAt = Date.now(); } this.adapter.rLog("TCP", duid, "Debug", "TCP", undefined, `Already connected via TCP`, "debug"); this.cloudDevices.delete(duid); return; // Resolves void } // Attempt TCP connection const client = new EnhancedSocket(); await new Promise<void>((resolve, reject) => { const onErrorOnce = (err: Error) => reject(err); client.once("error", onErrorOnce); // Only catch error during initial connection client.setTimeout(timeoutMs, () => { client.destroy(); reject(new Error("TCP connect timeout")); }); client.connect(TCP_CONNECTION_PORT, ip, () => { client.removeListener("error", onErrorOnce); client.setTimeout(0); // Disable timeout after connection client.setKeepAlive(true, 30000); // Keep connection alive through NAT; 30s initial delay this.deviceSockets[duid] = client; this.reconnectPlanned.delete(duid); this.adapter.requestsHandler.messageParser.resetTransportSequence(duid); resolve(); }); }); // Handle incoming data client.on("data", async (message: Buffer) => { try { client.lastReceivedAt = Date.now(); client.receivedBytes += message.length; this.adapter.rLog("TCP", duid, "<-", this.getLocalProtocolVersion(duid) ?? undefined, undefined, `raw data | bytes=${message.length} | totalRx=${client.receivedBytes} | bufferedBefore=${client.chunkBuffer.length}`, "debug"); client.chunkBuffer = client.chunkBuffer.length === 0 ? message : Buffer.concat([client.chunkBuffer, message] as Uint8Array[]); let offset = 0; while (offset + 4 <= client.chunkBuffer.length) { const segmentLength = client.chunkBuffer.readUInt32BE(offset); if (segmentLength < 17) { this.adapter.rLog("TCP", duid, "Warn", this.getLocalProtocolVersion(duid) ?? undefined, undefined, `invalid frame length | frameBytes=${segmentLength} | buffered=${client.chunkBuffer.length}`, "warn"); this.clearChunkBuffer(duid); this.scheduleReconnect(duid, "invalid frame length"); return; } const frameEnd = offset + 4 + segmentLength; if (frameEnd > client.chunkBuffer.length) { break; } const currentBuffer = client.chunkBuffer.subarray(offset + 4, frameEnd); // Control frames are unencrypted and establish/maintain the local socket session. const protocol = currentBuffer.readUInt16BE(15); const frameVersion = currentBuffer.subarray(0, 3).toString(); const frameMsgId = currentBuffer.length >= 7 ? currentBuffer.readUInt32BE(3) : undefined; this.adapter.rLog("TCP", duid, "<-", frameVersion, protocol, `frame header | tcpMsgId=${frameMsgId ?? "n/a"} | frameBytes=${segmentLength}`, "debug"); if (protocol === 1) { // CONNACK const nonce = currentBuffer.readUInt32BE(7); const returnCode = currentBuffer.length >= 21 ? currentBuffer.readUInt32BE(17) : 0; if (returnCode === 0 && this.localDevices[duid]) { this.localDevices[duid].ackNonce = nonce; this.resolveSessionAck(duid); this.adapter.rLog("TCP", duid, "<-", "Control", 1, `connack | ackNonce=${nonce} | returnCode=${returnCode}`, "debug"); } else { this.rejectSessionAck(duid, new Error(`TCP CONNACK rejected with returnCode=${returnCode}`)); this.adapter.rLog("TCP", duid, "<-", "Control", 1, `connack rejected | ackNonce=${nonce} | returnCode=${returnCode}`, "warn"); } } else if (segmentLength === 17 || segmentLength === 21) { // Short frames logic (Ping Response etc) switch (protocol) { case 3: // PINGRESP client.pingOutstanding = Math.max(0, client.pingOutstanding - 1); this.adapter.rLog("TCP", duid, "<-", frameVersion, protocol, `pingresp`, "debug"); break; case 5: // PUBACK this.adapter.rLog("TCP", duid, "<-", frameVersion, protocol, `puback | tcpMsgId=${frameMsgId ?? "n/a"}`, "debug"); break; default: } } else { // Decode standard data message const allMessages = this.adapter.requestsHandler.messageParser.decodeMsg(currentBuffer, duid); for (const data of allMessages) { // Protocol 4: Device Status Update if (data.protocol === 4 || data.protocol === 6 || data.protocol === 7) { this.sendPubAck(duid, data.seq, data.version); } if (data.protocol === 4 || data.version === "B01") { const payloadStr = data.payload.toString(); let parsedPayload; try { parsedPayload = JSON.parse(payloadStr); } catch (e) { this.adapter.rLog("TCP", duid, "Error", data.version, undefined, `Parse Error | ${e}`, "warn"); continue; } if (data.version === "B01") { const dps = parsedPayload.dps; if (dps?.["10001"]) { let inner = dps["10001"]; if (typeof inner === "string") { try { inner = JSON.parse(inner); } catch (e) { this.adapter.rLog("TCP", duid, "Error", "B01", undefined, `Nested JSON Parse Error | ${e}`, "warn"); continue; } } const id = inner.msgId || inner.id; const result = inner.code === 0 ? inner.data : (inner.error || inner.result); if (id) { this.adapter.requestsHandler.resolvePendingRequest(id, result, `Local-${data.version}`, duid, "TCP"); } else { this.adapter.rLog("TCP", duid, "<-", "B01", undefined, `Received B01 message without ID.`, "warn"); } } } else if (data.protocol === 4) { this.resolveLocalProtocol4Payload(duid, data.version, data.protocol, parsedPayload); } } else { // Explicitly log unknown protocols as Error to identify missing handlers this.adapter.rLog("TCP", duid, "Error", data.version, data.protocol, `Unhandled Protocol ${data.protocol}`, "error"); } } } offset = frameEnd; } if (offset > 0) { client.chunkBuffer = client.chunkBuffer.subarray(offset); } } catch (error: unknown) { this.adapter.catchError(error, "initiateClient", duid); } }); const version = this.getLocalProtocolVersion(duid); if (version === "1.0" || version === "L01") { try { await this.initHandshake(duid, version); await this.waitForSessionAck(duid, timeoutMs, version); } catch (error: unknown) { const socket = this.deviceSockets[duid]; if (socket && !socket.destroyed) socket.destroy(); delete this.deviceSockets[duid]; throw error; } } client.on("close", () => this.scheduleReconnect(duid, `connection closed`)); client.on("error", (error) => this.scheduleReconnect(duid, `connection error: ${error.message}`)); client.on("end", () => this.scheduleReconnect(duid, "connection ended")); this.adapter.rLog("TCP", duid, "Info", undefined, undefined, `Connected`, "debug"); const connectedDev = this.localDevices[duid]; if (connectedDev) { connectedDev.staleSince = undefined; connectedDev.lastSeenAt = Date.now(); } this.cloudDevices.delete(duid); } /** Sends app-style PINGREQ frames so the socket session stays alive. */ startTcpKeepaliveInterval(): void { if (this.tcpKeepaliveInterval) return; this.tcpKeepaliveInterval = this.adapter.setInterval(() => { for (const duid of Object.keys(this.deviceSockets)) { this.checkTcpActivity(duid); } }, local_api.TCP_KEEPALIVE_CHECK_MS); } private checkTcpActivity(duid: string): void { const client = this.deviceSockets[duid]; if (!client?.connected || !this.isConnected(duid)) return; const version = this.getLocalProtocolVersion(duid); if (version !== "1.0" && version !== "L01") return; const now = Date.now(); const lastInbound = client.lastReceivedAt ?? client.lastSentAt ?? now; const lastOutbound = client.lastSentAt ?? client.lastReceivedAt ?? now; const inboundIdleMs = now - lastInbound; const outboundIdleMs = now - lastOutbound; const pingOutstanding = client.pingOutstanding ?? 0; const pingDueMs = local_api.TCP_KEEPALIVE_MS - local_api.TCP_KEEPALIVE_GRACE_MS; const lastPingAgeMs = client.lastPingAt ? now - client.lastPingAt : 0; if (pingOutstanding > 0) { const pingTimeoutMs = client.lastPingAt ? lastPingAgeMs : inboundIdleMs; if (pingTimeoutMs >= local_api.TCP_KEEPALIVE_MS) { this.adapter.rLog("TCP", duid, "Warn", version, undefined, `keepalive timeout | pingOutstanding=${pingOutstanding} | pingTimeout=${pingTimeoutMs}ms | inboundIdle=${inboundIdleMs}ms | outboundIdle=${outboundIdleMs}ms | lastPingAgo=${lastPingAgeMs}ms`, "warn"); this.scheduleReconnect(duid, "keepalive timeout", false); } return; } if (pingOutstanding === 0 && outboundIdleMs >= 2 * local_api.TCP_KEEPALIVE_MS) { this.adapter.rLog("TCP", duid, "Warn", version, undefined, `keepalive timeout | reason=no write activity | inboundIdle=${inboundIdleMs}ms | outboundIdle=${outboundIdleMs}ms`, "warn"); this.scheduleReconnect(duid, "keepalive write timeout", false); return; } if (inboundIdleMs < pingDueMs && outboundIdleMs < pingDueMs) return; this.sendPing(duid); } stopTcpKeepaliveInterval(): void { if (this.tcpKeepaliveInterval) { this.adapter.clearInterval(this.tcpKeepaliveInterval); this.tcpKeepaliveInterval = undefined; } } private resetDeviceSocket(duid: string, reason: string, rejectPendingRequests = true): void { if (rejectPendingRequests) { this.adapter.requestsHandler?.rejectPendingTcpRequests?.(duid, reason); } const old = this.deviceSockets[duid]; if (old) { old.removeAllListeners(); if (!old.destroyed) old.destroy(); delete this.deviceSockets[duid]; } } private isEndpointRefreshError(error: unknown): boolean { const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; const message = this.adapter.errorMessage(error); return code === "EHOSTUNREACH" || code === "ENETUNREACH" || code === "ETIMEDOUT" || code === "ECONNREFUSED" || message.includes("TCP connect timeout") || message.includes("EHOSTUNREACH") || message.includes("ENETUNREACH") || message.includes("ETIMEDOUT") || message.includes("ECONNREFUSED"); } private markEndpointStale(duid: string, reason: string): void { const dev = this.localDevices[duid]; if (dev) { dev.staleSince = dev.staleSince ?? Date.now(); dev.connectNonce = undefined; dev.ackNonce = undefined; } this.resetDeviceSocket(duid, reason); this.refreshEndpoint(duid, reason).catch((e: unknown) => { this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `Endpoint refresh failed after ${reason}: ${this.adapter.errorMessage(e)}`, "debug"); }); } /** Schedules reconnect in 5s. */ scheduleReconnect(duid: string, reason: string, silent = false): void { this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `TCP ${reason} for ${duid}, retry in 5s`, "debug"); this.resetDeviceSocket(duid, reason); if (this.reconnectPlanned.has(duid)) return; this.reconnectPlanned.add(duid); this.adapter.setTimeout(() => { this.reconnectPlanned.delete(duid); // Retry only if device is still considered local if (this.localDevices[duid]?.staleSince) { this.refreshEndpoint(duid, reason).catch((e: unknown) => this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `Endpoint refresh failed: ${this.adapter.errorMessage(e)}`, "debug")); } else if (this.getIpForDuid(duid)) { this.initiateClient(duid, silent).catch((e) => this.adapter.rLog("TCP", duid, "Error", undefined, undefined, `Reconnect failed: ${e?.message || e}`, silent ? "debug" : "warn")); } else if (!silent) { this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `Skip reconnect: no longer in localDevices`, "debug"); } }, 5000); } /** * Checks if an IP is reachable via ICMP Ping. */ async isLocallyReachable(ip: string): Promise<boolean> { const res = await ping.promise.probe(ip, { timeout: 2 }); return res.alive; } /** * Checks if the buffer contains a complete message (or multiple complete messages). */ checkComplete(buffer: Buffer): boolean { let offset = 0; if (buffer.length < 4) { return false; } while (offset + 4 <= buffer.length) { const segmentLength = buffer.readUInt32BE(offset); const nextOffset = offset + 4 + segmentLength; if (nextOffset > buffer.length) { return false; // Data implies more bytes than available } offset = nextOffset; } return offset === buffer.length; } clearChunkBuffer(duid: string): void { if (this.deviceSockets[duid]) { this.deviceSockets[duid].chunkBuffer = Buffer.alloc(0); } } private waitForSessionAck(duid: string, timeoutMs: number, version: string): Promise<void> { if (this.localDevices[duid]?.ackNonce !== undefined) { return Promise.resolve(); } const existingWaiter = this.sessionAckWaiters.get(duid); if (existingWaiter) { clearTimeout(existingWaiter.timer); existingWaiter.reject(new Error(`TCP session handshake superseded for ${duid}`)); this.sessionAckWaiters.delete(duid); } return new Promise<void>((resolve, reject) => { const timer = setTimeout(() => { this.sessionAckWaiters.delete(duid); reject(new Error(`TCP session handshake timeout for ${duid} (${version})`)); }, timeoutMs); this.sessionAckWaiters.set(duid, { resolve: () => { clearTimeout(timer); this.sessionAckWaiters.delete(duid); resolve(); }, reject, timer, }); }); } private resolveSessionAck(duid: string): void { const waiter = this.sessionAckWaiters.get(duid); if (waiter) { waiter.resolve(); } } private rejectSessionAck(duid: string, error: Error): void { const waiter = this.sessionAckWaiters.get(duid); if (waiter) { clearTimeout(waiter.timer); this.sessionAckWaiters.delete(duid); waiter.reject(error); } } sendMessage(duid: string, message: Buffer): boolean { const client = this.deviceSockets[duid]; if (client?.connected && !client.destroyed && client.writable) { try { client.write(message, (error?: Error | null) => { if (error) { this.scheduleReconnect(duid, `write failed: ${error.message}`); } }); } catch (error: unknown) { this.scheduleReconnect(duid, `write failed: ${this.adapter.errorMessage(error)}`); return false; } client.lastSentAt = Date.now(); client.sentBytes += message.length; return true; } return false; } isConnected(duid: string): boolean { if (this.deviceSockets[duid] && this.deviceSockets[duid].connected) { const dev = this.localDevices[duid]; if (dev && (dev.version === "1.0" || dev.version === "L01")) { return dev.ackNonce !== undefined; } // For B01 or generic TCP - connected socket is enough return true; } return false; } private resolveLocalProtocol4Payload(duid: string, version: string, protocol: number, parsedPayload: any): void { const dps = parsedPayload?.dps; let content: any = null; if (dps && typeof dps === "object" && !Array.isArray(dps)) { content = dps["102"] ?? dps["101"] ?? dps; } if (content == null && parsedPayload && typeof parsedPayload.id !== "undefined") { content = parsedPayload; } if (typeof content === "string") { try { content = JSON.parse(content); } catch (e) { this.adapter.rLog("TCP", duid, "Error", version, protocol, `Nested JSON Parse Error | ${e}`, "warn"); return; } } if (!content || typeof content !== "object" || typeof content.id === "undefined") return; const id = Number(content.id); if (!Number.isFinite(id)) return; const result = Object.prototype.hasOwnProperty.call(content, "result") ? content.result : content.error; this.adapter.requestsHandler.resolvePendingRequest(id, result, String(protocol), duid, "TCP"); } private normalizeNetworkInfo(result: unknown): Record<string, unknown> | null { const value = Array.isArray(result) ? result[0] : result; if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record<string, unknown>; const nested = record.data ?? record.result; if (nested && typeof nested === "object" && !Array.isArray(nested)) { return nested as Record<string, unknown>; } return record; } private extractIpFromNetworkInfo(result: unknown): string | null { const info = this.normalizeNetworkInfo(result); if (!info) return null; const candidate = info.ip ?? info.ipAddress ?? info.ipAdress; if (typeof candidate !== "string") return null; const ip = candidate.trim(); return isIP(ip) !== 0 ? ip : null; } private connectLocalEndpointIfDue(duid: string, timeoutMs = 5000, minIntervalMs = local_api.STALE_ENDPOINT_CONNECT_MIN_INTERVAL_MS): void { if (this.isConnected(duid)) return; const dev = this.localDevices[duid]; if (!dev) return; const now = Date.now(); if (dev.lastConnectAttemptAt && now - dev.lastConnectAttemptAt < minIntervalMs) return; dev.lastConnectAttemptAt = now; this.initiateClient(duid, true, timeoutMs).catch((e: unknown) => { this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `Local endpoint probe failed for ${dev.ip}: ${this.adapter.errorMessage(e)}`, "debug"); }); } public updateLocalEndpoint(duid: string, ip: string, version: string, source: LocalEndpointSource = "network_info"): boolean { if (!duid || typeof duid !== "string" || !ip || isIP(ip) === 0 || !version) return false; const now = Date.now(); const existing = this.localDevices[duid]; if (!existing) { this.localDevices[duid] = { ip, version, lastSeenAt: now, endpointSource: source, }; const logConnection = source === "udp" ? "UDP" : "TCP"; this.adapter.rLog(logConnection, duid, "Info", version, undefined, `Local endpoint discovered at ${ip} via ${source}.`, "debug"); this.connectLocalEndpointIfDue(duid, 5000, 0); return true; } const oldIp = existing.ip; const ipChanged = oldIp !== ip; const versionChanged = existing.version !== version; existing.ip = ip; existing.version = version; existing.lastSeenAt = now; existing.endpointSource = source; if (ipChanged || versionChanged) { existing.connectNonce = undefined; existing.ackNonce = undefined; } if (ipChanged) { existing.staleSince = undefined; this.reconnectPlanned.delete(duid); this.adapter.rLog("TCP", duid, "Info", version, undefined, `Local endpoint changed from ${oldIp} to ${ip} via ${source}. Reconnecting TCP.`, "info"); this.resetDeviceSocket(duid, `local endpoint changed from ${oldIp} to ${ip}`); this.connectLocalEndpointIfDue(duid, 5000, 0); return true; } if (existing.staleSince || !this.isConnected(duid)) { this.connectLocalEndpointIfDue(duid); } return false; } public async refreshEndpoint(duid: string, reason = "endpoint refresh", force = false): Promise<boolean> { const existingRefresh = this.endpointRefreshPromises.get(duid); if (existingRefresh) return existingRefresh; const now = Date.now(); const lastStartedAt = this.endpointRefreshLastStartedAt.get(duid) ?? 0; if (!force && now - lastStartedAt < local_api.ENDPOINT_REFRESH_MIN_INTERVAL_MS) { return false; } const promise = this.refreshEndpointInternal(duid, reason) .then((refreshed) => { if (!refreshed && (!this.adapter.requestsHandler?.sendRequest || !this.adapter.mqtt_api?.isConnected?.())) { this.endpointRefreshLastStartedAt.delete(duid); } return refreshed; }) .finally(() => { this.endpointRefreshPromises.delete(duid); }); this.endpointRefreshLastStartedAt.set(duid, now); this.endpointRefreshPromises.set(duid, promise); return promise; } private async refreshEndpointInternal(duid: string, reason: string): Promise<boolean> { if (!this.adapter.requestsHandler?.sendRequest || !this.adapter.mqtt_api?.isConnected?.()) { this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `Skipping endpoint refresh after ${reason}: MQTT unavailable.`, "debug"); return false; } const version = await this.adapter.getDeviceProtocolVersion(duid); const method = version === "B01" ? "service.get_net_info" : "get_network_info"; const params = version === "B01" ? {} : []; let result: unknown; try { result = await this.adapter.requestsHandler.sendRequest(duid, method, params, { priority: -10, timeout: 5000 }); } catch (e: unknown) { this.adapter.rLog("TCP", duid, "Debug", version, undefined, `Endpoint refresh ${method} failed after ${reason}: ${this.adapter.errorMessage(e)}`, "debug"); return false; } const ip = this.extractIpFromNetworkInfo(result); if (!ip) { this.adapter.rLog("TCP", duid, "Debug", version, undefined, `Endpoint refresh ${method} returned no usable IP.`, "debug"); return false; } const changed = this.updateLocalEndpoint(duid, ip, version, "network_info"); this.adapter.rLog("TCP", duid, "Debug", version, undefined, `Endpoint refresh resolved ${ip}${changed ? " (updated)" : ""}.`, "debug"); return true; } public async refreshStaleLocalEndpoints(reason = "scheduled endpoint refresh"): Promise<void> { const duids = Object.keys(this.localDevices).filter((duid) => { const dev = this.localDevices[duid]; return !!dev?.ip && !this.isConnected(duid); }); await Promise.all(duids.map((duid) => this.refreshEndpoint(duid, reason).catch((e: unknown) => { this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `Scheduled endpoint refresh failed: ${this.adapter.errorMessage(e)}`, "debug"); return false; }))); } /** * Starts listening for UDP broadcast packets to discover devices. * @see test/unit/transport_specification.test.ts for the UDP discovery protocol. */ async startUdpDiscovery(timeoutMs = 10_000): Promise<void> { this.discoveryStopping = false; this.ensureUdpDiscoveryServer(); if (timeoutMs <= 0) return; if (this.discoveryWindowPromise) return this.discoveryWindowPromise; this.discoveryWindowPromise = this.waitForUdpDiscoveryWindow(timeoutMs) .finally(() => { this.discoveryWindowPromise = null; }); return this.discoveryWindowPromise; } private ensureUdpDiscoveryServer(): void { if (this.discoveryServer) return; const socketOptions: dgram.SocketOptions = process.platform === "win32" ? { type: "udp4", reuseAddr: true } : { type: "udp4", reusePort: true }; const server = dgram.createSocket(socketOptions); this.discoveryServer = server; server.on("message", (msg) => this.handleUdpDiscoveryMessage(msg)); server.on("listening", () => { const addr = server.address(); const ownedDevices = this.getExpectedOwnedDiscoveryDuids(); this.adapter.rLog("UDP", null, "Info", undefined, undefined, `UDP listening on ${addr.address}:${addr.port} (expecting ${ownedDevices.length} online owned devices).`, "info"); }); server.on("error", (error) => { this.adapter.rLog("UDP", null, "Warn", "N/A", undefined, `Discovery socket error: ${this.adapter.errorMessage(error)}. Restarting listener.`, "warn"); if (this.discoveryServer === server) { this.discoveryServer = null; } try { server.close(); } catch { // ignore close errors } this.scheduleUdpDiscoveryRestart("socket error"); }); server.on("close", () => { if (this.discoveryServer === server) { this.discoveryServer = null; } if (!this.discoveryStopping) { this.scheduleUdpDiscoveryRestart("socket closed"); } }); try { server.bind(UDP_DISCOVERY_PORT); } catch (e: unknown) { this.adapter.rLog("UDP", null, "Warn", "N/A", undefined, `Failed to bind UDP discovery port: ${this.adapter.errorMessage(e)}. Restarting listener.`, "warn"); if (this.discoveryServer === server) { this.discoveryServer = null; } try { server.close(); } catch { // ignore close errors } this.scheduleUdpDiscoveryRestart("bind failed"); } } private scheduleUdpDiscoveryRestart(reason: string): void { if (this.discoveryStopping || this.discoveryRestartTimer) return; this.discoveryRestartTimer = this.adapter.setTimeout(() => { this.discoveryRestartTimer = null; if (this.discoveryStopping) return; this.adapter.rLog("UDP", null, "Debug", undefined, undefined, `Restarting UDP discovery listener after ${reason}.`, "debug"); this.ensureUdpDiscoveryServer(); }, local_api.UDP_DISCOVERY_RESTART_MS) as unknown as NodeJS.Timeout; } private handleUdpDiscoveryMessage(msg: Buffer): void { let decodedMessage: string | null = null; let parsedMessage: any; // Structure depends on version try { const version = versionParser.parse(msg).version; switch (version) { case "L01": parsedMessage = vL01_Parser.parse(msg.subarray(3)); decodedMessage = this.decryptGCM(msg.toString("hex")); break; case "B01": // Try L01 (GCM) first try { parsedMessage = vL01_Parser.parse(msg.subarray(3)); decodedMessage = this.decryptGCM(msg.toString("hex")); } catch { /* ignore */ } if (!decodedMessage) { // Fallback to 1.0 (ECB) try { parsedMessage = v1_0_Parser.parse(msg.subarray(3)); decodedMessage = this.decryptECB(parsedMessage.payload); } catch { this.adapter.rLog("UDP", null, "Debug", "B01", undefined, `B01 discovery decryption failed for both GCM and ECB`, "debug"); } } break; case "1.0": parsedMessage = v1_0_Parser.parse(msg.subarray(3)); decodedMessage = this.decryptECB(parsedMessage.payload); break; default: this.adapter.rLog("UDP", null, "Warn", version, undefined, `Unknown protocol version "${version}" found in local discovery packet.`, "warn"); } if (!decodedMessage) return; const parsedDecodedMessage = JSON.parse(decodedMessage); if (!parsedDecodedMessage || typeof parsedDecodedMessage !== "object") return; const duid = parsedDecodedMessage.duid; const ip = parsedDecodedMessage.ip; if (typeof duid !== "string" || typeof ip !== "string") return; const localKeys = this.adapter.http_api.getMatchedLocalKeys(); const localKey = localKeys.get(duid); // Only track devices we have a key for if (!localKey) return; this.updateLocalEndpoint(duid, ip, version, "udp"); } catch (error: unknown) { this.adapter.rLog("UDP", null, "Error", "N/A", undefined, `Failed to process UDP message: ${this.adapter.errorStack(error)}`, "warn"); } } private getExpectedOwnedDiscoveryDuids(): string[] { const allDevices = this.adapter.http_api.getDevices(); return allDevices .filter((d) => d.online !== false && !this.adapter.http_api.isSharedDevice(d.duid)) .map((d) => d.duid); } private getFreshDiscoveryDuids(startedAt: number, onlyOwned = false): string[] { return Object.keys(this.localDevices).filter((duid) => { const dev = this.localDevices[duid]; if (!dev?.lastSeenAt || dev.lastSeenAt < startedAt) return false; return !onlyOwned || !this.adapter.http_api.isSharedDevice(duid); }); } private waitForUdpDiscoveryWindow(timeoutMs: number): Promise<void> { const server = this.discoveryServer; const startedAt = Date.now(); const expectedOwnedDuids = new Set(this.getExpectedOwnedDiscoveryDuids()); if (expectedOwnedDuids.size === 0) { this.adapter.rLog("UDP", null, "Debug", undefined, undefined, "UDP discovery listener is active; no online owned devices to wait for.", "debug"); return Promise.resolve(); } return new Promise<void>((resolve) => { let resolved = false; const cleanup = () => { if (this.discoveryTimer) { clearTimeout(this.discoveryTimer); this.discoveryTimer = null; } if (this.gracePeriodTimer) { clearTimeout(this.gracePeriodTimer); this.gracePeriodTimer = null; } server?.removeListener("message", onMessage); this.resolveDiscoveryWindow = null; }; const finish = (finishReason: string) => { if (resolved) return; resolved = true; cleanup(); const freshDuids = this.getFreshDiscoveryDuids(startedAt); this.adapter.rLog("UDP", null, "Info", undefined, undefined, `UDP discovery window finished (${finishReason}). Fresh devices: [${freshDuids.join(", ")}]. Listener remains active.`, "info"); resolve(); }; const checkFinished = () => { const freshOwnedCount = this.getFreshDiscoveryDuids(startedAt, true) .filter((duid) => expectedOwnedDuids.has(duid)) .length; if (freshOwnedCount < expectedOwnedDuids.size || this.gracePeriodTimer) return; if (this.discoveryTimer) { clearTimeout(this.discoveryTimer); this.discoveryTimer = null; } this.gracePeriodTimer = setTimeout(() => { finish("all owned devices seen"); }, 1500); }; const onMessage = () => { setImmediate(checkFinished); }; this.resolveDiscoveryWindow = () => finish("stopped"); server?.on("message", onMessage); this.discoveryTimer = setTimeout(() => { finish("timeout"); }, timeoutMs); checkFinished(); }); } stopUdpDiscovery(): void { this.discoveryStopping = true; this.resolveDiscoveryWindow?.(); if (this.discoveryTimer) { clearTimeout(this.discoveryTimer); this.discoveryTimer = null; } if (this.gracePeriodTimer) { clearTimeout(this.gracePeriodTimer); this.gracePeriodTimer = null; } if (this.discoveryRestartTimer) { clearTimeout(this.discoveryRestartTimer); this.discoveryRestartTimer = null; } if (this.discoveryServer) { try { this.discoveryServer.removeAllListeners(); this.discoveryServer.close(); } catch { // ignore close errors } this.discoveryServer = null; } } /** * Initializes the local socket session before PUBLISH frames are sent. */ async initHandshake(duid: string, version: string): Promise<void> { const dev = this.localDevices[duid]; if (!dev) { this.adapter.rLog("TCP", duid, "Warn", undefined, undefined, "initHandshake: no local device data found", "warn"); return; } try { const connectNonce = nextSocketRandom(); dev.connectNonce = connectNonce; dev.ackNonce = undefined; // Reset for new handshake this.adapter.requestsHandler.messageParser.resetTransportSequence(duid); await this.sendHello(duid, connectNonce, version); } catch (err: unknown) { this.adapter.rLog("TCP", duid, "Error", version, undefined, `initHandshake failed for ${duid}: ${this.adapter.errorMessage(err)}`, "warn"); } } /** * Probes a device at a specific IP via TCP. * If successful, promotes it to a Local Device. */ async checkAndPromoteLocalConnection(duid: string, ip: string, timeoutMs = 5000, suppressLog = false): Promise<boolean> { if (this.isConnected(duid)) return true; // Register temporarily to allow initiateClient to work if (!this.localDevices[duid]) { // Fetch protocol version (mapped from cloud pv) const version = await this.adapter.getDeviceProtocolVersion(duid); this.localDevices[duid] = { ip: ip, version: version, lastSeenAt: Date.now(), endpointSource: "probe", } as LocalDevice; } else { if (this.localDevices[duid].ip !== ip) { this.localDevices[duid].ip = ip; this.localDevices[duid].connectNonce = undefined; this.localDevices[duid].ackNonce = undefined; } this.localDevices[duid].lastSeenAt = Date.now(); this.localDevices[duid].endpointSource = "probe"; } try { await this.initiateClient(duid, suppressLog, timeoutMs); if (this.isConnected(duid)) { this.adapter.rLog("TCP", duid, "Info", "TCP", undefined, `Network Probe success! Device ${duid} is reachable at ${ip}. Promoted to Local Control.`, "info"); return true; } // Check if we at least have a connected socket (waiting for L01 handshake) const socket = this.deviceSockets[duid]; if (socket && socket.connected) { this.adapter.rLog("TCP", duid, "Debug", "TCP", undefined, `Connection initiated but not yet fully confirmed (Handshake pending). Keeping ${ip} as candidate.`, "debug"); return false; } // If we are here, TCP failed hard (timeout/refused), but initiateClient swallowed the error. // We must clean up to prevent infinite reconnect loops. throw new Error("TCP Connection failed (No socket established)"); } catch (e: unknown) { // Probe failed - cleanup temporary registration AND cancel any scheduled reconnects delete this.localDevices[duid]; this.adapter.rLog("TCP", duid, "Debug", undefined, undefined, `Network Probe failed for ${ip}: ${this.adapter.errorMessage(e)} (Cloud Fallback)`, "debug"); return false; } } /** App-style CONNECT packet, once per TCP socket session. */ async sendHello(duid: string, connectNonce: number, version: string): Promise<void> { const keepAliveSeconds = 10; const protocol = 0; // SocketFrameType.CONNECT // Match the app socket CONNECT frame: 17-byte header + 4-byte keepalive. // PUBLISH frames carry payload length and CRC, CONNECT/CONNACK do not. const payload = Buffer.alloc(4); payload.writeUInt32BE(keepAliveSeconds, 0); const wrapped = this.buildControlFrame(version, 0, connectNonce, 0, protocol, payload); this.sendMessage(duid, wrapped); this.adapter.rLog("TCP", duid, "->", version, 0, `connect | connectNonce=${connectNonce} | keepAlive=${keepAliveSeconds}s`, "debug"); } sendPing(duid: string): void { const version = this.getLocalProtocolVersion(duid); if (version !== "1.0" && version !== "L01") return; const client = this.deviceSockets[duid]; if (!client?.connected) return; this.sendMessage(duid, this.buildControlFrame(version, 0, 0, 0, 2)); client.lastPingAt = Date.now(); client.pingOutstanding = (client.pingOutstanding ?? 0) + 1; this.adapter.rLog("TCP", duid, "->", version, 2, "pingreq", "debug"); } private sendPubAck(duid: string, messageId: number, version: string): void { this.sendMessage(duid, this.buildControlFrame(version, messageId, 0, 0, 5)); this.adapter.rLog("TCP", duid, "->", version, 5, `puback | tcpMsgId=${messageId}`, "debug"); } private buildControlFrame(version: string, messageId: number, randomValue: number, timestamp: number, protocol: number, payload = Buffer.alloc(0)): Buffer { const msg = Buffer.alloc(17 + payload.length); msg.write(version); msg.writeUInt32BE(messageId >>> 0, 3); msg.writeUInt32BE(randomValue >>> 0, 7); msg.writeUInt32BE(timestamp >>> 0, 11); msg.writeUInt16BE(protocol, 15); payload.copy(msg, 17); const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32BE(msg.length, 0); return Buffer.concat([lenBuf, msg] as Uint8Array[]); } isLocalDevice(duid: string): boolean { return duid in this.deviceSockets; } getIpForDuid(duid: string): string | null { return this.localDevices?.[duid]?.ip || null; } getLocalProtocolVersion(duid: string): string | null { return this.localDevices?.[duid]?.version || null; } // -------------------- // Decryption Helpers (Discovery Specific) // -------------------- /** * Decrypts AES-128-ECB packets (Protocol 1.0 Discovery). */ decryptECB(encrypted: Buffer | string): string { const input = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted, "binary"); const decipher = crypto.createDecipheriv("aes-128-ecb", BROADCAST_TOKEN, null); decipher.setAutoPadding(false); try { let decrypted = decipher.update(input); decrypted = Buffer.concat([decrypted, decipher.final()]); return this.removePadding(decrypted.toString("utf8")); } catch (e: unknown) { // Log warning instead of error to avoid spamming if it's just a bad packet this.adapter.rLog("UDP", null, "Warn", "N/A", undefined, `Failed to decrypt packet (ECB): ${this.adapter.errorMessage(e)}`, "warn"); return ""; } } /** * Decrypts AES-256-GCM packets (Protocol L01 Discovery). */ decryptGCM(hexPacket: string): string | null { const packet = Buffer.from(hexPacket, "hex"); if (packet.length < 15) { this.adapter.rLog("UDP", null, "Error", "N/A", undefined, "GCM Payload too small", "error"); return null; } // Validate CRC32 const crcFromPacket = packet.readUInt32BE(packet.length - 4); const packetWithoutCrc = packet.subarray(0, packet.length - 4); if (crc32.buf(packetWithoutCrc) >>> 0 !== crcFromPacket) { this.adapter.rLog("UDP", null, "Error", "N/A", undefined, "CRC validation failed", "error"); return null; } // Extract GCM components const payloadLength = packet.readUInt16BE(9); const payload = packet.subarray(11, 11 + payloadLength); // Key derivation for discovery is fixed to SHA256 of the BROADCAST_TOKEN const key = crypto.createHash("sha256").update(BROADCAST_TOKEN).digest(); const digestInput = packet.subarray(0, 9); const digest = crypto.createHash("sha256").update(digestInput).digest(); const iv = digest.subarray(0, 12); const tag = payload.subarray(payload.length - 16); const ciphertext = payload.subarray(0, payload.length - 16); const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); try { const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return decrypted.toString("utf8"); } catch (e: unknown) { this.adapter.rLog("UDP", null, "Error", "N/A", undefined, `Failed to decrypt! Error: ${this.adapter.errorMessage(e)} IV: ${iv.toString("hex")} Tag: ${tag.toString("hex")} Encrypted: ${ciphertext.toString("hex")}`, "error"); return null; } } /** * Manually removes padding (Legacy support). */ removePadding(str: string): string { const paddingLength = str.charCodeAt(str.length - 1); return str.slice(0, -paddingLength); } clearLocalDevicedTimeout(): void { if (this.localDevicesInterval) { this.adapter.clearTimeout(this.localDevicesInterval as any); this.localDevicesInterval = null; } } }