@@ -12,12 +12,30 @@ const ea = exposes.access;
1212
1313const SHELLY_ENDPOINT_ID = 239 ;
1414const SHELLY_OPTIONS = { profileId : ZSpec . CUSTOM_SHELLY_PROFILE_ID } ;
15+ const SHELLY_PRESENCE_MAX_ZONES = 10 ;
1516
1617const NS = "zhc:shelly" ;
1718
1819const HA_ELECTRICAL_MEASUREMENT_CLUSTER_ID = 0x0b04 ;
1920const HA_ELECTRICAL_MEASUREMENT_POWER_FACTOR_ATTR_ID = 0x0510 ;
2021
22+ const checkOption = ( device : Zh . Device | DummyDevice , options : KeyValue , key : string , defaultValue = false ) : boolean => {
23+ if ( options ?. [ key ] === "true" ) return true ;
24+ if ( options ?. [ key ] === "false" ) return false ;
25+ if ( ! utils . isDummyDevice ( device ) && device . meta [ key ] !== undefined ) return ! ! device . meta [ key ] ;
26+
27+ return defaultValue ;
28+ } ;
29+
30+ const shellyPresenceEndpointNames = ( device : Zh . Device | DummyDevice ) : string [ ] => {
31+ const count =
32+ ! utils . isDummyDevice ( device ) && typeof device . meta . presence_zone_count === "number"
33+ ? Math . min ( Math . max ( Math . trunc ( device . meta . presence_zone_count ) , 1 ) , SHELLY_PRESENCE_MAX_ZONES )
34+ : SHELLY_PRESENCE_MAX_ZONES ;
35+
36+ return Array . from ( { length : count } , ( _ , index ) => String ( index + 1 ) ) ;
37+ } ;
38+
2139interface ShellyRPC {
2240 attributes : {
2341 data : string ;
@@ -371,6 +389,56 @@ const shellyModernExtend = {
371389 } ) ,
372390 ] ;
373391 } ,
392+ shellyWindowCovering ( ) : ModernExtend {
393+ const result = m . windowCovering ( { controls : [ "lift" , "tilt" ] } ) ;
394+ const tiltOption = e
395+ . enum ( "cover_tilt_enabled" , ea . SET , [ "auto" , "true" , "false" ] )
396+ . withDescription ( "Expose tilt/slat controls for covers with Shelly slat control enabled" ) ;
397+ const exposesFn : DefinitionExposesFunction = ( device , options ) => {
398+ const cover = e . cover ( ) . withPosition ( ) ;
399+ if ( checkOption ( device , options , "cover_tilt_enabled" ) ) {
400+ cover . withTilt ( ) ;
401+ }
402+
403+ return [ cover ] ;
404+ } ;
405+
406+ result . exposes = [ exposesFn ] ;
407+ result . options = [ ...( result . options ?? [ ] ) , tiltOption ] ;
408+
409+ return result ;
410+ } ,
411+ shellyPresenceOccupancy ( ) : ModernExtend {
412+ const exposesFn : DefinitionExposesFunction = ( device ) => {
413+ return shellyPresenceEndpointNames ( device ) . map ( ( endpointName ) => e . occupancy ( ) . withAccess ( ea . STATE_GET ) . withEndpoint ( endpointName ) ) ;
414+ } ;
415+
416+ const fromZigbee : Fz . Converter < "msOccupancySensing" > [ ] = [
417+ {
418+ cluster : "msOccupancySensing" ,
419+ type : [ "attributeReport" , "readResponse" ] ,
420+ convert : ( model , msg , publish , options , meta ) => {
421+ if ( ! ( "occupancy" in msg . data ) ) return ;
422+
423+ const endpointName = utils . getEndpointName ( msg , model , meta ) . toString ( ) ;
424+ if ( ! shellyPresenceEndpointNames ( meta . device ) . includes ( endpointName ) ) return ;
425+
426+ return { [ utils . postfixWithEndpointName ( "occupancy" , msg , model , meta ) ] : ( msg . data . occupancy & 1 ) > 0 } ;
427+ } ,
428+ } ,
429+ ] ;
430+
431+ const toZigbee : Tz . Converter [ ] = [
432+ {
433+ key : [ "occupancy" ] ,
434+ convertGet : async ( entity , key , meta ) => {
435+ await determineEndpoint ( entity , meta , "msOccupancySensing" ) . read ( "msOccupancySensing" , [ "occupancy" ] ) ;
436+ } ,
437+ } ,
438+ ] ;
439+
440+ return { exposes : [ exposesFn ] , fromZigbee, toZigbee, isModernExtend : true } ;
441+ } ,
374442 shellyRPCSetup ( features : string [ ] = [ ] ) : ModernExtend {
375443 // Set helper variables
376444 const shellyRPCBugFixed = false ; // For firmware 20250819-150402/ga0def2d
@@ -380,6 +448,8 @@ const shellyModernExtend = {
380448 const featurePowerstripPowerOnBehavior = features . includes ( "PowerstripPowerOnBehavior" ) ;
381449 const featureTwoPMInputMode = features . includes ( "2PMInputMode" ) ;
382450 const featureOnePMInputMode = features . includes ( "1PMInputMode" ) ;
451+ const featureCoverTiltAuto = features . includes ( "CoverTiltAuto" ) ;
452+ const featurePresenceZonesAuto = features . includes ( "PresenceZonesAuto" ) ;
383453
384454 // Generic helper functions
385455 const validateTime = ( value : string ) => {
@@ -423,13 +493,33 @@ const shellyModernExtend = {
423493
424494 const rpcSend = async ( endpoint : Zh . Endpoint | Zh . Group , method : string , params : object = undefined ) => {
425495 const command = {
426- id : 1 , // We can't read replies anyway so don't care for now
496+ id : 1 ,
427497 method : method ,
428498 params : params ,
429499 } ;
430500 return await rpcSendRaw ( endpoint , JSON . stringify ( command ) ) ;
431501 } ;
432502
503+ const rpcRequest = async ( endpoint : Zh . Endpoint | Zh . Group , method : string , params : object = undefined ) : Promise < KeyValue | undefined > => {
504+ await rpcSend ( endpoint , method , params ) ;
505+ const rxCtlResult = await endpoint . read < "shellyRPCCluster" , ShellyRPC > ( "shellyRPCCluster" , [ "rxCtl" ] , SHELLY_OPTIONS ) ;
506+ const expectedLen = rxCtlResult . rxCtl ;
507+ if ( ! expectedLen ) return undefined ;
508+
509+ let accumulated = "" ;
510+ while ( accumulated . length < expectedLen ) {
511+ const dataResult = await endpoint . read < "shellyRPCCluster" , ShellyRPC > ( "shellyRPCCluster" , [ "data" ] , {
512+ ...SHELLY_OPTIONS ,
513+ timeout : 1000 ,
514+ } ) ;
515+ if ( ! dataResult . data ) break ;
516+ accumulated += dataResult . data ;
517+ }
518+
519+ if ( accumulated . length < expectedLen ) return undefined ;
520+ return JSON . parse ( accumulated . substring ( 0 , expectedLen ) ) ;
521+ } ;
522+
433523 const rpcReceive = async ( endpoint : Zh . Endpoint | Zh . Group , key : string ) => {
434524 logger . debug ( `||| shellyRPC rpcReceive(${ key } )` , NS ) ;
435525 if ( key === "rpc_rxctl" ) {
@@ -763,6 +853,55 @@ const shellyModernExtend = {
763853 }
764854 } ) ;
765855 }
856+ if ( featureCoverTiltAuto ) {
857+ configure . push ( async ( device ) => {
858+ const ep = device . getEndpoint ( SHELLY_ENDPOINT_ID ) ;
859+ if ( ! ep ) return ;
860+ try {
861+ const response = await rpcRequest ( ep , "Cover.GetConfig" , { id : 0 } ) ;
862+ if ( ! response ?. result ) return ;
863+ assertObject < KeyValue > ( response . result ) ;
864+ if ( ! response . result . slat ) return ;
865+ assertObject < KeyValue > ( response . result . slat ) ;
866+ const enabled = response . result . slat . enable ;
867+ if ( typeof enabled === "boolean" && device . meta . cover_tilt_enabled !== enabled ) {
868+ device . meta . cover_tilt_enabled = enabled ;
869+ device . save ( ) ;
870+ }
871+ } catch ( e ) {
872+ logger . warning ( `Failed to read cover_tilt_enabled during configure, use manual option to override: ${ e } ` , NS ) ;
873+ }
874+ } ) ;
875+ }
876+ if ( featurePresenceZonesAuto ) {
877+ configure . push ( async ( device ) => {
878+ const ep = device . getEndpoint ( SHELLY_ENDPOINT_ID ) ;
879+ if ( ! ep ) return ;
880+ try {
881+ const response = await rpcRequest ( ep , "Shelly.GetConfig" ) ;
882+ const result = response ?. result ?? response ?. params ?? response ;
883+ if ( ! result ) return ;
884+ assertObject < KeyValue > ( result ) ;
885+
886+ let zoneCount = Object . keys ( result ) . filter ( ( key ) => / ^ p r e s e n c e z o n e : \d + $ / . test ( key ) ) . length ;
887+ if ( zoneCount === 0 ) {
888+ const presenceConfig = result . presence ;
889+ if ( presenceConfig ) {
890+ assertObject < KeyValue > ( presenceConfig ) ;
891+ zoneCount = typeof presenceConfig . main_zone === "string" ? 1 : 0 ;
892+ }
893+ }
894+ if ( zoneCount < 1 || zoneCount > SHELLY_PRESENCE_MAX_ZONES ) return ;
895+
896+ if ( device . meta . presence_zone_count !== zoneCount ) {
897+ device . meta . presence_zone_count = zoneCount ;
898+ device . save ( ) ;
899+ }
900+ } catch ( e ) {
901+ logger . warning ( `Failed to read presence zones during configure, using last known or default exposed zones: ${ e } ` , NS ) ;
902+ }
903+ } ) ;
904+ }
766905 return { exposes, fromZigbee, toZigbee, configure, isModernExtend : true } ;
767906 } ,
768907 shellyWiFiSetup ( ) : ModernExtend {
@@ -1501,9 +1640,9 @@ export const definitions: DefinitionWithExtend[] = [
15011640 ] ,
15021641 extend : [
15031642 m . deviceEndpoints ( { endpoints : { sw1 : 2 , sw2 : 3 } } ) ,
1504- m . windowCovering ( { controls : [ "lift" , "tilt" ] } ) ,
1643+ shellyModernExtend . shellyWindowCovering ( ) ,
15051644 ...shellyModernExtend . shellyCustomClusters ( ) ,
1506- shellyModernExtend . shellyRPCSetup ( [ "2PMInputMode" ] ) ,
1645+ shellyModernExtend . shellyRPCSetup ( [ "2PMInputMode" , "CoverTiltAuto" ] ) ,
15071646 shellyModernExtend . shellyWiFiSetup ( ) ,
15081647 ] ,
15091648 configure : async ( device , coordinatorEndpoint ) => {
@@ -1987,19 +2126,11 @@ export const definitions: DefinitionWithExtend[] = [
19872126 description : "Presence Gen4 Zigbee" ,
19882127 extend : [
19892128 m . deviceEndpoints ( { endpoints : { "1" : 1 , "2" : 2 , "3" : 3 , "4" : 4 , "5" : 5 , "6" : 6 , "7" : 7 , "8" : 8 , "9" : 9 , "10" : 10 } } ) ,
1990- m . occupancy ( { reporting : false , endpointNames : [ "1" ] } ) ,
1991- m . occupancy ( { reporting : false , endpointNames : [ "2" ] } ) ,
1992- m . occupancy ( { reporting : false , endpointNames : [ "3" ] } ) ,
1993- m . occupancy ( { reporting : false , endpointNames : [ "4" ] } ) ,
1994- m . occupancy ( { reporting : false , endpointNames : [ "5" ] } ) ,
1995- m . occupancy ( { reporting : false , endpointNames : [ "6" ] } ) ,
1996- m . occupancy ( { reporting : false , endpointNames : [ "7" ] } ) ,
1997- m . occupancy ( { reporting : false , endpointNames : [ "8" ] } ) ,
1998- m . occupancy ( { reporting : false , endpointNames : [ "9" ] } ) ,
1999- m . occupancy ( { reporting : false , endpointNames : [ "10" ] } ) ,
2129+ shellyModernExtend . shellyPresenceOccupancy ( ) ,
20002130 ...shellyModernExtend . shellyLightLevel ( ) ,
20012131 m . identify ( ) ,
20022132 ...shellyModernExtend . shellyCustomClusters ( ) ,
2133+ shellyModernExtend . shellyRPCSetup ( [ "PresenceZonesAuto" ] ) ,
20032134 shellyModernExtend . shellyWiFiSetup ( ) ,
20042135 ] ,
20052136 } ,
0 commit comments