Add support for Shelly device generation detection and update related tests
- Implement generation detection logic for Shelly devices using model codes, metadata, and type inference. - Extend relay switch and inventory handling to include generation capabilities. - Ensure compatibility with Gen1, Gen2, and Gen3 devices for relay control and diagnostics. - Update unit tests to validate generation inference, fallback behavior, and API compatibility.
This commit is contained in:
Vendored
+161
-32
@@ -145,6 +145,8 @@ function buildTransportHeartbeatState(brokerState = {}) {
|
||||
broker_last_connected_at: brokerState.lastConnectedAt || null,
|
||||
broker_last_disconnected_at: brokerState.lastDisconnectedAt || null,
|
||||
broker_disconnect_reason: brokerState.disconnectReason || null,
|
||||
broker_last_close_code: brokerState.lastCloseCode ?? null,
|
||||
broker_last_close_clean: brokerState.lastCloseClean ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -179,6 +181,30 @@ function buildBrokerSocketUrl(brokerUrl, gatewayId, token) {
|
||||
return `${baseUrl}/ws/agent?${search.toString()}`;
|
||||
}
|
||||
|
||||
function redactBrokerSocketUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || ""));
|
||||
for (const key of ["token", "agentToken", "agent_token"]) {
|
||||
if (parsed.searchParams.has(key)) {
|
||||
parsed.searchParams.set(key, "***");
|
||||
}
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return String(value || "").replace(/([?&](?:token|agentToken|agent_token)=)[^&]+/gi, "$1***");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBrokerSocketError(error, socketUrl) {
|
||||
const message = error instanceof Error && error.message
|
||||
? error.message
|
||||
: error?.message
|
||||
? String(error.message)
|
||||
: "Broker connection failed";
|
||||
const redactedUrl = redactBrokerSocketUrl(socketUrl);
|
||||
return redactedUrl ? `${message} (${redactedUrl})` : message;
|
||||
}
|
||||
|
||||
async function readSocketMessageText(data) {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
@@ -509,6 +535,90 @@ function expandCandidateIps(options = {}) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeShellyDeviceGeneration(value) {
|
||||
if (Number.isInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
return inferShellyDeviceGenerationFromString(value);
|
||||
}
|
||||
|
||||
function inferShellyDeviceGenerationFromString(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicit = normalized.match(/\bgen(?:eration)?\s*([1-9]\d*)\b/i);
|
||||
if (explicit) {
|
||||
return Number(explicit[1]);
|
||||
}
|
||||
|
||||
const upper = normalized.toUpperCase();
|
||||
const sSeries = upper.match(/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/);
|
||||
if (sSeries) {
|
||||
return Number(sSeries[1]);
|
||||
}
|
||||
|
||||
if (/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i.test(normalized) || /\bSP[A-Z0-9]+-[A-Z0-9-]+\b/.test(upper)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/.test(upper)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveShellyDeviceGeneration(identity = {}) {
|
||||
for (const candidate of [
|
||||
identity.gen,
|
||||
identity.generation,
|
||||
identity.device_generation,
|
||||
identity.capabilities?.generation,
|
||||
]) {
|
||||
const generation = normalizeShellyDeviceGeneration(candidate);
|
||||
if (generation !== null) {
|
||||
return generation;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of [
|
||||
identity.model,
|
||||
identity.type,
|
||||
identity.app,
|
||||
identity.name,
|
||||
identity.id,
|
||||
identity.mac,
|
||||
]) {
|
||||
const generation = inferShellyDeviceGenerationFromString(candidate);
|
||||
if (generation !== null) {
|
||||
return generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveShellyCommandGeneration(payload = {}) {
|
||||
return resolveShellyDeviceGeneration({
|
||||
gen: payload.gen ?? payload.generation ?? payload.deviceGeneration ?? payload.device_generation,
|
||||
device_generation: payload.device_generation,
|
||||
capabilities: payload.capabilities,
|
||||
model: payload.model ?? payload.deviceModel ?? payload.device_model ?? payload.deviceType ?? payload.device_type,
|
||||
type: payload.type ?? payload.deviceType ?? payload.device_type,
|
||||
app: payload.app,
|
||||
name: payload.name ?? payload.deviceName ?? payload.device_name,
|
||||
id: payload.deviceId ?? payload.device_id ?? payload.relayId ?? payload.relay_id,
|
||||
mac: payload.mac,
|
||||
});
|
||||
}
|
||||
|
||||
export async function discoverShellyDevices(options = {}, fetchImpl = fetch) {
|
||||
const candidateIps = expandCandidateIps(options);
|
||||
const discovered = [];
|
||||
@@ -526,7 +636,7 @@ export async function discoverShellyDevices(options = {}, fetchImpl = fetch) {
|
||||
channel_count: Number(identity.num_outputs || identity.num_switches || 1),
|
||||
online: true,
|
||||
capabilities: {
|
||||
generation: identity.gen || null,
|
||||
generation: resolveShellyDeviceGeneration(identity),
|
||||
},
|
||||
metadata: identity,
|
||||
});
|
||||
@@ -587,48 +697,55 @@ export async function setRelayState(payload, fetchImpl = fetch) {
|
||||
const toggleAfter = resolveRelayToggleAfterSeconds(payload);
|
||||
const timerQuery = toggleAfter === null ? "" : `&toggle_after=${encodeURIComponent(String(toggleAfter))}`;
|
||||
const legacyTimerQuery = toggleAfter === null ? "" : `&timer=${encodeURIComponent(String(toggleAfter))}`;
|
||||
const deviceGeneration = resolveShellyCommandGeneration(payload);
|
||||
if (!ip) {
|
||||
throw new Error("Missing relay local IP");
|
||||
}
|
||||
|
||||
if (toggleAfter !== null) {
|
||||
try {
|
||||
const legacy = await fetchJson(`http://${ip}/relay/${channel}?turn=${on ? "on" : "off"}${legacyTimerQuery}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(legacy.ison ?? legacy.output ?? on),
|
||||
raw: legacy,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isHttpTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const rpc = await fetchJson(`http://${ip}/rpc/Switch.Set?id=${channel}&on=${on ? "true" : "false"}${timerQuery}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(rpc.output ?? on),
|
||||
raw: rpc,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const runRpcSwitch = async () => {
|
||||
const rpc = await fetchJson(`http://${ip}/rpc/Switch.Set?id=${channel}&on=${on ? "true" : "false"}${timerQuery}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(rpc.output ?? on),
|
||||
raw: rpc,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isHttpTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const runLegacySwitch = async () => {
|
||||
const legacy = await fetchJson(`http://${ip}/relay/${channel}?turn=${on ? "on" : "off"}${legacyTimerQuery}`, fetchImpl, { timeoutMs });
|
||||
return {
|
||||
online: true,
|
||||
on: Boolean(legacy.ison ?? legacy.output ?? on),
|
||||
raw: legacy,
|
||||
};
|
||||
};
|
||||
|
||||
if (toggleAfter !== null) {
|
||||
const attempts = deviceGeneration === 1
|
||||
? [runLegacySwitch, runRpcSwitch]
|
||||
: [runRpcSwitch, runLegacySwitch];
|
||||
|
||||
let lastError = null;
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
return await attempt();
|
||||
} catch (error) {
|
||||
if (isHttpTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Unable to switch relay");
|
||||
}
|
||||
|
||||
try {
|
||||
return await runRpcSwitch();
|
||||
} catch (error) {
|
||||
if (isHttpTimeoutError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return await runLegacySwitch();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1227,12 +1344,13 @@ export function createShellBridge(sendMessage, { createPtyProcess = createDefaul
|
||||
sessions.set(sessionId, sessionRecord);
|
||||
sendMessage({ type: "SHELL_OPENED", sessionId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
sendMessage({
|
||||
type: "SHELL_OUTPUT",
|
||||
sessionId,
|
||||
data: `Failed to start root shell: ${error instanceof Error ? error.message : String(error)}\r\n`,
|
||||
data: `Failed to start root shell: ${message}\r\n`,
|
||||
});
|
||||
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1 });
|
||||
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1, reason: "shell_spawn_failed", message });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1567,13 +1685,15 @@ function normalizeShellEvent(message) {
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_EXIT") {
|
||||
const reason = String(message.reason || "agent_exit");
|
||||
return {
|
||||
sessionId: String(sessionId),
|
||||
event: {
|
||||
type: "CLOSED",
|
||||
payload: {
|
||||
code: Number.isFinite(message.code) ? message.code : 0,
|
||||
reason: "agent_exit",
|
||||
reason,
|
||||
message: message.message ? String(message.message) : null,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1704,6 +1824,8 @@ function createBrokerBridge({
|
||||
disconnectReason: null,
|
||||
lastConnectedAt: null,
|
||||
lastDisconnectedAt: null,
|
||||
lastCloseCode: null,
|
||||
lastCloseClean: null,
|
||||
};
|
||||
|
||||
let stopped = false;
|
||||
@@ -1761,6 +1883,8 @@ function createBrokerBridge({
|
||||
state.connected = true;
|
||||
state.lastError = null;
|
||||
state.disconnectReason = null;
|
||||
state.lastCloseCode = null;
|
||||
state.lastCloseClean = null;
|
||||
state.lastConnectedAt = formatUpdateTimestamp();
|
||||
};
|
||||
|
||||
@@ -1814,14 +1938,19 @@ function createBrokerBridge({
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
state.lastError = "Broker connection failed";
|
||||
socket.onerror = (error) => {
|
||||
state.lastError = normalizeBrokerSocketError(error, socketUrl);
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
socket = null;
|
||||
state.connected = false;
|
||||
state.disconnectReason = event.reason || "broker_disconnected";
|
||||
state.lastCloseCode = Number.isFinite(Number(event.code)) ? Number(event.code) : null;
|
||||
state.lastCloseClean = typeof event.wasClean === "boolean" ? event.wasClean : null;
|
||||
if (state.lastCloseCode && state.lastCloseCode !== 1000 && !state.lastError) {
|
||||
state.lastError = `Broker websocket closed with code ${state.lastCloseCode}`;
|
||||
}
|
||||
state.lastDisconnectedAt = formatUpdateTimestamp();
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildStatusReport,
|
||||
claimIfNeeded,
|
||||
createShellBridge,
|
||||
discoverShellyDevices,
|
||||
finalizePendingUpdateOnStartup,
|
||||
getAgentStatus,
|
||||
getRelayStatus,
|
||||
@@ -120,6 +121,29 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
|
||||
assert.equal(switched.on, false);
|
||||
});
|
||||
|
||||
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
|
||||
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
|
||||
assert.equal(String(url), "http://192.168.1.2/shelly");
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
id: "shelly1minig3-e4b3231f6410",
|
||||
mac: "E4B3231F6410",
|
||||
model: "S3SW-001X8EU",
|
||||
type: "Shelly 1 Mini Gen3",
|
||||
num_switches: 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(inventory.length, 1);
|
||||
assert.equal(inventory[0].device_id, "E4B3231F6410");
|
||||
assert.equal(inventory[0].model, "S3SW-001X8EU");
|
||||
assert.equal(inventory[0].capabilities.generation, 3);
|
||||
});
|
||||
|
||||
test("relay status reads use the legacy endpoint when the RPC status endpoint stalls", async () => {
|
||||
const urls = [];
|
||||
const fakeFetch = async (url) => {
|
||||
@@ -181,19 +205,22 @@ test("relay switch commands pass timer values to local Shelly APIs", async () =>
|
||||
};
|
||||
};
|
||||
|
||||
await setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, toggleAfter: 3 }, legacyFetch);
|
||||
await setRelayState({
|
||||
localIp: "10.1.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
toggleAfter: 3,
|
||||
deviceGeneration: 1,
|
||||
}, legacyFetch);
|
||||
|
||||
assert.equal(
|
||||
legacyUrls[0],
|
||||
"http://10.1.0.31/relay/0?turn=on&timer=3"
|
||||
);
|
||||
|
||||
const fallbackUrls = [];
|
||||
const rpcFallbackFetch = async (url) => {
|
||||
fallbackUrls.push(String(url));
|
||||
if (String(url).includes("/relay/")) {
|
||||
throw new Error("Legacy relay endpoint unsupported");
|
||||
}
|
||||
const gen3Urls = [];
|
||||
const gen3Fetch = async (url) => {
|
||||
gen3Urls.push(String(url));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -203,12 +230,46 @@ test("relay switch commands pass timer values to local Shelly APIs", async () =>
|
||||
};
|
||||
};
|
||||
|
||||
await setRelayState({ localIp: "10.1.0.31", channel: 0, on: true, toggle_after: 3 }, rpcFallbackFetch);
|
||||
await setRelayState({
|
||||
localIp: "10.1.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
toggle_after: 3,
|
||||
device_generation: 3,
|
||||
}, gen3Fetch);
|
||||
|
||||
assert.equal(
|
||||
fallbackUrls[1],
|
||||
gen3Urls[0],
|
||||
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3"
|
||||
);
|
||||
|
||||
const fallbackUrls = [];
|
||||
const legacyFallbackFetch = async (url) => {
|
||||
fallbackUrls.push(String(url));
|
||||
if (String(url).includes("/rpc/")) {
|
||||
throw new Error("RPC switch endpoint unsupported");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { ison: true, has_timer: true, timer_duration: 3 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await setRelayState({
|
||||
localIp: "10.1.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
toggle_after: 3,
|
||||
device_model: "S3SW-001X8EU",
|
||||
}, legacyFallbackFetch);
|
||||
|
||||
assert.deepEqual(fallbackUrls, [
|
||||
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3",
|
||||
"http://10.1.0.31/relay/0?turn=on&timer=3",
|
||||
]);
|
||||
});
|
||||
|
||||
test("runUpdate stages a pending verification restart after installing new artifacts", async () => {
|
||||
@@ -800,6 +861,7 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
|
||||
assert.equal(commandResultPosts[0].body.ok, true);
|
||||
assert.equal(Array.isArray(commandResultPosts[0].body.payload.inventory), true);
|
||||
assert.equal(commandResultPosts[0].body.payload.inventory[0].device_id, "AA:BB:CC:DD:EE:FF");
|
||||
assert.equal(commandResultPosts[0].body.payload.inventory[0].capabilities.generation, 2);
|
||||
|
||||
assert.ok(shellCalls.some((call) => call.type === "open"));
|
||||
assert.ok(shellCalls.some((call) => call.type === "close"));
|
||||
|
||||
Reference in New Issue
Block a user