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:
Jeppe Bundgaard
2026-04-27 17:40:39 +02:00
parent ae5cb7c65f
commit e93d3f30d2
11 changed files with 1022 additions and 74 deletions
+161 -32
View File
@@ -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();
+71 -9
View File
@@ -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"));