Skip to content

Commit 2b87093

Browse files
committed
Use Shelly RPC for dynamic cover and presence features
1 parent 3e4d8c4 commit 2b87093

2 files changed

Lines changed: 305 additions & 13 deletions

File tree

src/devices/shelly.ts

Lines changed: 144 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,30 @@ const ea = exposes.access;
1212

1313
const SHELLY_ENDPOINT_ID = 239;
1414
const SHELLY_OPTIONS = {profileId: ZSpec.CUSTOM_SHELLY_PROFILE_ID};
15+
const SHELLY_PRESENCE_MAX_ZONES = 10;
1516

1617
const NS = "zhc:shelly";
1718

1819
const HA_ELECTRICAL_MEASUREMENT_CLUSTER_ID = 0x0b04;
1920
const 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+
2139
interface 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) => /^presencezone:\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
},

test/shelly.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import assert from "node:assert";
2+
import {describe, expect, it, vi} from "vitest";
3+
import {findByDevice} from "../src/index";
4+
import type {DefinitionExposesFunction, Expose} from "../src/lib/types";
5+
import {mockDevice} from "./utils";
6+
7+
const getFeatureNames = (expose: Expose): string[] => {
8+
return "features" in expose ? expose.features.map((feature) => feature.name) : [];
9+
};
10+
11+
describe("Shelly 2PM Gen4 cover mode", () => {
12+
const mockShelly2PMCover = (read?: ReturnType<typeof vi.fn>) =>
13+
mockDevice({
14+
modelID: "2PM",
15+
manufacturerName: "Shelly",
16+
endpoints: [
17+
{ID: 1, profileID: 260, deviceID: 514, inputClusterIDs: [0, 3, 4, 5, 258], outputClusterIDs: []},
18+
{ID: 239, profileID: 49153, deviceID: 8193, inputClusterIDs: [64513, 64514], outputClusterIDs: [], read},
19+
{ID: 242, profileID: 41440, deviceID: 97, inputClusterIDs: [], outputClusterIDs: [33]},
20+
],
21+
});
22+
23+
it("exposes lift-only covers by default and opt-in tilt controls", async () => {
24+
const device = mockShelly2PMCover();
25+
const definition = await findByDevice(device);
26+
expect(definition.model).toBe("S4SW-002P16EU-COVER");
27+
expect(typeof definition.exposes).toBe("function");
28+
const exposes = definition.exposes as DefinitionExposesFunction;
29+
30+
const defaultCover = exposes(device, {}).find((expose) => expose.type === "cover");
31+
const tiltCover = exposes(device, {cover_tilt_enabled: "true"}).find((expose) => expose.type === "cover");
32+
device.meta.cover_tilt_enabled = true;
33+
const autoDetectedTiltCover = exposes(device, {cover_tilt_enabled: "auto"}).find((expose) => expose.type === "cover");
34+
assert(defaultCover);
35+
assert(tiltCover);
36+
assert(autoDetectedTiltCover);
37+
38+
expect(getFeatureNames(defaultCover)).toContain("position");
39+
expect(getFeatureNames(defaultCover)).not.toContain("tilt");
40+
expect(getFeatureNames(tiltCover)).toContain("position");
41+
expect(getFeatureNames(tiltCover)).toContain("tilt");
42+
expect(getFeatureNames(autoDetectedTiltCover)).toContain("tilt");
43+
expect(definition.options?.some((option) => option.name === "cover_tilt_enabled")).toBe(true);
44+
});
45+
46+
it("persists Shelly slat control from Cover.GetConfig during configure", async () => {
47+
const response = JSON.stringify({id: 1, result: {id: 0, slat: {enable: true}}});
48+
const read = vi
49+
.fn()
50+
.mockResolvedValueOnce({rxCtl: 0})
51+
.mockResolvedValueOnce({rxCtl: 0})
52+
.mockResolvedValueOnce({rxCtl: response.length})
53+
.mockResolvedValueOnce({data: response});
54+
const device = mockShelly2PMCover(read);
55+
const save = vi.spyOn(device, "save").mockImplementation(() => {});
56+
const definition = await findByDevice(device);
57+
58+
await definition.configure?.(device, mockShelly2PMCover().getEndpoint(1), definition);
59+
60+
expect(device.meta.cover_tilt_enabled).toBe(true);
61+
expect(save).toHaveBeenCalledTimes(1);
62+
expect(read).toHaveBeenCalledWith("shellyRPCCluster", ["rxCtl"], expect.any(Object));
63+
expect(read).toHaveBeenCalledWith("shellyRPCCluster", ["data"], expect.any(Object));
64+
expect(device.getEndpoint(239).write).toHaveBeenCalledWith(
65+
"shellyRPCCluster",
66+
{data: expect.stringContaining("Cover.GetConfig")},
67+
expect.any(Object),
68+
);
69+
});
70+
71+
it("leaves persisted slat control unchanged when Cover.GetConfig has no boolean slat flag", async () => {
72+
const response = JSON.stringify({id: 1, result: {id: 0}});
73+
const read = vi
74+
.fn()
75+
.mockResolvedValueOnce({rxCtl: 0})
76+
.mockResolvedValueOnce({rxCtl: 0})
77+
.mockResolvedValueOnce({rxCtl: response.length})
78+
.mockResolvedValueOnce({data: response});
79+
const device = mockShelly2PMCover(read);
80+
device.meta.cover_tilt_enabled = true;
81+
const save = vi.spyOn(device, "save").mockImplementation(() => {});
82+
const definition = await findByDevice(device);
83+
84+
await definition.configure?.(device, mockShelly2PMCover().getEndpoint(1), definition);
85+
86+
expect(device.meta.cover_tilt_enabled).toBe(true);
87+
expect(save).not.toHaveBeenCalled();
88+
});
89+
});
90+
91+
describe("Shelly Presence Gen4", () => {
92+
const mockShellyPresence = (read?: ReturnType<typeof vi.fn>) =>
93+
mockDevice({
94+
modelID: "Presence",
95+
manufacturerName: "Shelly",
96+
endpoints: [
97+
...Array.from({length: 10}, (_, index) => ({
98+
ID: index + 1,
99+
inputClusterIDs: [0, 3, 1030, 0xfc21],
100+
outputClusterIDs: [],
101+
})),
102+
{ID: 239, profileID: 49153, deviceID: 8193, inputClusterIDs: [64513, 64514], outputClusterIDs: [], read},
103+
{ID: 242, profileID: 41440, deviceID: 97, inputClusterIDs: [], outputClusterIDs: [33]},
104+
],
105+
});
106+
107+
const occupancyEndpoints = (definition: Awaited<ReturnType<typeof findByDevice>>, device: ReturnType<typeof mockShellyPresence>) => {
108+
expect(typeof definition.exposes).toBe("function");
109+
const exposes = definition.exposes as DefinitionExposesFunction;
110+
return exposes(device, {})
111+
.filter((expose) => expose.name === "occupancy")
112+
.map((expose) => expose.endpoint);
113+
};
114+
115+
it("falls back to all possible zone endpoints until the device config is read", async () => {
116+
const device = mockShellyPresence();
117+
const definition = await findByDevice(device);
118+
119+
expect(definition.model).toBe("S4SN-0U61X");
120+
expect(occupancyEndpoints(definition, device)).toStrictEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]);
121+
});
122+
123+
it("uses the persisted PresenceZone count for occupancy exposes", async () => {
124+
const device = mockShellyPresence();
125+
device.meta.presence_zone_count = 2;
126+
const definition = await findByDevice(device);
127+
128+
expect(occupancyEndpoints(definition, device)).toStrictEqual(["1", "2"]);
129+
});
130+
131+
it("persists the PresenceZone count from Shelly.GetConfig during configure", async () => {
132+
const response = JSON.stringify({
133+
id: 1,
134+
result: {
135+
presence: {main_zone: "presencezone:200"},
136+
"presencezone:200": {id: 200, name: "Room"},
137+
"presencezone:201": {id: 201, name: "Desk"},
138+
"presencezone:202": {id: 202, name: "Door"},
139+
},
140+
});
141+
const read = vi.fn((cluster: string, attributes: string[]) => {
142+
if (cluster === "shellyRPCCluster" && attributes.includes("rxCtl")) return Promise.resolve({rxCtl: response.length});
143+
if (cluster === "shellyRPCCluster" && attributes.includes("data")) return Promise.resolve({data: response});
144+
return Promise.resolve({});
145+
});
146+
const device = mockShellyPresence(read);
147+
const save = vi.spyOn(device, "save").mockImplementation(() => {});
148+
const definition = await findByDevice(device);
149+
150+
await definition.configure?.(device, mockShellyPresence().getEndpoint(1), definition);
151+
152+
expect(device.meta.presence_zone_count).toBe(3);
153+
expect(save).toHaveBeenCalledTimes(1);
154+
expect(device.getEndpoint(239).write).toHaveBeenCalledWith(
155+
"shellyRPCCluster",
156+
{data: expect.stringContaining("Shelly.GetConfig")},
157+
expect.any(Object),
158+
);
159+
expect(occupancyEndpoints(definition, device)).toStrictEqual(["1", "2", "3"]);
160+
});
161+
});

0 commit comments

Comments
 (0)