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"));
|
||||
|
||||
+137
-16
@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000;
|
||||
|
||||
function parseJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
@@ -119,11 +121,40 @@ function normalizeErrorMessage(error, fallback = "Connection step failed") {
|
||||
return message || fallback;
|
||||
}
|
||||
|
||||
function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
||||
const statusText = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
500: "Internal Server Error",
|
||||
503: "Service Unavailable",
|
||||
}[statusCode] || "WebSocket Upgrade Rejected";
|
||||
const body = JSON.stringify({
|
||||
ok: false,
|
||||
error_code: errorCode,
|
||||
message,
|
||||
details,
|
||||
});
|
||||
|
||||
socket.end(
|
||||
[
|
||||
`HTTP/1.1 ${statusCode} ${statusText}`,
|
||||
"content-type: application/json; charset=utf-8",
|
||||
`content-length: ${Buffer.byteLength(body)}`,
|
||||
"connection: close",
|
||||
"",
|
||||
body,
|
||||
].join("\r\n")
|
||||
);
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
const shellOpenTimeoutMs = options.shellOpenTimeoutMs ?? DEFAULT_SHELL_OPEN_TIMEOUT_MS;
|
||||
|
||||
const agents = new Map();
|
||||
const pendingCommands = new Map();
|
||||
@@ -147,7 +178,11 @@ export function createBrokerServer(options = {}) {
|
||||
});
|
||||
const json = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(json?.data?.message || json?.message || json?.error || `HTTP ${response.status}`);
|
||||
const error = new Error(json?.data?.message || json?.message || json?.error || `HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.code = json?.data?.error_code || json?.error_code || null;
|
||||
error.details = json?.data?.diagnostics || json?.diagnostics || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return json?.data ?? json;
|
||||
@@ -177,11 +212,12 @@ export function createBrokerServer(options = {}) {
|
||||
options.closeShellSession ||
|
||||
(authMode === "stub"
|
||||
? async () => ({})
|
||||
: async (_id, token, transcript, reason) =>
|
||||
: async (_id, token, transcript, reason, details = {}) =>
|
||||
managerRequest("/edge-agent/internal/shell-sessions/close", {
|
||||
token,
|
||||
transcript,
|
||||
reason,
|
||||
...details,
|
||||
}));
|
||||
const validateBrowserStream =
|
||||
options.validateBrowserStream ||
|
||||
@@ -254,21 +290,41 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const closeBrowserShellSession = async (sessionRecord, reason) => {
|
||||
const closeBrowserShellSession = async (sessionRecord, reason, details = {}) => {
|
||||
try {
|
||||
await closeShellSession(
|
||||
sessionRecord.session.id,
|
||||
sessionRecord.ws.sessionToken,
|
||||
sessionRecord.transcript,
|
||||
reason
|
||||
reason,
|
||||
{
|
||||
message: sessionRecord.closedMessage || null,
|
||||
code: sessionRecord.closedCode ?? null,
|
||||
stage: sessionRecord.closedStage || null,
|
||||
broker_connection_id: sessionRecord.agentConnectionId || null,
|
||||
details: sessionRecord.closedDetails || {},
|
||||
...details,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// Preserve socket teardown even when the manager callback is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
const closeBrowserShellSocket = (sessionRecord, reason, message = null, code = 1000) => {
|
||||
const clearShellOpenTimer = (sessionRecord) => {
|
||||
if (sessionRecord?.openTimer) {
|
||||
clearTimeout(sessionRecord.openTimer);
|
||||
sessionRecord.openTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeBrowserShellSocket = (sessionRecord, reason, message = null, code = 1000, details = {}) => {
|
||||
sessionRecord.closedReason = reason;
|
||||
sessionRecord.closedMessage = message || null;
|
||||
sessionRecord.closedCode = code;
|
||||
sessionRecord.closedDetails = details && typeof details === "object" ? details : {};
|
||||
sessionRecord.closedStage = String(sessionRecord.closedDetails.stage || (sessionRecord.opened ? "shell_active" : "shell_open"));
|
||||
clearShellOpenTimer(sessionRecord);
|
||||
if (sessionRecord.ws.readyState >= 2) {
|
||||
return;
|
||||
}
|
||||
@@ -276,7 +332,9 @@ export function createBrokerServer(options = {}) {
|
||||
sendJson(sessionRecord.ws, {
|
||||
type: "closed",
|
||||
reason,
|
||||
code,
|
||||
...(message ? { message } : {}),
|
||||
...(Object.keys(sessionRecord.closedDetails).length ? { details: sessionRecord.closedDetails } : {}),
|
||||
});
|
||||
sessionRecord.ws.close(code, reason);
|
||||
};
|
||||
@@ -287,7 +345,11 @@ export function createBrokerServer(options = {}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
closeBrowserShellSocket(sessionRecord, reason, "Gateway agent disconnected from the broker.", 1011);
|
||||
closeBrowserShellSocket(sessionRecord, reason, "Gateway agent disconnected from the broker.", 1011, {
|
||||
stage: "agent_disconnect",
|
||||
gateway_id: gatewayId,
|
||||
shell_session_id: sessionRecord.session.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -513,11 +575,20 @@ export function createBrokerServer(options = {}) {
|
||||
if (url.pathname === "/ws/browser-shell") {
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
if (token === "") {
|
||||
socket.destroy();
|
||||
rejectUpgrade(socket, 400, "shell_session_token_missing", "Missing shell session token.");
|
||||
return;
|
||||
}
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await validateShellSession({ token, headers: req.headers });
|
||||
} catch (error) {
|
||||
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", normalizeErrorMessage(error, "Shell session could not be validated."), {
|
||||
stage: "shell_session_validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await validateShellSession({ token, headers: req.headers });
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.sessionInfo = session;
|
||||
@@ -526,12 +597,20 @@ export function createBrokerServer(options = {}) {
|
||||
session,
|
||||
transcript: "",
|
||||
closedReason: null,
|
||||
closedMessage: null,
|
||||
closedCode: null,
|
||||
closedDetails: {},
|
||||
closedStage: null,
|
||||
opened: false,
|
||||
openTimer: null,
|
||||
agentConnectionId: null,
|
||||
};
|
||||
browserShellSessions.set(String(session.id), sessionRecord);
|
||||
wss.emit("connection", ws, req);
|
||||
|
||||
const agent = agents.get(String(session.gateway_id));
|
||||
if (agent && agent.readyState === 1) {
|
||||
sessionRecord.agentConnectionId = agent.connectionId || null;
|
||||
sendJson(agent, {
|
||||
type: "OPEN_ROOT_SHELL",
|
||||
payload: {
|
||||
@@ -544,12 +623,32 @@ export function createBrokerServer(options = {}) {
|
||||
shellArgs: session.shell_args ?? session.metadata?.shell_args ?? [],
|
||||
},
|
||||
});
|
||||
sessionRecord.openTimer = setTimeout(() => {
|
||||
closeBrowserShellSocket(
|
||||
sessionRecord,
|
||||
"shell_open_timeout",
|
||||
"Gateway agent did not confirm that the shell opened before the broker timeout.",
|
||||
1011,
|
||||
{
|
||||
stage: "shell_open",
|
||||
timeout_ms: shellOpenTimeoutMs,
|
||||
gateway_id: session.gateway_id,
|
||||
shell_session_id: session.id,
|
||||
}
|
||||
);
|
||||
}, Math.max(250, shellOpenTimeoutMs));
|
||||
sessionRecord.openTimer.unref?.();
|
||||
} else {
|
||||
closeBrowserShellSocket(
|
||||
sessionRecord,
|
||||
"agent_offline",
|
||||
"Gateway agent is not connected to the broker.",
|
||||
1011
|
||||
1011,
|
||||
{
|
||||
stage: "agent_lookup",
|
||||
gateway_id: session.gateway_id,
|
||||
shell_session_id: session.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -583,8 +682,8 @@ export function createBrokerServer(options = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
socket.destroy();
|
||||
} catch (error) {
|
||||
rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -706,17 +805,35 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_OPENED") {
|
||||
sessionRecord.opened = true;
|
||||
sessionRecord.agentConnectionId = ws.connectionId || sessionRecord.agentConnectionId || null;
|
||||
clearShellOpenTimer(sessionRecord);
|
||||
await markShellSessionOpened(sessionRecord.ws.sessionToken, ws.connectionId || null).catch(() => {});
|
||||
sendJson(sessionRecord.ws, { type: "opened" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "SHELL_EXIT") {
|
||||
sendJson(sessionRecord.ws, { type: "closed", reason: "agent_exit", code: message.code ?? 0 });
|
||||
await closeBrowserShellSession(sessionRecord, "agent_exit");
|
||||
const reason = String(message.reason || "agent_exit");
|
||||
const parsedExitCode = Number(message.code);
|
||||
const exitCode = Number.isFinite(parsedExitCode) ? parsedExitCode : 0;
|
||||
const closeMessage = message.message ? String(message.message) : null;
|
||||
clearShellOpenTimer(sessionRecord);
|
||||
sendJson(sessionRecord.ws, {
|
||||
type: "closed",
|
||||
reason,
|
||||
code: exitCode,
|
||||
...(closeMessage ? { message: closeMessage } : {}),
|
||||
});
|
||||
await closeBrowserShellSession(sessionRecord, reason, {
|
||||
message: closeMessage,
|
||||
code: exitCode,
|
||||
stage: sessionRecord.opened ? "shell_active" : "shell_start",
|
||||
broker_connection_id: ws.connectionId || null,
|
||||
});
|
||||
browserShellSessions.delete(String(message.sessionId));
|
||||
if (sessionRecord.ws.readyState < 2) {
|
||||
sessionRecord.ws.close(1000, "agent_exit");
|
||||
sessionRecord.ws.close(1000, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -796,7 +913,7 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", async (_code, buffer) => {
|
||||
ws.on("close", async (code, buffer) => {
|
||||
const closeReason = buffer?.toString?.("utf8") || null;
|
||||
|
||||
if (ws.gatewayId) {
|
||||
@@ -832,7 +949,11 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
const sessionRecord = browserShellSessions.get(sessionId);
|
||||
if (sessionRecord) {
|
||||
await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed");
|
||||
await closeBrowserShellSession(sessionRecord, sessionRecord.closedReason || "browser_closed", {
|
||||
code,
|
||||
stage: sessionRecord.closedStage || "browser_socket_close",
|
||||
message: sessionRecord.closedMessage || null,
|
||||
});
|
||||
browserShellSessions.delete(sessionId);
|
||||
}
|
||||
return;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -190,7 +190,12 @@ class shelly_relay_inventory
|
||||
$device['_dev_info']['code'] ?? null,
|
||||
$device['code'] ?? null,
|
||||
]);
|
||||
$device_model = $this->extractDeviceModel($device, $device_code, $catalog_entry);
|
||||
if ($device_code === '' && $device_model !== null) {
|
||||
$device_code = $device_model;
|
||||
}
|
||||
$device_type = $this->extractDeviceType($device, $device_code, $catalog_entry);
|
||||
$device_generation = $this->extractDeviceGeneration($device, $device_code, $device_type, $catalog_entry);
|
||||
$control_type = $this->extractControlType($device);
|
||||
$control_name = $this->extractControlName($device);
|
||||
$online = $this->extractOnlineState($device);
|
||||
@@ -214,6 +219,8 @@ class shelly_relay_inventory
|
||||
'cloud_name' => $cloud_name !== '' ? $cloud_name : null,
|
||||
'device_type' => $device_type,
|
||||
'code' => $device_code !== '' ? $device_code : null,
|
||||
'device_model' => $device_model,
|
||||
'device_generation' => $device_generation,
|
||||
'control_type' => $control_type,
|
||||
'control_name' => $control_name !== '' ? $control_name : null,
|
||||
'local_ip' => $local_ip,
|
||||
@@ -295,19 +302,155 @@ class shelly_relay_inventory
|
||||
*/
|
||||
private function extractDeviceType(array $device, string $device_code, ?array $catalog_entry = null): ?string
|
||||
{
|
||||
$device_type = $this->extractFirstString([
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$candidates = [
|
||||
$device['_dev_info']['type'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$device['type'] ?? null,
|
||||
$device['settings']['device']['type'] ?? null,
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$device['_dev_info']['app'] ?? null,
|
||||
$device['app'] ?? null,
|
||||
$catalog_entry['type'] ?? null,
|
||||
$device_code,
|
||||
]);
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = trim((string)($candidate ?? ''));
|
||||
if ($normalized !== '' && !$this->looksLikeShellyModelCode($normalized)) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
$device_type = $this->extractFirstString($candidates);
|
||||
|
||||
return $device_type !== '' ? $device_type : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractDeviceModel(array $device, string $device_code, ?array $catalog_entry = null): ?string
|
||||
{
|
||||
$candidates = [
|
||||
$device_code,
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$catalog_entry['type'] ?? null,
|
||||
$catalog_entry['model'] ?? null,
|
||||
$device['_dev_info']['type'] ?? null,
|
||||
$device['type'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$normalized = trim((string)($candidate ?? ''));
|
||||
if ($normalized !== '' && $this->looksLikeShellyModelCode($normalized)) {
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
private function extractDeviceGeneration(
|
||||
array $device,
|
||||
string $device_code,
|
||||
?string $device_type,
|
||||
?array $catalog_entry = null
|
||||
): ?int {
|
||||
foreach ([
|
||||
$device['_dev_info']['gen'] ?? null,
|
||||
$device['_dev_info']['generation'] ?? null,
|
||||
$device['gen'] ?? null,
|
||||
$device['generation'] ?? null,
|
||||
$device['settings']['device']['gen'] ?? null,
|
||||
$device['settings']['device']['generation'] ?? null,
|
||||
$device['status']['sys']['gen'] ?? null,
|
||||
$device['status']['sys']['generation'] ?? null,
|
||||
$catalog_entry['gen'] ?? null,
|
||||
$catalog_entry['generation'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = $this->normalizeDeviceGeneration($candidate);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
$device_code,
|
||||
$device_type,
|
||||
$device['_dev_info']['model'] ?? null,
|
||||
$device['_dev_info']['type'] ?? null,
|
||||
$device['_dev_info']['app'] ?? null,
|
||||
$device['model'] ?? null,
|
||||
$device['type'] ?? null,
|
||||
$device['app'] ?? null,
|
||||
$catalog_entry['type'] ?? null,
|
||||
$catalog_entry['model'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = $this->inferDeviceGenerationFromString((string)($candidate ?? ''));
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeDeviceGeneration(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$generation = (int)$value;
|
||||
return $generation > 0 ? $generation : null;
|
||||
}
|
||||
|
||||
return $this->inferDeviceGenerationFromString((string)($value ?? ''));
|
||||
}
|
||||
|
||||
private function inferDeviceGenerationFromString(string $value): ?int
|
||||
{
|
||||
$normalized = trim($value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
$upper = strtoupper($normalized);
|
||||
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1
|
||||
|| preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function looksLikeShellyModelCode(string $value): bool
|
||||
{
|
||||
$normalized = strtoupper(trim($value));
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/^(?:S[1-9]|SP|SN|SH)[A-Z0-9]+-[A-Z0-9-]+$/', $normalized) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $device
|
||||
*/
|
||||
|
||||
@@ -148,6 +148,83 @@ class edge_gateway_manager
|
||||
];
|
||||
}
|
||||
|
||||
private function buildShellReadinessDiagnostics(edge_gateways_o $gateway): array
|
||||
{
|
||||
$gatewayId = (int)$gateway->id;
|
||||
$brokerUrl = $this->buildBrokerPublicUrl();
|
||||
$wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell');
|
||||
$presence = $this->readBrokerPresence($gatewayId);
|
||||
$lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null;
|
||||
$ageSeconds = self::heartbeatAgeSeconds($lastSeenAt);
|
||||
$connected = self::isBrokerPresenceConnected($presence);
|
||||
$configuredBrokerUrl = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''));
|
||||
$derivedWarning = null;
|
||||
|
||||
if ($configuredBrokerUrl === '') {
|
||||
$apiBaseUrl = rtrim($this->getApiBaseUrl(), '/');
|
||||
$apiPath = (string)(parse_url($apiBaseUrl, PHP_URL_PATH) ?: '');
|
||||
$apiHost = (string)(parse_url($apiBaseUrl, PHP_URL_HOST) ?: '');
|
||||
if ($apiPath === '/api' && !in_array($apiHost, ['localhost', '127.0.0.1'], true)) {
|
||||
$derivedWarning = 'BROKER_PUBLIC_URL_DERIVED_WITH_API_PREFIX';
|
||||
}
|
||||
}
|
||||
|
||||
$diagnostics = [
|
||||
'ready' => false,
|
||||
'reason_code' => 'BROKER_NOT_READY',
|
||||
'message' => 'Gateway shell is not ready.',
|
||||
'gateway_id' => $gatewayId,
|
||||
'broker_url' => $brokerUrl,
|
||||
'ws_url' => $wsUrl,
|
||||
'public_broker_url_configured' => $configuredBrokerUrl !== '',
|
||||
'derived_warning' => $derivedWarning,
|
||||
'broker_presence' => [
|
||||
'connected' => !empty($presence['connected']),
|
||||
'connection_id' => isset($presence['connection_id']) ? (string)$presence['connection_id'] : null,
|
||||
'last_seen_at' => $lastSeenAt,
|
||||
'age_seconds' => $ageSeconds,
|
||||
'disconnect_reason' => isset($presence['disconnect_reason']) ? (string)$presence['disconnect_reason'] : null,
|
||||
'last_error' => isset($presence['last_error']) ? (string)$presence['last_error'] : null,
|
||||
],
|
||||
];
|
||||
|
||||
if ($brokerUrl === null || $wsUrl === null) {
|
||||
return array_merge($diagnostics, [
|
||||
'reason_code' => 'BROKER_NOT_CONFIGURED',
|
||||
'message' => 'The public edge broker URL is not configured, so a gateway shell cannot be opened.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($presence === []) {
|
||||
return array_merge($diagnostics, [
|
||||
'reason_code' => 'BROKER_DISCONNECTED',
|
||||
'message' => 'Gateway agent has not reported an active broker connection yet.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (empty($presence['connected'])) {
|
||||
return array_merge($diagnostics, [
|
||||
'reason_code' => 'BROKER_DISCONNECTED',
|
||||
'message' => 'Gateway agent is not connected to the edge broker.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$connected) {
|
||||
return array_merge($diagnostics, [
|
||||
'reason_code' => 'BROKER_STALE',
|
||||
'message' => 'Gateway broker presence is stale. Wait for the agent to reconnect before opening a shell.',
|
||||
]);
|
||||
}
|
||||
|
||||
return array_merge($diagnostics, [
|
||||
'ready' => true,
|
||||
'reason_code' => $derivedWarning ?: 'READY',
|
||||
'message' => $derivedWarning === null
|
||||
? 'Gateway shell broker path is ready.'
|
||||
: 'Gateway shell broker path is ready, but the broker URL was derived from the API URL.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -880,12 +957,19 @@ class edge_gateway_manager
|
||||
return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution);
|
||||
}
|
||||
|
||||
$job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', [
|
||||
$statusRequest = [
|
||||
'relayId' => $logicalRelayId,
|
||||
'deviceId' => $binding['device_id'],
|
||||
'localIp' => $binding['local_ip'],
|
||||
'channel' => (int)$binding['channel'],
|
||||
], null, [
|
||||
];
|
||||
$deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding);
|
||||
if ($deviceGeneration !== null) {
|
||||
$statusRequest['deviceGeneration'] = $deviceGeneration;
|
||||
$statusRequest['device_generation'] = $deviceGeneration;
|
||||
}
|
||||
|
||||
$job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', $statusRequest, null, [
|
||||
'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
|
||||
'fallback_reason' => $resolution['reason'] ?? null,
|
||||
'require_fast_path' => $requireFastLocalPath,
|
||||
@@ -987,6 +1071,11 @@ class edge_gateway_manager
|
||||
'channel' => (int)$binding['channel'],
|
||||
'on' => $on,
|
||||
];
|
||||
$deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding);
|
||||
if ($deviceGeneration !== null) {
|
||||
$request['deviceGeneration'] = $deviceGeneration;
|
||||
$request['device_generation'] = $deviceGeneration;
|
||||
}
|
||||
if ($toggleAfterSeconds !== null) {
|
||||
$request['toggleAfter'] = $toggleAfterSeconds;
|
||||
$request['toggle_after'] = $toggleAfterSeconds;
|
||||
@@ -1816,8 +1905,20 @@ BASH;
|
||||
?string $cwd = null
|
||||
): array {
|
||||
$gateway = $this->requireGateway($gatewayId);
|
||||
$readiness = $this->buildShellReadinessDiagnostics($gateway);
|
||||
if (empty($readiness['ready'])) {
|
||||
throw new edge_gateway_operation_exception(
|
||||
(string)$readiness['message'],
|
||||
(string)$readiness['reason_code'],
|
||||
409,
|
||||
$readiness
|
||||
);
|
||||
}
|
||||
|
||||
$sessionToken = bin2hex(random_bytes(32));
|
||||
$expiresAt = $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS);
|
||||
$brokerUrl = $this->buildBrokerPublicUrl();
|
||||
$wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell');
|
||||
|
||||
$sessionObject = new edge_gateway_shell_sessions_o();
|
||||
$sessionId = $sessionObject->add_object([
|
||||
@@ -1835,6 +1936,9 @@ BASH;
|
||||
'transcript' => null,
|
||||
'metadata_json' => [
|
||||
'root_dir' => self::DEFAULT_INSTALL_DIR,
|
||||
'shell_diagnostics' => $readiness,
|
||||
'requested_broker_url' => $brokerUrl,
|
||||
'requested_ws_url' => $wsUrl,
|
||||
'shortcut_paths' => [
|
||||
self::DEFAULT_INSTALL_DIR,
|
||||
self::DEFAULT_RUNTIME_DIR,
|
||||
@@ -1860,8 +1964,9 @@ BASH;
|
||||
'token' => $sessionToken,
|
||||
'gateway_id' => (int)$gateway->id,
|
||||
'expires_at' => $expiresAt,
|
||||
'broker_url' => $this->buildBrokerPublicUrl(),
|
||||
'ws_url' => $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell'),
|
||||
'broker_url' => $brokerUrl,
|
||||
'ws_url' => $wsUrl,
|
||||
'diagnostics' => $readiness,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1916,11 +2021,20 @@ BASH;
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function closeShellSessionByToken(string $plainToken, string $transcript = '', ?string $reason = null): array
|
||||
{
|
||||
public function closeShellSessionByToken(
|
||||
string $plainToken,
|
||||
string $transcript = '',
|
||||
?string $reason = null,
|
||||
array $details = []
|
||||
): array {
|
||||
$session = $this->findShellSessionByToken($plainToken);
|
||||
$metadata = (array)($session->metadata_json->value() ?? []);
|
||||
$closeDiagnostics = $this->normalizeShellCloseDiagnostics($reason, $details);
|
||||
$metadata['close_reason'] = $reason;
|
||||
$metadata['close_message'] = $closeDiagnostics['message'] ?? null;
|
||||
$metadata['close_code'] = $closeDiagnostics['code'] ?? null;
|
||||
$metadata['close_stage'] = $closeDiagnostics['stage'] ?? null;
|
||||
$metadata['close_diagnostics'] = $closeDiagnostics;
|
||||
$metadata['transcript_bytes'] = strlen($transcript);
|
||||
|
||||
$session->status->set($reason === 'agent_exit' ? 'COMPLETED' : 'CLOSED');
|
||||
@@ -1941,6 +2055,54 @@ BASH;
|
||||
return $session->asArray();
|
||||
}
|
||||
|
||||
private function normalizeShellCloseDiagnostics(?string $reason, array $details = []): array
|
||||
{
|
||||
$nestedDetails = isset($details['details']) && is_array($details['details']) ? (array)$details['details'] : [];
|
||||
$message = self::trimInstallSessionText(
|
||||
$details['message'] ?? $nestedDetails['message'] ?? null,
|
||||
self::INSTALL_SESSION_OUTPUT_LIMIT
|
||||
);
|
||||
$stage = self::trimInstallSessionText(
|
||||
$details['stage'] ?? $details['failure_stage'] ?? $nestedDetails['stage'] ?? null,
|
||||
128
|
||||
);
|
||||
$codeValue = $details['code'] ?? $details['close_code'] ?? $nestedDetails['code'] ?? null;
|
||||
$code = is_numeric($codeValue) ? (int)$codeValue : null;
|
||||
$diagnostics = [
|
||||
'reason' => self::trimInstallSessionText($reason, 128),
|
||||
'message' => $message,
|
||||
'code' => $code,
|
||||
'stage' => $stage,
|
||||
'was_clean' => array_key_exists('was_clean', $details) ? (bool)$details['was_clean'] : null,
|
||||
'connection_id' => self::trimInstallSessionText(
|
||||
$details['connection_id'] ?? $details['broker_connection_id'] ?? $nestedDetails['connection_id'] ?? null,
|
||||
128
|
||||
),
|
||||
'broker_url' => self::trimInstallSessionText($details['broker_url'] ?? $nestedDetails['broker_url'] ?? null, 512),
|
||||
'ws_url' => self::trimInstallSessionText(
|
||||
$this->redactShellDiagnosticUrl($details['ws_url'] ?? $nestedDetails['ws_url'] ?? null),
|
||||
512
|
||||
),
|
||||
'closed_at' => $this->now(),
|
||||
];
|
||||
|
||||
return array_filter($diagnostics, static fn(mixed $value): bool => $value !== null && $value !== '');
|
||||
}
|
||||
|
||||
private function redactShellDiagnosticUrl(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = trim((string)$value);
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string)preg_replace('/([?&](?:token|agentToken|agent_token)=)[^&]*/i', '$1***', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -3135,6 +3297,170 @@ BASH;
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveRelayBindingDeviceGeneration(array $binding): ?int
|
||||
{
|
||||
$metadata = isset($binding['metadata']) && is_array($binding['metadata'])
|
||||
? (array)$binding['metadata']
|
||||
: [];
|
||||
|
||||
$generation = $this->resolveShellyDeviceGenerationFromPayload(array_merge($metadata, $binding));
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
|
||||
$inventoryDevice = $this->findInventoryDeviceForRelayBinding($binding);
|
||||
if ($inventoryDevice !== null) {
|
||||
$generation = $this->resolveShellyDeviceGenerationFromPayload($inventoryDevice);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
$deviceId = trim((string)($binding['device_id'] ?? ''));
|
||||
$relayId = trim((string)($binding['relay_id'] ?? ''));
|
||||
foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) {
|
||||
$optionDeviceId = trim((string)($option['device_id'] ?? ''));
|
||||
$optionRelayId = trim((string)($option['id'] ?? ''));
|
||||
if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$generation = $this->resolveShellyDeviceGenerationFromPayload($option);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findInventoryDeviceForRelayBinding(array $binding): ?array
|
||||
{
|
||||
$gatewayId = (int)($binding['gateway_id'] ?? 0);
|
||||
$deviceId = trim((string)($binding['device_id'] ?? ''));
|
||||
if ($gatewayId <= 0 || $deviceId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'device_id' => $deviceId,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']);
|
||||
return $inventoryObject->exists() ? $inventoryObject->asArray() : null;
|
||||
}
|
||||
|
||||
private function normalizeDeviceCapabilities(array $device): array
|
||||
{
|
||||
$capabilities = isset($device['capabilities']) && is_array($device['capabilities'])
|
||||
? (array)$device['capabilities']
|
||||
: [];
|
||||
|
||||
if (!isset($capabilities['generation'])) {
|
||||
$generation = $this->resolveShellyDeviceGenerationFromPayload($device);
|
||||
if ($generation !== null) {
|
||||
$capabilities['generation'] = $generation;
|
||||
}
|
||||
}
|
||||
|
||||
return $capabilities;
|
||||
}
|
||||
|
||||
private function resolveShellyDeviceGenerationFromPayload(array $payload): ?int
|
||||
{
|
||||
$metadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [];
|
||||
$capabilities = isset($payload['capabilities']) && is_array($payload['capabilities']) ? (array)$payload['capabilities'] : [];
|
||||
|
||||
foreach ([
|
||||
$payload['deviceGeneration'] ?? null,
|
||||
$payload['device_generation'] ?? null,
|
||||
$payload['generation'] ?? null,
|
||||
$payload['gen'] ?? null,
|
||||
$capabilities['generation'] ?? null,
|
||||
$metadata['deviceGeneration'] ?? null,
|
||||
$metadata['device_generation'] ?? null,
|
||||
$metadata['generation'] ?? null,
|
||||
$metadata['gen'] ?? null,
|
||||
$metadata['capabilities']['generation'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = $this->normalizeShellyDeviceGeneration($candidate);
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([
|
||||
$payload['device_model'] ?? null,
|
||||
$payload['deviceModel'] ?? null,
|
||||
$payload['model'] ?? null,
|
||||
$payload['device_type'] ?? null,
|
||||
$payload['deviceType'] ?? null,
|
||||
$payload['type'] ?? null,
|
||||
$payload['code'] ?? null,
|
||||
$metadata['device_model'] ?? null,
|
||||
$metadata['deviceModel'] ?? null,
|
||||
$metadata['model'] ?? null,
|
||||
$metadata['device_type'] ?? null,
|
||||
$metadata['deviceType'] ?? null,
|
||||
$metadata['type'] ?? null,
|
||||
$metadata['code'] ?? null,
|
||||
] as $candidate) {
|
||||
$generation = $this->inferShellyDeviceGenerationFromString((string)($candidate ?? ''));
|
||||
if ($generation !== null) {
|
||||
return $generation;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeShellyDeviceGeneration(mixed $value): ?int
|
||||
{
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$generation = (int)$value;
|
||||
return $generation > 0 ? $generation : null;
|
||||
}
|
||||
|
||||
return $this->inferShellyDeviceGenerationFromString((string)($value ?? ''));
|
||||
}
|
||||
|
||||
private function inferShellyDeviceGenerationFromString(string $value): ?int
|
||||
{
|
||||
$normalized = trim($value);
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
$upper = strtoupper($normalized);
|
||||
if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) {
|
||||
return (int)$matches[1];
|
||||
}
|
||||
|
||||
if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1
|
||||
|| preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function backfillRelayBindingLocalIpFromInventory(int $gatewayId, string $deviceId, ?string $localIp): void
|
||||
{
|
||||
$localIp = $this->normalizeLocalIp($localIp);
|
||||
@@ -3170,6 +3496,7 @@ BASH;
|
||||
}
|
||||
|
||||
$localIp = $this->extractDeviceLocalIp($device);
|
||||
$capabilities = $this->normalizeDeviceCapabilities($device);
|
||||
$rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([
|
||||
'gateway_id' => $gatewayId,
|
||||
'device_id' => $deviceId,
|
||||
@@ -3183,7 +3510,7 @@ BASH;
|
||||
'local_ip' => $localIp,
|
||||
'model' => $device['model'] ?? null,
|
||||
'channel_count' => (int)($device['channel_count'] ?? $device['channels'] ?? 1),
|
||||
'capabilities_json' => (array)($device['capabilities'] ?? []),
|
||||
'capabilities_json' => $capabilities,
|
||||
'online' => (bool)($device['online'] ?? true),
|
||||
'last_seen_at' => $this->now(),
|
||||
'metadata_json' => (array)($device['metadata'] ?? []),
|
||||
@@ -3198,7 +3525,7 @@ BASH;
|
||||
}
|
||||
$inventoryObject->model->set($device['model'] ?? null);
|
||||
$inventoryObject->channel_count->set((int)($device['channel_count'] ?? $device['channels'] ?? 1));
|
||||
$inventoryObject->capabilities_json->set((array)($device['capabilities'] ?? []));
|
||||
$inventoryObject->capabilities_json->set($capabilities);
|
||||
$inventoryObject->online->set((bool)($device['online'] ?? true));
|
||||
$inventoryObject->last_seen_at->set($this->now());
|
||||
$inventoryObject->metadata_json->set((array)($device['metadata'] ?? []));
|
||||
@@ -3575,6 +3902,27 @@ BASH;
|
||||
$metadata['consumer_contexts'] = $consumerContexts;
|
||||
$metadata['consumers'] = $consumerContexts;
|
||||
|
||||
foreach (['device_type', 'device_model', 'code'] as $field) {
|
||||
$value = trim((string)($binding[$field] ?? $metadata[$field] ?? ''));
|
||||
if ($value !== '') {
|
||||
$metadata[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$generation = $this->normalizeShellyDeviceGeneration(
|
||||
$binding['deviceGeneration']
|
||||
?? $binding['device_generation']
|
||||
?? $binding['generation']
|
||||
?? $metadata['deviceGeneration']
|
||||
?? $metadata['device_generation']
|
||||
?? $metadata['generation']
|
||||
?? null
|
||||
);
|
||||
if ($generation !== null) {
|
||||
$metadata['device_generation'] = $generation;
|
||||
$metadata['generation'] = $generation;
|
||||
}
|
||||
|
||||
if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) {
|
||||
$metadata['last_resolution'] = (array)$binding['last_resolution'];
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ class edge_gateway_operation_exception extends Exception
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly string $errorCode,
|
||||
public readonly int $status = 400
|
||||
public readonly int $status = 400,
|
||||
public readonly array $details = []
|
||||
) {
|
||||
parent::__construct($message, $status);
|
||||
}
|
||||
|
||||
@@ -184,7 +184,15 @@ class edgeGatewaysRoute
|
||||
$cwd = isset($payload['cwd']) ? (string)$payload['cwd'] : null;
|
||||
$cols = isset($payload['cols']) ? (int)$payload['cols'] : null;
|
||||
$rows = isset($payload['rows']) ? (int)$payload['rows'] : null;
|
||||
$response->success($this->manager()->createShellSession($gatewayId, $this->actorUserId(), $reason, $cols, $rows, $cwd), 201);
|
||||
try {
|
||||
$response->success($this->manager()->createShellSession($gatewayId, $this->actorUserId(), $reason, $cols, $rows, $cwd), 201);
|
||||
} catch (edge_gateway_operation_exception $exception) {
|
||||
$response->error([
|
||||
'message' => $exception->getMessage(),
|
||||
'error_code' => $exception->errorCode,
|
||||
'diagnostics' => $exception->details,
|
||||
], $exception->status);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGatewayUpdate(): void
|
||||
@@ -698,7 +706,20 @@ class edgeGatewaysRoute
|
||||
$response->success($this->manager()->closeShellSessionByToken(
|
||||
(string)self::getParameter('token'),
|
||||
isset($payload['transcript']) ? (string)$payload['transcript'] : '',
|
||||
isset($payload['reason']) ? (string)$payload['reason'] : null
|
||||
isset($payload['reason']) ? (string)$payload['reason'] : null,
|
||||
[
|
||||
'message' => isset($payload['message']) ? (string)$payload['message'] : null,
|
||||
'code' => isset($payload['code']) ? (int)$payload['code'] : null,
|
||||
'close_code' => isset($payload['close_code']) ? (int)$payload['close_code'] : null,
|
||||
'stage' => isset($payload['stage']) ? (string)$payload['stage'] : null,
|
||||
'failure_stage' => isset($payload['failure_stage']) ? (string)$payload['failure_stage'] : null,
|
||||
'was_clean' => isset($payload['was_clean']) ? (bool)$payload['was_clean'] : null,
|
||||
'connection_id' => isset($payload['connection_id']) ? (string)$payload['connection_id'] : null,
|
||||
'broker_connection_id' => isset($payload['broker_connection_id']) ? (string)$payload['broker_connection_id'] : null,
|
||||
'broker_url' => isset($payload['broker_url']) ? (string)$payload['broker_url'] : null,
|
||||
'ws_url' => isset($payload['ws_url']) ? (string)$payload['ws_url'] : null,
|
||||
'details' => isset($payload['details']) && is_array($payload['details']) ? (array)$payload['details'] : [],
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,12 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
expect($shellSession->data()['diagnostics'] ?? [])
|
||||
->toHaveKey('ready', true)
|
||||
->toHaveKey('reason_code', 'READY')
|
||||
->and($shellSession->data()['diagnostics']['broker_presence']['connection_id'] ?? null)
|
||||
->toBe('broker-presence-1');
|
||||
|
||||
$shellToken = (string)$shellSession->data()['token'];
|
||||
|
||||
$validateShell = api_client()->post(
|
||||
@@ -172,6 +178,9 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
|
||||
'token' => $shellToken,
|
||||
'transcript' => "edge-broker-shell\n",
|
||||
'reason' => 'agent_exit',
|
||||
'message' => 'Shell exited cleanly.',
|
||||
'code' => 0,
|
||||
'stage' => 'shell_active',
|
||||
],
|
||||
edge_test_broker_headers()
|
||||
);
|
||||
@@ -184,7 +193,15 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
|
||||
expect($closedShell->data())
|
||||
->toHaveKey('status', 'COMPLETED')
|
||||
->and($closedShell->data()['transcript'] ?? null)
|
||||
->toBe("edge-broker-shell\n");
|
||||
->toBe("edge-broker-shell\n")
|
||||
->and($closedShell->data()['metadata']['close_reason'] ?? null)
|
||||
->toBe('agent_exit')
|
||||
->and($closedShell->data()['metadata']['close_message'] ?? null)
|
||||
->toBe('Shell exited cleanly.')
|
||||
->and($closedShell->data()['metadata']['close_code'] ?? null)
|
||||
->toBe(0)
|
||||
->and($closedShell->data()['metadata']['close_stage'] ?? null)
|
||||
->toBe('shell_active');
|
||||
|
||||
$logsPage = api_client()->get('/edge-gateways/' . (int)$gateway['id'] . '/logs', $session['headers']);
|
||||
|
||||
@@ -214,6 +231,35 @@ it('validates broker sessions and ingests presence, telemetry, logs, and shell l
|
||||
->toBe(0.42);
|
||||
});
|
||||
|
||||
it('rejects shell session creation while broker presence is unavailable', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Broker Shell Readiness Department',
|
||||
]);
|
||||
$session = api_fixtures()->createEdgeOperatorSession((int)$department['id']);
|
||||
$gateway = api_fixtures()->createClaimedEdgeGateway([
|
||||
'department_id' => (int)$department['id'],
|
||||
'label' => 'Disconnected Broker Gateway',
|
||||
]);
|
||||
|
||||
$response = api_client()->post(
|
||||
'/edge-gateways/' . (int)$gateway['id'] . '/shell-sessions',
|
||||
['reason' => 'Should fail without broker presence'],
|
||||
$session['headers']
|
||||
);
|
||||
|
||||
$response
|
||||
->assertStatus(409)
|
||||
->assertEnvelope()
|
||||
->assertSuccess(false);
|
||||
|
||||
expect($response->data())
|
||||
->toHaveKey('error_code', 'BROKER_DISCONNECTED')
|
||||
->and($response->data()['diagnostics']['ready'] ?? true)
|
||||
->toBeFalse()
|
||||
->and($response->data()['diagnostics']['broker_presence']['connected'] ?? true)
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds broker backlog and completes gateway operations through broker endpoints', function (): void {
|
||||
$department = api_fixtures()->createDepartment([
|
||||
'name' => 'Edge Broker Backlog Department',
|
||||
|
||||
@@ -25,6 +25,9 @@ it('keeps relay dispatch and discovery queueing on the edge gateway manager', fu
|
||||
expect($managerSource)->toContain("'channel' => (int)\$binding['channel']");
|
||||
expect($managerSource)->toContain("'on' => \$on");
|
||||
expect($managerSource)->toContain("'require_fast_path' => \$requireFastLocalPath");
|
||||
expect($managerSource)->toContain('private function resolveRelayBindingDeviceGeneration');
|
||||
expect($managerSource)->toContain('private function normalizeDeviceCapabilities');
|
||||
expect($managerSource)->toContain("\$request['device_generation'] = \$deviceGeneration;");
|
||||
expect($managerSource)->toContain("\$request['toggle_after'] = \$toggleAfterSeconds;");
|
||||
expect($managerSource)->toContain('private function expireTimedOutRelayStatusCommandJobs');
|
||||
expect($managerSource)->toContain("AND command_type = 'GET_RELAY_STATUS'");
|
||||
@@ -72,6 +75,10 @@ it('loads relay command helpers on the manager and gateway operations on the ded
|
||||
expect($reflection->getMethod('claimNextCommandJob')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('syncDeviceInventory'))->toBeTrue();
|
||||
expect($reflection->getMethod('syncDeviceInventory')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('resolveRelayBindingDeviceGeneration'))->toBeTrue();
|
||||
expect($reflection->getMethod('resolveRelayBindingDeviceGeneration')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('normalizeDeviceCapabilities'))->toBeTrue();
|
||||
expect($reflection->getMethod('normalizeDeviceCapabilities')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('resolveRelayBindingLocalIp'))->toBeTrue();
|
||||
expect($reflection->getMethod('resolveRelayBindingLocalIp')->isPrivate())->toBeTrue();
|
||||
expect($reflection->hasMethod('backfillRelayBindingLocalIpFromInventory'))->toBeTrue();
|
||||
|
||||
@@ -102,6 +102,8 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'cloud_name' => 'Gate From Shelly Cloud',
|
||||
'device_type' => 'Shelly Plus 1PM',
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'device_model' => 'SPSW-001PE16EU',
|
||||
'device_generation' => 2,
|
||||
'control_type' => 'Switch',
|
||||
'control_name' => 'Entrance Relay',
|
||||
'local_ip' => '10.32.0.11',
|
||||
@@ -116,6 +118,8 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'cloud_name' => 'Program Picker From Shelly Cloud',
|
||||
'device_type' => 'Shelly Pro 2PM',
|
||||
'code' => 'SPSW-201XE16EU',
|
||||
'device_model' => 'SPSW-201XE16EU',
|
||||
'device_generation' => 2,
|
||||
'control_type' => 'Relay',
|
||||
'control_name' => 'Program Picker',
|
||||
'local_ip' => '10.32.0.12',
|
||||
@@ -130,6 +134,8 @@ it('normalizes owned Shelly devices into relay select options', function (): voi
|
||||
'cloud_name' => null,
|
||||
'device_type' => 'Shelly 1',
|
||||
'code' => 'SHSW-1',
|
||||
'device_model' => 'SHSW-1',
|
||||
'device_generation' => 1,
|
||||
'control_type' => 'Relay',
|
||||
'control_name' => null,
|
||||
'local_ip' => null,
|
||||
@@ -184,6 +190,8 @@ it('falls back to local device and control names when Shelly cloud list metadata
|
||||
'cloud_name' => null,
|
||||
'device_type' => 'Shelly Plus 1PM',
|
||||
'code' => 'SPSW-001PE16EU',
|
||||
'device_model' => 'SPSW-001PE16EU',
|
||||
'device_generation' => 2,
|
||||
'control_type' => 'Switch',
|
||||
'control_name' => 'Entrance Relay',
|
||||
'local_ip' => '10.32.0.21',
|
||||
@@ -193,6 +201,68 @@ it('falls back to local device and control names when Shelly cloud list metadata
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Shelly 1 Mini Gen3 type, model, and generation aligned with Shelly metadata', function (): void {
|
||||
$inventory = (new shelly_relay_inventory())
|
||||
->setInventoryFetcher(static function (): array {
|
||||
return [
|
||||
'isok' => true,
|
||||
'data' => [
|
||||
'devices_status' => [
|
||||
'device-key-1' => [
|
||||
'_dev_info' => [
|
||||
'id' => 'e4b3231f6410',
|
||||
'model' => 'S3SW-001X8EU',
|
||||
'type' => 'Shelly 1 Mini Gen3',
|
||||
'online' => 1,
|
||||
],
|
||||
'name' => 'Roskilde indkørselsport',
|
||||
'wifi_sta' => [
|
||||
'ip' => '192.168.1.2',
|
||||
],
|
||||
'status' => [
|
||||
'switch:0' => [
|
||||
'output' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
})
|
||||
->setDeviceListFetcher(static function (): array {
|
||||
return [
|
||||
'isok' => true,
|
||||
'data' => [
|
||||
'devices' => [
|
||||
'e4b3231f6410' => [
|
||||
'id' => 'e4b3231f6410',
|
||||
'name' => 'Roskilde indkørselsport',
|
||||
'type' => 'S3SW-001X8EU',
|
||||
'cloud_online' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
expect($inventory->listRelayOptions())->toBe([[
|
||||
'id' => 'e4b3231f6410',
|
||||
'name' => 'Roskilde indkørselsport (Shelly 1 Mini Gen3)',
|
||||
'device_id' => 'e4b3231f6410',
|
||||
'device_name' => 'Roskilde indkørselsport',
|
||||
'cloud_name' => 'Roskilde indkørselsport',
|
||||
'device_type' => 'Shelly 1 Mini Gen3',
|
||||
'code' => 'S3SW-001X8EU',
|
||||
'device_model' => 'S3SW-001X8EU',
|
||||
'device_generation' => 3,
|
||||
'control_type' => 'Switch',
|
||||
'control_name' => null,
|
||||
'local_ip' => '192.168.1.2',
|
||||
'status_color' => 'Green',
|
||||
'online' => true,
|
||||
]]);
|
||||
});
|
||||
|
||||
it('fails fast when Shelly inventory does not include owned devices status', function (): void {
|
||||
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
|
||||
return [
|
||||
|
||||
Reference in New Issue
Block a user