iobroker.roborock
Version:
457 lines (398 loc) • 15.2 kB
text/typescript
import { describe, expect, it } from "vitest";
import { local_api } from "./localApi";
import { messageParser } from "./messageParser";
import { MockAdapter } from "./mock/MockAdapter";
import { RoborockRequest, requestsHandler } from "./requestsHandler";
describe("local_api transport sequence", () => {
it("sends app-style TCP connect without consuming the app-frame sequence", async () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const parser = new messageParser(adapter);
const sentMessages: Buffer[] = [];
adapter.http_api = { getMatchedLocalKeys: () => new Map([[duid, "0011223344556677"]]) };
adapter.local_api = api;
adapter.requestsHandler = { messageParser: parser };
api.sendMessage = (_duid: string, message: Buffer) => {
sentMessages.push(message);
return true;
};
api.localDevices[duid] = {
ip: "127.0.0.1",
version: "L01",
connectNonce: 123456,
ackNonce: 654321,
};
parser.resetTransportSequence(duid);
await api.sendHello(duid, 123456, "L01");
const appFrame = await parser.buildRoborockMessage(
duid,
4,
Math.floor(Date.now() / 1000),
JSON.stringify({ dps: { 101: JSON.stringify({ id: 301, method: "get_status", params: [] }) }, t: 1 }),
"L01"
);
expect(sentMessages).to.have.length(1);
expect(sentMessages[0].readUInt32BE(0)).to.equal(21);
expect(sentMessages[0].readUInt32BE(4 + 3)).to.equal(0);
expect(sentMessages[0].readUInt32BE(4 + 17)).to.equal(10);
expect(appFrame).to.be.instanceOf(Buffer);
expect((appFrame as Buffer).readUInt32BE(3)).to.equal(1);
});
it("sends app-style TCP ping and puback control frames", () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const sentMessages: Buffer[] = [];
api.sendMessage = (_duid: string, message: Buffer) => {
sentMessages.push(message);
return true;
};
api.localDevices[duid] = {
ip: "127.0.0.1",
version: "1.0",
ackNonce: 654321,
};
api.deviceSockets[duid] = {
connected: true,
pingOutstanding: 0,
} as any;
api.sendPing(duid);
(api as any).sendPubAck(duid, 42, "1.0");
expect(sentMessages).to.have.length(2);
expect(sentMessages[0].readUInt32BE(0)).to.equal(17);
expect(sentMessages[0].subarray(4, 7).toString()).to.equal("1.0");
expect(sentMessages[0].readUInt32BE(4 + 3)).to.equal(0);
expect(sentMessages[0].readUInt32BE(4 + 7)).to.equal(0);
expect(sentMessages[0].readUInt32BE(4 + 11)).to.equal(0);
expect(sentMessages[0].readUInt16BE(4 + 15)).to.equal(2);
expect(sentMessages[1].readUInt32BE(0)).to.equal(17);
expect(sentMessages[1].subarray(4, 7).toString()).to.equal("1.0");
expect(sentMessages[1].readUInt32BE(4 + 3)).to.equal(42);
expect(sentMessages[1].readUInt32BE(4 + 7)).to.equal(0);
expect(sentMessages[1].readUInt32BE(4 + 11)).to.equal(0);
expect(sentMessages[1].readUInt16BE(4 + 15)).to.equal(5);
expect((api.deviceSockets[duid] as any).pingOutstanding).to.equal(1);
});
it("sends app-style TCP ping only after inbound or outbound activity is idle", () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const sentMessages: Buffer[] = [];
const now = Date.now();
api.sendMessage = (_duid: string, message: Buffer) => {
sentMessages.push(message);
return true;
};
api.localDevices[duid] = {
ip: "127.0.0.1",
version: "1.0",
ackNonce: 654321,
};
api.deviceSockets[duid] = {
connected: true,
lastReceivedAt: now,
lastSentAt: now,
pingOutstanding: 0,
} as any;
(api as any).checkTcpActivity(duid);
expect(sentMessages).to.have.length(0);
(api.deviceSockets[duid] as any).lastReceivedAt = now - 9_000;
(api.deviceSockets[duid] as any).lastSentAt = now;
(api as any).checkTcpActivity(duid);
expect(sentMessages).to.have.length(1);
expect(sentMessages[0].readUInt16BE(4 + 15)).to.equal(2);
});
it("keeps an outstanding ping open until the ping response deadline", () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
let reconnects = 0;
const sentMessages: Buffer[] = [];
const now = Date.now();
api.sendMessage = (_duid: string, message: Buffer) => {
sentMessages.push(message);
return true;
};
api.scheduleReconnect = () => {
reconnects += 1;
};
api.localDevices[duid] = {
ip: "127.0.0.1",
version: "1.0",
ackNonce: 654321,
};
api.deviceSockets[duid] = {
connected: true,
lastReceivedAt: now - 20_000,
lastSentAt: now - 1_000,
lastPingAt: now - 1_000,
pingOutstanding: 1,
} as any;
(api as any).checkTcpActivity(duid);
expect(reconnects).to.equal(0);
expect(sentMessages).to.have.length(0);
(api.deviceSockets[duid] as any).lastPingAt = now - 11_000;
(api as any).checkTcpActivity(duid);
expect(reconnects).to.equal(1);
expect(sentMessages).to.have.length(0);
});
it("does not send another TCP ping while a previous ping is outstanding", () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
let reconnects = 0;
const sentMessages: Buffer[] = [];
const now = Date.now();
api.sendMessage = (_duid: string, message: Buffer) => {
sentMessages.push(message);
return true;
};
api.scheduleReconnect = () => {
reconnects += 1;
};
api.localDevices[duid] = {
ip: "127.0.0.1",
version: "1.0",
ackNonce: 654321,
};
api.deviceSockets[duid] = {
connected: true,
lastReceivedAt: now - 20_000,
lastSentAt: now - 20_000,
lastPingAt: now - 5_000,
pingOutstanding: 1,
} as any;
(api as any).checkTcpActivity(duid);
expect(reconnects).to.equal(0);
expect(sentMessages).to.have.length(0);
});
it("rejects only pending TCP requests for the reset device", async () => {
const adapter = new MockAdapter() as any;
adapter.setInterval = () => undefined;
const handler = new requestsHandler(adapter);
const api = new local_api(adapter);
const tcpReq = new RoborockRequest(handler, "duid-a", "get_prop", ["get_status"], {} as any, "TestQueue", "1.0");
const mqttReq = new RoborockRequest(handler, "duid-a", "get_prop", ["get_status"], {} as any, "TestQueue", "1.0");
const otherTcpReq = new RoborockRequest(handler, "duid-b", "get_prop", ["get_status"], {} as any, "TestQueue", "1.0");
adapter.requestsHandler = handler;
adapter.local_api = api;
adapter.logLevel = "error";
adapter.setTimeout = () => undefined;
tcpReq.messageID = 1;
tcpReq.sentConnectionType = "TCP";
mqttReq.messageID = 2;
mqttReq.sentConnectionType = "MQTT";
otherTcpReq.messageID = 3;
otherTcpReq.sentConnectionType = "TCP";
adapter.pendingRequests.set(1, tcpReq);
adapter.pendingRequests.set(2, mqttReq);
adapter.pendingRequests.set(3, otherTcpReq);
api.scheduleReconnect("duid-a", "connection error: read ECONNRESET", true);
await expect(tcpReq.promise).rejects.toThrow(/TCP network session reset/);
expect(adapter.pendingRequests.has(1)).to.equal(false);
expect(adapter.pendingRequests.has(2)).to.equal(true);
expect(adapter.pendingRequests.has(3)).to.equal(true);
});
it("resolves local protocol 4 responses from dps 102, dps 101, or direct payloads", () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const resolved: Array<{ id: number; result: unknown; protocol: unknown; connectionType: string }> = [];
adapter.requestsHandler = {
resolvePendingRequest: (id: number, result: unknown, protocol: unknown, _duid: string, connectionType: string) => {
resolved.push({ id, result, protocol, connectionType });
},
};
(api as any).resolveLocalProtocol4Payload(duid, "1.0", 4, { dps: { "102": { id: 301, result: ["ok"] } } });
(api as any).resolveLocalProtocol4Payload(duid, "1.0", 4, { dps: { "101": JSON.stringify({ id: 302, result: ["done"] }) } });
(api as any).resolveLocalProtocol4Payload(duid, "1.0", 4, { id: 303, error: { code: -1 } });
expect(resolved).to.deep.equal([
{ id: 301, result: ["ok"], protocol: "4", connectionType: "TCP" },
{ id: 302, result: ["done"], protocol: "4", connectionType: "TCP" },
{ id: 303, result: { code: -1 }, protocol: "4", connectionType: "TCP" },
]);
});
it("does not treat trailing partial TCP frame bytes as complete", () => {
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const frame = Buffer.alloc(4 + 17);
frame.writeUInt32BE(17, 0);
frame.write("1.0", 4);
frame.writeUInt16BE(3, 4 + 15);
expect(api.checkComplete(frame)).to.equal(true);
expect(api.checkComplete(frame.subarray(0, frame.length - 1))).to.equal(false);
expect(api.checkComplete(Buffer.concat([frame, Buffer.from([0x00, 0x00])]))).to.equal(false);
expect(api.checkComplete(Buffer.from([0x00, 0x00, 0x11]))).to.equal(false);
});
it("merges discovered endpoint changes without dropping other local devices", () => {
const duid = "duid-a";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const attempts: Array<{ duid: string; suppressLog: boolean; timeoutMs: number | undefined }> = [];
let destroyed = false;
adapter.requestsHandler = {
rejectPendingTcpRequests: () => 0,
};
api.initiateClient = async (attemptDuid: string, suppressLog?: boolean, timeoutMs?: number) => {
attempts.push({ duid: attemptDuid, suppressLog: !!suppressLog, timeoutMs });
};
api.localDevices[duid] = {
ip: "10.1.1.81",
version: "1.0",
connectNonce: 1,
ackNonce: 2,
staleSince: 100,
};
api.localDevices["duid-b"] = {
ip: "10.1.1.82",
version: "1.0",
};
api.deviceSockets[duid] = {
connected: true,
destroyed: false,
removeAllListeners: () => {},
destroy: () => {
destroyed = true;
},
} as any;
const changed = api.updateLocalEndpoint(duid, "10.1.1.89", "1.0", "udp");
expect(changed).to.equal(true);
expect(api.localDevices[duid].ip).to.equal("10.1.1.89");
expect(api.localDevices[duid].connectNonce).to.equal(undefined);
expect(api.localDevices[duid].ackNonce).to.equal(undefined);
expect(api.localDevices[duid].staleSince).to.equal(undefined);
expect(api.localDevices["duid-b"].ip).to.equal("10.1.1.82");
expect(api.deviceSockets[duid]).to.equal(undefined);
expect(destroyed).to.equal(true);
expect(attempts).to.deep.equal([{ duid, suppressLog: true, timeoutMs: 5000 }]);
});
it("marks unreachable TCP endpoints stale and triggers endpoint refresh instead of reconnect looping", async () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
let scheduledReconnects = 0;
let refreshes = 0;
adapter.requestsHandler = {
rejectPendingTcpRequests: () => 0,
};
api.localDevices[duid] = {
ip: "10.1.1.81",
version: "1.0",
};
(api as any)._performConnection = async () => {
const err: any = new Error("connect EHOSTUNREACH 10.1.1.81:58867");
err.code = "EHOSTUNREACH";
throw err;
};
api.scheduleReconnect = () => {
scheduledReconnects += 1;
};
api.refreshEndpoint = async () => {
refreshes += 1;
return false;
};
await api.initiateClient(duid);
expect(scheduledReconnects).to.equal(0);
expect(refreshes).to.equal(1);
expect(api.localDevices[duid].staleSince).to.be.a("number");
});
it("clears stale endpoint state after a confirmed local TCP connection", async () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
api.cloudDevices.add(duid);
api.localDevices[duid] = {
ip: "10.1.1.81",
version: "B01",
staleSince: 100,
};
api.deviceSockets[duid] = {
connected: true,
} as any;
await api.initiateClient(duid);
expect(api.localDevices[duid].staleSince).to.equal(undefined);
expect(api.localDevices[duid].lastSeenAt).to.be.a("number");
expect(api.cloudDevices.has(duid)).to.equal(false);
});
it("refreshes stale endpoints from MQTT network info and reconnects to the new IP", async () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const requests: Array<{ method: string; params: unknown }> = [];
const attempts: string[] = [];
adapter.mqtt_api = { isConnected: () => true };
adapter.requestsHandler = {
sendRequest: async (_duid: string, method: string, params: unknown) => {
requests.push({ method, params });
return [{ ip: "10.1.1.89" }];
},
rejectPendingTcpRequests: () => 0,
};
adapter.getDeviceProtocolVersion = async () => "1.0";
api.initiateClient = async (attemptDuid: string) => {
attempts.push(attemptDuid);
};
api.localDevices[duid] = {
ip: "10.1.1.81",
version: "1.0",
staleSince: 100,
};
await expect(api.refreshEndpoint(duid, "test", true)).resolves.to.equal(true);
expect(requests).to.deep.equal([{ method: "get_network_info", params: [] }]);
expect(api.localDevices[duid].ip).to.equal("10.1.1.89");
expect(api.localDevices[duid].staleSince).to.equal(undefined);
expect(attempts).to.deep.equal([duid]);
});
it("uses B01 network info method and accepts ipAdress spelling during endpoint refresh", async () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const requests: Array<{ method: string; params: unknown }> = [];
adapter.mqtt_api = { isConnected: () => true };
adapter.requestsHandler = {
sendRequest: async (_duid: string, method: string, params: unknown) => {
requests.push({ method, params });
return { ipAdress: "10.1.1.90" };
},
rejectPendingTcpRequests: () => 0,
};
adapter.getDeviceProtocolVersion = async () => "B01";
api.initiateClient = async () => {};
api.localDevices[duid] = {
ip: "10.1.1.81",
version: "B01",
staleSince: 100,
};
await expect(api.refreshEndpoint(duid, "test", true)).resolves.to.equal(true);
expect(requests).to.deep.equal([{ method: "service.get_net_info", params: {} }]);
expect(api.localDevices[duid].ip).to.equal("10.1.1.90");
});
it("does not throttle the next endpoint refresh after MQTT was temporarily unavailable", async () => {
const duid = "duid";
const adapter = new MockAdapter() as any;
const api = new local_api(adapter);
const requests: string[] = [];
let mqttConnected = false;
adapter.mqtt_api = { isConnected: () => mqttConnected };
adapter.requestsHandler = {
sendRequest: async (_duid: string, method: string) => {
requests.push(method);
return [{ ip: "10.1.1.91" }];
},
rejectPendingTcpRequests: () => 0,
};
adapter.getDeviceProtocolVersion = async () => "1.0";
api.initiateClient = async () => {};
api.localDevices[duid] = {
ip: "10.1.1.81",
version: "1.0",
staleSince: 100,
};
await expect(api.refreshEndpoint(duid, "mqtt down", false)).resolves.to.equal(false);
mqttConnected = true;
await expect(api.refreshEndpoint(duid, "mqtt back", false)).resolves.to.equal(true);
expect(requests).to.deep.equal(["get_network_info"]);
expect(api.localDevices[duid].ip).to.equal("10.1.1.91");
});
});