@@ -13,6 +13,7 @@ const ea = exposes.access;
1313const SHELLY_ENDPOINT_ID = 239 ;
1414const SHELLY_OPTIONS = { profileId : ZSpec . CUSTOM_SHELLY_PROFILE_ID } ;
1515const SHELLY_PRESENCE_MAX_ZONES = 10 ;
16+ const SHELLY_RPC_DATA_READ_TIMEOUT = 10000 ;
1617
1718const 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+
6685interface 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