Skip to content

Commit f57a7c0

Browse files
committed
Fix Shelly Wi-Fi readback via RPC fallback
1 parent b4a9b54 commit f57a7c0

2 files changed

Lines changed: 428 additions & 72 deletions

File tree

src/devices/shelly.ts

Lines changed: 209 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const ea = exposes.access;
1313
const SHELLY_ENDPOINT_ID = 239;
1414
const SHELLY_OPTIONS = {profileId: ZSpec.CUSTOM_SHELLY_PROFILE_ID};
1515
const SHELLY_PRESENCE_MAX_ZONES = 10;
16+
const SHELLY_RPC_DATA_READ_TIMEOUT = 10000;
1617

1718
const NS = "zhc:shelly";
1819

@@ -63,6 +64,24 @@ interface ShellyRPC {
6364
commandResponses: never;
6465
}
6566

67+
interface ShellyWiFiSetup {
68+
attributes: {
69+
status: string;
70+
ip: string;
71+
actionCode: number;
72+
dhcp: boolean;
73+
enabled: boolean;
74+
ssid: string;
75+
password: string;
76+
staticIp: string;
77+
netMask: string;
78+
gateway: string;
79+
nameServer: string;
80+
};
81+
commands: never;
82+
commandResponses: never;
83+
}
84+
6685
interface ShellyWS90Wind {
6786
attributes: {
6887
windSpeed: number;
@@ -111,6 +130,79 @@ interface ShellyLightLevel {
111130
commandResponses: never;
112131
}
113132

133+
let shellyRpcSending = false;
134+
135+
const shellyRpcLock = async <T>(callback: () => Promise<T>): Promise<T> => {
136+
// Since RPC messages require multiple writes to complete, we have to make sure
137+
// we're not interleaving request/response transactions accidentally.
138+
while (shellyRpcSending) {
139+
await sleep(200);
140+
}
141+
try {
142+
shellyRpcSending = true;
143+
return await callback();
144+
} finally {
145+
shellyRpcSending = false;
146+
}
147+
};
148+
149+
const shellyRpcSendRawUnlocked = async (endpoint: Zh.Endpoint | Zh.Group, message: string) => {
150+
const splitBytes = 40;
151+
152+
logger.debug(">>> shellyRPC write TxCtl", NS);
153+
const txCtl = message.length;
154+
await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {txCtl: txCtl}, SHELLY_OPTIONS);
155+
logger.debug(`>>> TxCtl: ${txCtl}`, NS);
156+
157+
logger.debug(">>> shellyRPC write Data", NS);
158+
let dataToSend = message;
159+
while (dataToSend.length > 0) {
160+
const data = dataToSend.substring(0, splitBytes);
161+
dataToSend = dataToSend.substring(splitBytes);
162+
await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {data: data}, SHELLY_OPTIONS);
163+
logger.debug(`>>> Data: ${data}`, NS);
164+
}
165+
};
166+
167+
const shellyRpcSendRaw = async (endpoint: Zh.Endpoint | Zh.Group, message: string) =>
168+
shellyRpcLock(async () => await shellyRpcSendRawUnlocked(endpoint, message));
169+
170+
const shellyRpcSend = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined) => {
171+
const command = {
172+
id: 1,
173+
method: method,
174+
params: params,
175+
};
176+
return await shellyRpcSendRaw(endpoint, JSON.stringify(command));
177+
};
178+
179+
const shellyRpcRequest = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined): Promise<KeyValue | undefined> => {
180+
return await shellyRpcLock(async () => {
181+
const command = {
182+
id: 1,
183+
method: method,
184+
params: params,
185+
};
186+
await shellyRpcSendRawUnlocked(endpoint, JSON.stringify(command));
187+
const rxCtlResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["rxCtl"], SHELLY_OPTIONS);
188+
const expectedLen = rxCtlResult.rxCtl;
189+
if (!expectedLen) return undefined;
190+
191+
let accumulated = "";
192+
while (accumulated.length < expectedLen) {
193+
const dataResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["data"], {
194+
...SHELLY_OPTIONS,
195+
timeout: SHELLY_RPC_DATA_READ_TIMEOUT,
196+
});
197+
if (!dataResult.data) break;
198+
accumulated += dataResult.data;
199+
}
200+
201+
if (accumulated.length < expectedLen) return undefined;
202+
return JSON.parse(accumulated.substring(0, expectedLen));
203+
});
204+
};
205+
114206
// =============================================================================
115207
// WS90 Weather Station - Calculated Values (stored in device.meta for persistence)
116208
// =============================================================================
@@ -476,66 +568,9 @@ const shellyModernExtend = {
476568
}
477569
};
478570

479-
// RPC helper functions
480-
let rpcSending = false;
481-
482-
const rpcSendRaw = async (endpoint: Zh.Endpoint | Zh.Group, message: string) => {
483-
// Since RPC messages require multiple writes to complete, we have to make sure
484-
// we're not interleaving them accidentally. This is good enough for now, at least
485-
// until the RPC receive firmware bug is fixed by Shelly.
486-
while (rpcSending) {
487-
await sleep(200);
488-
}
489-
try {
490-
rpcSending = true;
491-
const splitBytes = 40;
492-
493-
logger.debug(">>> shellyRPC write TxCtl", NS);
494-
const txCtl = message.length;
495-
await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {txCtl: txCtl}, SHELLY_OPTIONS);
496-
logger.debug(`>>> TxCtl: ${txCtl}`, NS);
497-
498-
logger.debug(">>> shellyRPC write Data", NS);
499-
let dataToSend = message;
500-
while (dataToSend.length > 0) {
501-
const data = dataToSend.substring(0, splitBytes);
502-
dataToSend = dataToSend.substring(splitBytes);
503-
await endpoint.write<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", {data: data}, SHELLY_OPTIONS);
504-
logger.debug(`>>> Data: ${data}`, NS);
505-
}
506-
} finally {
507-
rpcSending = false;
508-
}
509-
};
510-
511-
const rpcSend = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined) => {
512-
const command = {
513-
id: 1,
514-
method: method,
515-
params: params,
516-
};
517-
return await rpcSendRaw(endpoint, JSON.stringify(command));
518-
};
519-
520-
const rpcRequest = async (endpoint: Zh.Endpoint | Zh.Group, method: string, params: object = undefined): Promise<KeyValue | undefined> => {
521-
await rpcSend(endpoint, method, params);
522-
const rxCtlResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["rxCtl"], SHELLY_OPTIONS);
523-
const expectedLen = rxCtlResult.rxCtl;
524-
if (!expectedLen) return undefined;
525-
526-
let accumulated = "";
527-
while (accumulated.length < expectedLen) {
528-
const dataResult = await endpoint.read<"shellyRPCCluster", ShellyRPC>("shellyRPCCluster", ["data"], {
529-
...SHELLY_OPTIONS,
530-
timeout: 1000,
531-
});
532-
if (!dataResult.data) break;
533-
accumulated += dataResult.data;
534-
}
535-
536-
if (accumulated.length < expectedLen) return undefined;
537-
return JSON.parse(accumulated.substring(0, expectedLen));
538-
};
571+
const rpcSendRaw = shellyRpcSendRaw;
572+
const rpcSend = shellyRpcSend;
573+
const rpcRequest = shellyRpcRequest;
539574

540575
const rpcReceive = async (endpoint: Zh.Endpoint | Zh.Group, key: string) => {
541576
logger.debug(`||| shellyRPC rpcReceive(${key})`, NS);
@@ -946,12 +981,96 @@ const shellyModernExtend = {
946981
return {exposes, fromZigbee, toZigbee, configure, isModernExtend: true};
947982
},
948983
shellyWiFiSetup(): ModernExtend {
949-
// biome-ignore lint/suspicious/noExplicitAny: generic
950-
const refresh = async (endpoint: any) => {
951-
await endpoint.write("shellyWiFiSetupCluster", {actionCode: 0}, SHELLY_OPTIONS);
952-
await endpoint.read("shellyWiFiSetupCluster", ["status", "ip", "enabled", "dhcp", "ssid"], SHELLY_OPTIONS);
953-
await endpoint.read("shellyWiFiSetupCluster", ["staticIp", "netMask"], SHELLY_OPTIONS);
954-
await endpoint.read("shellyWiFiSetupCluster", ["gateway", "nameServer"], SHELLY_OPTIONS);
984+
const normalizeWifiString = (value: unknown): string | undefined => (typeof value === "string" && value !== "" ? value : undefined);
985+
const getKnownFullWifiSsid = (device: Zh.Device, options?: KeyValue): string | undefined => {
986+
if (typeof options?.shelly_wifi_ssid === "string" && options.shelly_wifi_ssid !== "") return options.shelly_wifi_ssid;
987+
if (typeof device.meta.shelly_wifi_ssid === "string" && device.meta.shelly_wifi_ssid !== "") return device.meta.shelly_wifi_ssid;
988+
return undefined;
989+
};
990+
const cacheFullWifiSsid = (device: Zh.Device, ssid: unknown) => {
991+
if (typeof ssid === "string" && ssid !== "" && device.meta.shelly_wifi_ssid !== ssid) {
992+
device.meta.shelly_wifi_ssid = ssid;
993+
device.save();
994+
}
995+
};
996+
const rpcResult = (response: KeyValue | undefined): KeyValue | undefined => {
997+
const result = response?.result ?? response?.params ?? response;
998+
if (!result) return undefined;
999+
assertObject<KeyValue>(result);
1000+
return result;
1001+
};
1002+
const readWifiStateViaRpc = async (endpoint: Zh.Endpoint): Promise<KeyValue | undefined> => {
1003+
const config = rpcResult(await shellyRpcRequest(endpoint, "Wifi.GetConfig"));
1004+
const status = rpcResult(await shellyRpcRequest(endpoint, "Wifi.GetStatus"));
1005+
const state: KeyValue = {};
1006+
const wifiConfig: KeyValue = {};
1007+
1008+
const sta = config?.sta;
1009+
if (sta) {
1010+
assertObject<KeyValue>(sta);
1011+
if (typeof sta.enable === "boolean") wifiConfig.enabled = sta.enable;
1012+
if (typeof sta.ssid === "string") wifiConfig.ssid = sta.ssid;
1013+
if (typeof sta.ipv4mode === "string") state.dhcp_enabled = sta.ipv4mode === "dhcp";
1014+
if (typeof sta.ip === "string") wifiConfig.static_ip = normalizeWifiString(sta.ip);
1015+
if (typeof sta.netmask === "string") wifiConfig.net_mask = normalizeWifiString(sta.netmask);
1016+
if (typeof sta.gw === "string") wifiConfig.gateway = normalizeWifiString(sta.gw);
1017+
if (typeof sta.nameserver === "string") wifiConfig.name_server = normalizeWifiString(sta.nameserver);
1018+
}
1019+
1020+
if (status) {
1021+
if (typeof status.status === "string") state.wifi_status = status.status;
1022+
if (typeof status.sta_ip === "string") state.ip_address = normalizeWifiString(status.sta_ip);
1023+
if (wifiConfig.ssid === undefined && typeof status.ssid === "string") wifiConfig.ssid = status.ssid;
1024+
}
1025+
1026+
if (Object.keys(wifiConfig).length > 0) {
1027+
state.wifi_config = wifiConfig;
1028+
}
1029+
1030+
return Object.keys(state).length > 0 ? state : undefined;
1031+
};
1032+
const refresh = async (endpoint: Zh.Endpoint, meta?: Tz.Meta) => {
1033+
let published = false;
1034+
if (meta) {
1035+
try {
1036+
const rpcState = await readWifiStateViaRpc(endpoint);
1037+
const ssid = rpcState?.wifi_config;
1038+
if (ssid && utils.isObject(ssid)) {
1039+
cacheFullWifiSsid(endpoint.getDevice(), ssid.ssid);
1040+
}
1041+
if (rpcState) {
1042+
meta.publish(rpcState);
1043+
published = true;
1044+
}
1045+
} catch (e) {
1046+
logger.debug(`Failed to read Wi-Fi state through Shelly RPC, falling back to setup cluster: ${e}`, NS);
1047+
const ssid = getKnownFullWifiSsid(endpoint.getDevice(), meta.options);
1048+
if (ssid) {
1049+
meta.publish({wifi_config: {ssid}});
1050+
published = true;
1051+
}
1052+
}
1053+
}
1054+
1055+
if (published) {
1056+
return;
1057+
}
1058+
1059+
try {
1060+
await endpoint.write<"shellyWiFiSetupCluster", ShellyWiFiSetup>("shellyWiFiSetupCluster", {actionCode: 0}, SHELLY_OPTIONS);
1061+
await endpoint.read<"shellyWiFiSetupCluster", ShellyWiFiSetup>(
1062+
"shellyWiFiSetupCluster",
1063+
["status", "ip", "enabled", "dhcp", "ssid"],
1064+
SHELLY_OPTIONS,
1065+
);
1066+
await endpoint.read<"shellyWiFiSetupCluster", ShellyWiFiSetup>("shellyWiFiSetupCluster", ["staticIp", "netMask"], SHELLY_OPTIONS);
1067+
await endpoint.read<"shellyWiFiSetupCluster", ShellyWiFiSetup>("shellyWiFiSetupCluster", ["gateway", "nameServer"], SHELLY_OPTIONS);
1068+
if (meta) {
1069+
published = true;
1070+
}
1071+
} catch (e) {
1072+
logger.warning(`Failed to read Wi-Fi state through Shelly setup cluster; leaving previous state unchanged: ${e}`, NS);
1073+
}
9551074
};
9561075

9571076
const exposes: Expose[] = [
@@ -1006,7 +1125,14 @@ const shellyModernExtend = {
10061125

10071126
// Wi-Fi config
10081127
if (msg.data.enabled !== undefined) wifi_config.enabled = msg.data.enabled === 1;
1009-
if (msg.data.ssid !== undefined) wifi_config.ssid = msg.data.ssid;
1128+
if (msg.data.ssid !== undefined) {
1129+
const reportedSsid = normalizeWifiString(msg.data.ssid);
1130+
const knownFullSsid = getKnownFullWifiSsid(meta.device, options);
1131+
wifi_config.ssid = knownFullSsid && reportedSsid && knownFullSsid.startsWith(reportedSsid) ? knownFullSsid : reportedSsid;
1132+
if (reportedSsid && (!knownFullSsid || reportedSsid.length > knownFullSsid.length)) {
1133+
cacheFullWifiSsid(meta.device, reportedSsid);
1134+
}
1135+
}
10101136
if (msg.data.staticIp !== undefined) wifi_config.static_ip = msg.data.staticIp;
10111137
if (msg.data.netMask !== undefined) wifi_config.net_mask = msg.data.netMask;
10121138
if (msg.data.gateway !== undefined) wifi_config.gateway = msg.data.gateway;
@@ -1028,15 +1154,19 @@ const shellyModernExtend = {
10281154
{
10291155
key: ["wifi_status", "ip_address", "dhcp_enabled"],
10301156
convertGet: async (entity, key, meta) => {
1031-
const ep = determineEndpoint(entity, meta, "shellyWiFiSetupCluster");
1032-
await refresh(ep);
1157+
utils.assertEndpoint(entity);
1158+
const ep = entity.getDevice().getEndpoint(SHELLY_ENDPOINT_ID);
1159+
if (!ep) throw new Error(`Shelly endpoint ${SHELLY_ENDPOINT_ID} not found`);
1160+
await refresh(ep, meta);
10331161
},
10341162
},
10351163
{
10361164
key: ["wifi_config"],
10371165
convertGet: async (entity, key, meta) => {
1038-
const ep = determineEndpoint(entity, meta, "shellyWiFiSetupCluster");
1039-
await refresh(ep);
1166+
utils.assertEndpoint(entity);
1167+
const ep = entity.getDevice().getEndpoint(SHELLY_ENDPOINT_ID);
1168+
if (!ep) throw new Error(`Shelly endpoint ${SHELLY_ENDPOINT_ID} not found`);
1169+
await refresh(ep, meta);
10401170
},
10411171
convertSet: async (entity, key, value, meta) => {
10421172
assertObject<KeyValue>(value);
@@ -1089,11 +1219,18 @@ const shellyModernExtend = {
10891219
const configure: Configure[] = [
10901220
async (device, coordinatorEndpoint, definition) => {
10911221
const ep = device.getEndpoint(SHELLY_ENDPOINT_ID);
1222+
if (!ep) return;
10921223
await refresh(ep);
10931224
},
10941225
];
10951226

1096-
return {exposes, fromZigbee, toZigbee, configure, isModernExtend: true};
1227+
const options = [
1228+
e
1229+
.text("shelly_wifi_ssid", ea.SET)
1230+
.withDescription("Full Wi-Fi SSID to use when the Shelly Wi-Fi setup cluster reports a shortened network name"),
1231+
];
1232+
1233+
return {exposes, fromZigbee, toZigbee, configure, options, isModernExtend: true};
10971234
},
10981235
ws90CalculatedValues(): ModernExtend {
10991236
const exposes: Expose[] = [

0 commit comments

Comments
 (0)