diff --git a/src/devices/shelly.ts b/src/devices/shelly.ts index 4b3fb2f2127db..2bfe2bda9f1b7 100644 --- a/src/devices/shelly.ts +++ b/src/devices/shelly.ts @@ -13,6 +13,7 @@ const ea = exposes.access; const SHELLY_ENDPOINT_ID = 239; const SHELLY_OPTIONS = {profileId: ZSpec.CUSTOM_SHELLY_PROFILE_ID}; const SHELLY_PRESENCE_MAX_ZONES = 10; +const SHELLY_RPC_DATA_READ_TIMEOUT = 10000; const NS = "zhc:shelly"; @@ -63,6 +64,24 @@ interface ShellyRPC { commandResponses: never; } +interface ShellyWiFiSetup { + attributes: { + status: string; + ip: string; + actionCode: number; + dhcp: boolean; + enabled: boolean; + ssid: string; + password: string; + staticIp: string; + netMask: string; + gateway: string; + nameServer: string; + }; + commands: never; + commandResponses: never; +} + interface ShellyWS90Wind { attributes: { windSpeed: number; @@ -111,6 +130,79 @@ interface ShellyLightLevel { commandResponses: never; } +let shellyRpcSending = false; + +const shellyRpcLock = async (callback: () => Promise): Promise => { + // Since RPC messages require multiple writes to complete, we have to make sure + // we're not interleaving request/response transactions accidentally. + while (shellyRpcSending) { + await sleep(200); + } + try { + shellyRpcSending = true; + return await callback(); + } finally { + shellyRpcSending = false; + } +}; + +const shellyRpcSendRawUnlocked = async (endpoint: Zh.Endpoint | Zh.Group, message: string) => { + const splitBytes = 40; + + logger.debug(">>> shellyRPC write TxCtl", NS); + const txCtl = message.length; + await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {txCtl: txCtl}, SHELLY_OPTIONS); + logger.debug(`>>> TxCtl: ${txCtl}`, NS); + + logger.debug(">>> shellyRPC write Data", NS); + let dataToSend = message; + while (dataToSend.length > 0) { + const data = dataToSend.substring(0, splitBytes); + dataToSend = dataToSend.substring(splitBytes); + await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {data: data}, SHELLY_OPTIONS); + logger.debug(`>>> Data: ${data}`, NS); + } +}; + +const shellyRpcSendRaw = async (endpoint: Zh.Endpoint | Zh.Group, message: string) => + shellyRpcLock(async () => await shellyRpcSendRawUnlocked(endpoint, message)); + +const shellyRpcSend = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined) => { + const command = { + id: 1, + method: method, + params: params, + }; + return await shellyRpcSendRaw(endpoint, JSON.stringify(command)); +}; + +const shellyRpcRequest = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined): Promise => { + return await shellyRpcLock(async () => { + const command = { + id: 1, + method: method, + params: params, + }; + await shellyRpcSendRawUnlocked(endpoint, JSON.stringify(command)); + const rxCtlResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["rxCtl"], SHELLY_OPTIONS); + const expectedLen = rxCtlResult.rxCtl; + if (!expectedLen) return undefined; + + let accumulated = ""; + while (accumulated.length < expectedLen) { + const dataResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["data"], { + ...SHELLY_OPTIONS, + timeout: SHELLY_RPC_DATA_READ_TIMEOUT, + }); + if (!dataResult.data) break; + accumulated += dataResult.data; + } + + if (accumulated.length < expectedLen) return undefined; + return JSON.parse(accumulated.substring(0, expectedLen)); + }); +}; + // ============================================================================= // WS90 Weather Station - Calculated Values (stored in device.meta for persistence) // ============================================================================= @@ -476,66 +568,9 @@ const shellyModernExtend = { } }; - // RPC helper functions - let rpcSending = false; - - const rpcSendRaw = async (endpoint: Zh.Endpoint | Zh.Group, message: string) => { - // Since RPC messages require multiple writes to complete, we have to make sure - // we're not interleaving them accidentally. This is good enough for now, at least - // until the RPC receive firmware bug is fixed by Shelly. - while (rpcSending) { - await sleep(200); - } - try { - rpcSending = true; - const splitBytes = 40; - - logger.debug(">>> shellyRPC write TxCtl", NS); - const txCtl = message.length; - await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {txCtl: txCtl}, SHELLY_OPTIONS); - logger.debug(`>>> TxCtl: ${txCtl}`, NS); - - logger.debug(">>> shellyRPC write Data", NS); - let dataToSend = message; - while (dataToSend.length > 0) { - const data = dataToSend.substring(0, splitBytes); - dataToSend = dataToSend.substring(splitBytes); - await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {data: data}, SHELLY_OPTIONS); - logger.debug(`>>> Data: ${data}`, NS); - } - } finally { - rpcSending = false; - } - }; - - const rpcSend = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined) => { - const command = { - id: 1, - method: method, - params: params, - }; - return await rpcSendRaw(endpoint, JSON.stringify(command)); - }; - - const rpcRequest = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined): Promise => { - await rpcSend(endpoint, method, params); - const rxCtlResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["rxCtl"], SHELLY_OPTIONS); - const expectedLen = rxCtlResult.rxCtl; - if (!expectedLen) return undefined; - - let accumulated = ""; - while (accumulated.length < expectedLen) { - const dataResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["data"], { - ...SHELLY_OPTIONS, - timeout: 1000, - }); - if (!dataResult.data) break; - accumulated += dataResult.data; - } - - if (accumulated.length < expectedLen) return undefined; - return JSON.parse(accumulated.substring(0, expectedLen)); - }; + const rpcSendRaw = shellyRpcSendRaw; + const rpcSend = shellyRpcSend; + const rpcRequest = shellyRpcRequest; const rpcReceive = async (endpoint: Zh.Endpoint | Zh.Group, key: string) => { logger.debug(`||| shellyRPC rpcReceive(${key})`, NS); @@ -946,12 +981,96 @@ const shellyModernExtend = { return {exposes, fromZigbee, toZigbee, configure, isModernExtend: true}; }, shellyWiFiSetup(): ModernExtend { - // biome-ignore lint/suspicious/noExplicitAny: generic - const refresh = async (endpoint: any) => { - await endpoint.write("shellyWiFiSetupCluster", {actionCode: 0}, SHELLY_OPTIONS); - await endpoint.read("shellyWiFiSetupCluster", ["status", "ip", "enabled", "dhcp", "ssid"], SHELLY_OPTIONS); - await endpoint.read("shellyWiFiSetupCluster", ["staticIp", "netMask"], SHELLY_OPTIONS); - await endpoint.read("shellyWiFiSetupCluster", ["gateway", "nameServer"], SHELLY_OPTIONS); + const normalizeWifiString = (value: unknown): string | undefined => (typeof value === "string" && value !== "" ? value : undefined); + const getKnownFullWifiSsid = (device: Zh.Device, options?: KeyValue): string | undefined => { + if (typeof options?.shelly_wifi_ssid === "string" && options.shelly_wifi_ssid !== "") return options.shelly_wifi_ssid; + if (typeof device.meta.shelly_wifi_ssid === "string" && device.meta.shelly_wifi_ssid !== "") return device.meta.shelly_wifi_ssid; + return undefined; + }; + const cacheFullWifiSsid = (device: Zh.Device, ssid: unknown) => { + if (typeof ssid === "string" && ssid !== "" && device.meta.shelly_wifi_ssid !== ssid) { + device.meta.shelly_wifi_ssid = ssid; + device.save(); + } + }; + const rpcResult = (response: KeyValue | undefined): KeyValue | undefined => { + const result = response?.result ?? response?.params ?? response; + if (!result) return undefined; + assertObject(result); + return result; + }; + const readWifiStateViaRpc = async (endpoint: Zh.Endpoint): Promise => { + const config = rpcResult(await shellyRpcRequest(endpoint, "Wifi.GetConfig")); + const status = rpcResult(await shellyRpcRequest(endpoint, "Wifi.GetStatus")); + const state: KeyValue = {}; + const wifiConfig: KeyValue = {}; + + const sta = config?.sta; + if (sta) { + assertObject(sta); + if (typeof sta.enable === "boolean") wifiConfig.enabled = sta.enable; + if (typeof sta.ssid === "string") wifiConfig.ssid = sta.ssid; + if (typeof sta.ipv4mode === "string") state.dhcp_enabled = sta.ipv4mode === "dhcp"; + if (typeof sta.ip === "string") wifiConfig.static_ip = normalizeWifiString(sta.ip); + if (typeof sta.netmask === "string") wifiConfig.net_mask = normalizeWifiString(sta.netmask); + if (typeof sta.gw === "string") wifiConfig.gateway = normalizeWifiString(sta.gw); + if (typeof sta.nameserver === "string") wifiConfig.name_server = normalizeWifiString(sta.nameserver); + } + + if (status) { + if (typeof status.status === "string") state.wifi_status = status.status; + if (typeof status.sta_ip === "string") state.ip_address = normalizeWifiString(status.sta_ip); + if (wifiConfig.ssid === undefined && typeof status.ssid === "string") wifiConfig.ssid = status.ssid; + } + + if (Object.keys(wifiConfig).length > 0) { + state.wifi_config = wifiConfig; + } + + return Object.keys(state).length > 0 ? state : undefined; + }; + const refresh = async (endpoint: Zh.Endpoint, meta?: Tz.Meta) => { + let published = false; + if (meta) { + try { + const rpcState = await readWifiStateViaRpc(endpoint); + const ssid = rpcState?.wifi_config; + if (ssid && utils.isObject(ssid)) { + cacheFullWifiSsid(endpoint.getDevice(), ssid.ssid); + } + if (rpcState) { + meta.publish(rpcState); + published = true; + } + } catch (e) { + logger.debug(`Failed to read Wi-Fi state through Shelly RPC, falling back to setup cluster: ${e}`, NS); + const ssid = getKnownFullWifiSsid(endpoint.getDevice(), meta.options); + if (ssid) { + meta.publish({wifi_config: {ssid}}); + published = true; + } + } + } + + if (published) { + return; + } + + try { + await endpoint.write<"shellyWiFiSetupCluster", ShellyWiFiSetup>("shellyWiFiSetupCluster", {actionCode: 0}, SHELLY_OPTIONS); + await endpoint.read<"shellyWiFiSetupCluster", ShellyWiFiSetup>( + "shellyWiFiSetupCluster", + ["status", "ip", "enabled", "dhcp", "ssid"], + SHELLY_OPTIONS, + ); + await endpoint.read<"shellyWiFiSetupCluster", ShellyWiFiSetup>("shellyWiFiSetupCluster", ["staticIp", "netMask"], SHELLY_OPTIONS); + await endpoint.read<"shellyWiFiSetupCluster", ShellyWiFiSetup>("shellyWiFiSetupCluster", ["gateway", "nameServer"], SHELLY_OPTIONS); + if (meta) { + published = true; + } + } catch (e) { + logger.warning(`Failed to read Wi-Fi state through Shelly setup cluster; leaving previous state unchanged: ${e}`, NS); + } }; const exposes: Expose[] = [ @@ -1006,7 +1125,14 @@ const shellyModernExtend = { // Wi-Fi config if (msg.data.enabled !== undefined) wifi_config.enabled = msg.data.enabled === 1; - if (msg.data.ssid !== undefined) wifi_config.ssid = msg.data.ssid; + if (msg.data.ssid !== undefined) { + const reportedSsid = normalizeWifiString(msg.data.ssid); + const knownFullSsid = getKnownFullWifiSsid(meta.device, options); + wifi_config.ssid = knownFullSsid && reportedSsid && knownFullSsid.startsWith(reportedSsid) ? knownFullSsid : reportedSsid; + if (reportedSsid && (!knownFullSsid || reportedSsid.length > knownFullSsid.length)) { + cacheFullWifiSsid(meta.device, reportedSsid); + } + } if (msg.data.staticIp !== undefined) wifi_config.static_ip = msg.data.staticIp; if (msg.data.netMask !== undefined) wifi_config.net_mask = msg.data.netMask; if (msg.data.gateway !== undefined) wifi_config.gateway = msg.data.gateway; @@ -1028,15 +1154,19 @@ const shellyModernExtend = { { key: ["wifi_status", "ip_address", "dhcp_enabled"], convertGet: async (entity, key, meta) => { - const ep = determineEndpoint(entity, meta, "shellyWiFiSetupCluster"); - await refresh(ep); + utils.assertEndpoint(entity); + const ep = entity.getDevice().getEndpoint(SHELLY_ENDPOINT_ID); + if (!ep) throw new Error(`Shelly endpoint ${SHELLY_ENDPOINT_ID} not found`); + await refresh(ep, meta); }, }, { key: ["wifi_config"], convertGet: async (entity, key, meta) => { - const ep = determineEndpoint(entity, meta, "shellyWiFiSetupCluster"); - await refresh(ep); + utils.assertEndpoint(entity); + const ep = entity.getDevice().getEndpoint(SHELLY_ENDPOINT_ID); + if (!ep) throw new Error(`Shelly endpoint ${SHELLY_ENDPOINT_ID} not found`); + await refresh(ep, meta); }, convertSet: async (entity, key, value, meta) => { assertObject(value); @@ -1089,11 +1219,18 @@ const shellyModernExtend = { const configure: Configure[] = [ async (device, coordinatorEndpoint, definition) => { const ep = device.getEndpoint(SHELLY_ENDPOINT_ID); + if (!ep) return; await refresh(ep); }, ]; - return {exposes, fromZigbee, toZigbee, configure, isModernExtend: true}; + const options = [ + e + .text("shelly_wifi_ssid", ea.SET) + .withDescription("Full Wi-Fi SSID to use when the Shelly Wi-Fi setup cluster reports a shortened network name"), + ]; + + return {exposes, fromZigbee, toZigbee, configure, options, isModernExtend: true}; }, ws90CalculatedValues(): ModernExtend { const exposes: Expose[] = [ @@ -1458,16 +1595,72 @@ const fzLocal = { } satisfies Fz.Converter<"ssIasZone", undefined, ["commandStatusChangeNotification", "attributeReport", "readResponse"]>, }; +const shellyRpcResult = (response: KeyValue | undefined): KeyValue | undefined => { + const result = response?.result ?? response?.params ?? response; + if (!result) return undefined; + assertObject(result); + return result; +}; + +const getShellyInputId = (meta: Pick): number => { + if (meta.endpoint_name === "sw2") return 1; + return 0; +}; + +const getShellyRpcEndpoint = (entity: Zh.Endpoint | Zh.Group): Zh.Endpoint | undefined => { + if (!utils.isEndpoint(entity)) return undefined; + return entity.getDevice().getEndpoint(SHELLY_ENDPOINT_ID); +}; + +const shellyInputTypeLookup = { + switch: "toggle", + button: "momentary", +} as const; + +const shellyInputTypeSetLookup = { + toggle: "switch", + momentary: "button", +} as const; + const tzLocal = { switch_input_type: { key: ["switch_type"], convertSet: async (entity, key, value, meta) => { const lookup = {toggle: 0, momentary: 1} as const; + const rpcEndpoint = getShellyRpcEndpoint(entity); + if (rpcEndpoint) { + const inputId = getShellyInputId(meta); + try { + await shellyRpcSend(rpcEndpoint, "Input.SetConfig", { + id: inputId, + config: {type: utils.getFromLookup(value as string, shellyInputTypeSetLookup)}, + }); + return {state: {switch_type: value}}; + } catch { + // Fall back to the standard Zigbee input config cluster for devices that expose it. + } + } + const ep = determineEndpoint(entity, meta, "genOnOffSwitchCfg"); await ep.write("genOnOffSwitchCfg", {switchType: utils.getFromLookup(value as string, lookup)}); return {state: {switch_type: value}}; }, convertGet: async (entity, key, meta) => { + const rpcEndpoint = getShellyRpcEndpoint(entity); + if (rpcEndpoint) { + const inputId = getShellyInputId(meta); + try { + const config = shellyRpcResult(await shellyRpcRequest(rpcEndpoint, "Input.GetConfig", {id: inputId})); + if (typeof config?.type === "string") { + const switchType = utils.getFromLookup(config.type, shellyInputTypeLookup); + meta.publish({[meta.endpoint_name ? `switch_type_${meta.endpoint_name}` : "switch_type"]: switchType}); + } + return; + } catch { + // Fall back to the standard Zigbee input config cluster for devices that expose it. + } + } + const ep = determineEndpoint(entity, meta, "genOnOffSwitchCfg"); await ep.read("genOnOffSwitchCfg", ["switchType"]); }, diff --git a/test/shelly.test.ts b/test/shelly.test.ts index b8b8b651330ea..b7a99b738edff 100644 --- a/test/shelly.test.ts +++ b/test/shelly.test.ts @@ -97,6 +97,76 @@ describe("Shelly 2PM Gen4 cover mode", () => { expect(state).toStrictEqual({switch_type_sw1: "toggle"}); }); + it("reads switch input type through the Shelly RPC endpoint", async () => { + const response = JSON.stringify({id: 1, result: {id: 1, type: "button", enable: true, invert: false}}); + const read = vi.fn().mockResolvedValueOnce({rxCtl: response.length}).mockResolvedValueOnce({data: response}); + const device = mockShelly2PMCoverWithInputs(read); + const definition = await findByDevice(device); + const converter = definition.toZigbee.find((converter) => converter.key.includes("switch_type")) as Tz.Converter; + const publish = vi.fn(); + + await converter.convertGet?.(device.getEndpoint(3), "switch_type", {endpoint_name: "sw2", message: {}, publish} as never); + + expect(device.getEndpoint(239).write).toHaveBeenCalledWith( + "shellyRPCCluster", + {data: expect.stringContaining("Input.GetConfig")}, + expect.any(Object), + ); + expect(device.getEndpoint(239).write).toHaveBeenCalledWith("shellyRPCCluster", {data: expect.stringContaining('"id":1')}, expect.any(Object)); + expect(read).toHaveBeenCalledWith("shellyRPCCluster", ["rxCtl"], expect.any(Object)); + expect(read).toHaveBeenCalledWith("shellyRPCCluster", ["data"], expect.any(Object)); + expect(device.getEndpoint(3).read).not.toHaveBeenCalled(); + expect(publish).toHaveBeenCalledWith({switch_type_sw2: "momentary"}); + }); + + it("falls back to the input config cluster when Shelly RPC input type read fails", async () => { + const read = vi.fn().mockRejectedValueOnce(new Error("RPC unavailable")); + const device = mockShelly2PMCoverWithInputs(read); + const definition = await findByDevice(device); + const converter = definition.toZigbee.find((converter) => converter.key.includes("switch_type")) as Tz.Converter; + + await converter.convertGet?.(device.getEndpoint(3), "switch_type", {endpoint_name: "sw2", message: {}, publish: vi.fn()} as never); + + expect(device.getEndpoint(239).write).toHaveBeenCalledWith( + "shellyRPCCluster", + {data: expect.stringContaining("Input.GetConfig")}, + expect.any(Object), + ); + expect(device.getEndpoint(3).read).toHaveBeenCalledWith("genOnOffSwitchCfg", ["switchType"]); + }); + + it("writes switch input type through the Shelly RPC endpoint", async () => { + const device = mockShelly2PMCoverWithInputs(); + const definition = await findByDevice(device); + const converter = definition.toZigbee.find((converter) => converter.key.includes("switch_type")) as Tz.Converter; + + await converter.convertSet?.(device.getEndpoint(3), "switch_type", "momentary", {endpoint_name: "sw2", message: {}} as never); + + expect(device.getEndpoint(239).write).toHaveBeenCalledWith( + "shellyRPCCluster", + {data: expect.stringContaining("Input.SetConfig")}, + expect.any(Object), + ); + expect(device.getEndpoint(239).write).toHaveBeenCalledWith( + "shellyRPCCluster", + {data: expect.stringContaining('"type":"button"')}, + expect.any(Object), + ); + expect(device.getEndpoint(3).write).not.toHaveBeenCalled(); + }); + + it("falls back to the input config cluster when Shelly RPC input type write fails", async () => { + const device = mockShelly2PMCoverWithInputs(); + vi.mocked(device.getEndpoint(239).write).mockRejectedValueOnce(new Error("RPC unavailable")); + const definition = await findByDevice(device); + const converter = definition.toZigbee.find((converter) => converter.key.includes("switch_type")) as Tz.Converter; + + await converter.convertSet?.(device.getEndpoint(3), "switch_type", "momentary", {endpoint_name: "sw2", message: {}} as never); + + expect(device.getEndpoint(239).write).toHaveBeenCalledWith("shellyRPCCluster", {txCtl: expect.any(Number)}, expect.any(Object)); + expect(device.getEndpoint(3).write).toHaveBeenCalledWith("genOnOffSwitchCfg", {switchType: 1}); + }); + it("reads two-channel switch mode through the Shelly RPC endpoint", async () => { const response = JSON.stringify({id: 1, result: {id: 1, in_mode: "detached"}}); const read = vi.fn().mockResolvedValueOnce({rxCtl: response.length}).mockResolvedValueOnce({data: response}); @@ -224,6 +294,225 @@ describe("Shelly 2PM Gen4 cover mode", () => { }); }); +describe("Shelly Wi-Fi setup", () => { + const mockShelly2PMCover = (read?: ReturnType) => + mockDevice({ + modelID: "2PM", + manufacturerName: "Shelly", + endpoints: [ + {ID: 1, profileID: 260, deviceID: 514, inputClusterIDs: [0, 3, 4, 5, 258], outputClusterIDs: []}, + {ID: 239, profileID: 49153, deviceID: 8193, inputClusterIDs: [64513, 64514], outputClusterIDs: [], read}, + {ID: 242, profileID: 41440, deviceID: 97, inputClusterIDs: [], outputClusterIDs: [33]}, + ], + }); + + it("uses the cached full Shelly Wi-Fi SSID when the setup cluster reports a shortened name", async () => { + const device = mockShelly2PMCover(); + const definition = await findByDevice(device); + const converter = definition.fromZigbee?.find((c) => c.cluster === "shellyWiFiSetupCluster"); + assert(converter); + + const msg = { + data: {ssid: "The Int", enabled: 1}, + endpoint: device.getEndpoint(239), + } as unknown as Fz.Message<"shellyWiFiSetupCluster">; + + expect( + converter.convert( + definition, + msg, + () => {}, + {shelly_wifi_ssid: "The Internet of Shitty Things"}, + {state: {}, device, deviceExposesChanged: () => {}}, + ), + ).toStrictEqual({ + wifi_config: { + enabled: true, + ssid: "The Internet of Shitty Things", + }, + }); + expect(definition.options?.some((option) => option.name === "shelly_wifi_ssid")).toBe(true); + }); + + it("publishes full Shelly Wi-Fi config through RPC before falling back to setup-cluster reads", async () => { + const configResponse = JSON.stringify({ + id: 1, + result: { + sta: { + enable: true, + ssid: "The Internet of Shitty Things", + ipv4mode: "dhcp", + ip: null, + netmask: null, + gw: null, + nameserver: null, + }, + }, + }); + const statusResponse = JSON.stringify({ + id: 1, + result: { + status: "got ip", + sta_ip: "192.168.1.230", + ssid: "The Internet of Shitty Things", + }, + }); + const responses = [configResponse, statusResponse]; + let responseIndex = 0; + let currentResponse = ""; + const read = vi.fn((cluster: string, attributes: string[]) => { + if (cluster === "shellyRPCCluster" && attributes.includes("rxCtl")) { + currentResponse = responses[responseIndex++]; + return Promise.resolve({rxCtl: currentResponse.length}); + } + if (cluster === "shellyRPCCluster" && attributes.includes("data")) return Promise.resolve({data: currentResponse}); + return Promise.resolve({}); + }); + const publish = vi.fn(); + const device = mockShelly2PMCover(read); + const definition = await findByDevice(device); + const converter = definition.toZigbee?.find((c) => c.key.includes("wifi_config")); + assert(converter?.convertGet); + + await converter.convertGet(device.getEndpoint(239), "wifi_config", { + device, + state: {}, + publish, + } as unknown as Tz.Meta); + + expect(publish).toHaveBeenCalledWith({ + dhcp_enabled: true, + ip_address: "192.168.1.230", + wifi_config: { + enabled: true, + ssid: "The Internet of Shitty Things", + }, + wifi_status: "got ip", + }); + expect(device.meta.shelly_wifi_ssid).toBe("The Internet of Shitty Things"); + expect(device.getEndpoint(239).write).toHaveBeenCalledWith( + "shellyRPCCluster", + {data: expect.stringContaining("Wifi.GetConfig")}, + expect.any(Object), + ); + expect(device.getEndpoint(239).write).toHaveBeenCalledWith( + "shellyRPCCluster", + {data: expect.stringContaining("Wifi.GetStatus")}, + expect.any(Object), + ); + expect(read).not.toHaveBeenCalledWith("shellyWiFiSetupCluster", ["status", "ip", "enabled", "dhcp", "ssid"], expect.any(Object)); + }); + + it("uses a long Shelly RPC data read timeout so delayed chunks do not advance unread", async () => { + const configResponse = JSON.stringify({ + id: 1, + result: { + sta: { + enable: true, + ssid: "The Internet of Shitty Things", + ipv4mode: "dhcp", + }, + }, + }); + const statusResponse = JSON.stringify({ + id: 1, + result: { + status: "got ip", + sta_ip: "192.168.1.230", + }, + }); + const responses = [configResponse, statusResponse]; + let currentResponse = ""; + let chunks: string[] = []; + const read = vi.fn((cluster: string, attributes: string[]) => { + if (cluster === "shellyRPCCluster" && attributes.includes("rxCtl")) { + currentResponse = responses.shift() ?? ""; + chunks = [currentResponse.slice(0, 20), currentResponse.slice(20)]; + return Promise.resolve({rxCtl: currentResponse.length}); + } + if (cluster === "shellyRPCCluster" && attributes.includes("data")) { + return Promise.resolve({data: chunks.shift()}); + } + return Promise.resolve({}); + }); + const publish = vi.fn(); + const device = mockShelly2PMCover(read); + const definition = await findByDevice(device); + const converter = definition.toZigbee?.find((c) => c.key.includes("wifi_config")); + assert(converter?.convertGet); + + await converter.convertGet(device.getEndpoint(239), "wifi_config", { + device, + state: {}, + publish, + } as unknown as Tz.Meta); + + expect(publish).toHaveBeenCalledWith({ + dhcp_enabled: true, + ip_address: "192.168.1.230", + wifi_config: { + enabled: true, + ssid: "The Internet of Shitty Things", + }, + wifi_status: "got ip", + }); + expect(read).toHaveBeenCalledWith("shellyRPCCluster", ["data"], expect.objectContaining({timeout: 10000})); + expect(read.mock.calls.filter(([cluster, attributes]) => cluster === "shellyRPCCluster" && attributes.includes("data"))).toHaveLength(4); + expect(read).not.toHaveBeenCalledWith("shellyWiFiSetupCluster", ["status", "ip", "enabled", "dhcp", "ssid"], expect.any(Object)); + }); + + it("publishes the configured full Shelly Wi-Fi SSID when RPC readback fails", async () => { + const read = vi.fn((cluster: string, attributes: string[]) => { + if (cluster === "shellyRPCCluster" && attributes.includes("rxCtl")) throw new Error("RPC unavailable"); + return Promise.resolve({}); + }); + const publish = vi.fn(); + const device = mockShelly2PMCover(read); + const definition = await findByDevice(device); + const converter = definition.toZigbee?.find((c) => c.key.includes("wifi_config")); + assert(converter?.convertGet); + + await converter.convertGet(device.getEndpoint(239), "wifi_config", { + device, + options: {shelly_wifi_ssid: "The Internet of Shitty Things"}, + state: {}, + publish, + } as unknown as Tz.Meta); + + expect(publish).toHaveBeenCalledWith({ + wifi_config: { + ssid: "The Internet of Shitty Things", + }, + }); + expect(read).not.toHaveBeenCalledWith("shellyWiFiSetupCluster", ["status", "ip", "enabled", "dhcp", "ssid"], expect.any(Object)); + }); + + it("does not fail a get when both Shelly Wi-Fi readback paths are unavailable", async () => { + const read = vi.fn((cluster: string, attributes: string[]) => { + if (cluster === "shellyRPCCluster" && attributes.includes("rxCtl")) throw new Error("RPC unavailable"); + if (cluster === "shellyWiFiSetupCluster" && attributes.includes("status")) throw new Error("setup cluster unavailable"); + return Promise.resolve({}); + }); + const publish = vi.fn(); + const device = mockShelly2PMCover(read); + const definition = await findByDevice(device); + const converter = definition.toZigbee?.find((c) => c.key.includes("wifi_config")); + assert(converter?.convertGet); + + await expect( + converter.convertGet(device.getEndpoint(239), "wifi_config", { + device, + state: {}, + publish, + } as unknown as Tz.Meta), + ).resolves.toBeUndefined(); + + expect(device.getEndpoint(239).write).toHaveBeenCalledWith("shellyWiFiSetupCluster", {actionCode: 0}, expect.any(Object)); + expect(read).toHaveBeenCalledWith("shellyWiFiSetupCluster", ["status", "ip", "enabled", "dhcp", "ssid"], expect.any(Object)); + expect(publish).not.toHaveBeenCalled(); + }); +}); + describe("Shelly Presence Gen4", () => { const mockShellyPresence = (read?: ReturnType) => mockDevice({