Skip to content

Commit b99c20f

Browse files
committed
Use Shelly RPC for dynamic cover and presence features
1 parent d5594b2 commit b99c20f

2 files changed

Lines changed: 136 additions & 3 deletions

File tree

src/devices/shelly.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ const NS = "zhc:shelly";
1919
const HA_ELECTRICAL_MEASUREMENT_CLUSTER_ID = 0x0b04;
2020
const HA_ELECTRICAL_MEASUREMENT_POWER_FACTOR_ATTR_ID = 0x0510;
2121

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+
2230
const shellyPresenceEndpointNames = (device: Zh.Device | DummyDevice): string[] => {
2331
const count =
2432
!utils.isDummyDevice(device) && typeof device.meta.presence_zone_count === "number"
@@ -381,6 +389,25 @@ const shellyModernExtend = {
381389
}),
382390
];
383391
},
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+
},
384411
shellyPresenceOccupancy(): ModernExtend {
385412
const exposesFn: DefinitionExposesFunction = (device) => {
386413
return shellyPresenceEndpointNames(device).map((endpointName) => e.occupancy().withAccess(ea.STATE_GET).withEndpoint(endpointName));
@@ -421,6 +448,7 @@ const shellyModernExtend = {
421448
const featurePowerstripPowerOnBehavior = features.includes("PowerstripPowerOnBehavior");
422449
const featureTwoPMInputMode = features.includes("2PMInputMode");
423450
const featureOnePMInputMode = features.includes("1PMInputMode");
451+
const featureCoverTiltAuto = features.includes("CoverTiltAuto");
424452
const featurePresenceZonesAuto = features.includes("PresenceZonesAuto");
425453

426454
// Generic helper functions
@@ -825,6 +853,26 @@ const shellyModernExtend = {
825853
}
826854
});
827855
}
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+
}
828876
if (featurePresenceZonesAuto) {
829877
configure.push(async (device) => {
830878
const ep = device.getEndpoint(SHELLY_ENDPOINT_ID);
@@ -1592,9 +1640,9 @@ export const definitions: DefinitionWithExtend[] = [
15921640
],
15931641
extend: [
15941642
m.deviceEndpoints({endpoints: {sw1: 2, sw2: 3}}),
1595-
m.windowCovering({controls: ["lift", "tilt"]}),
1643+
shellyModernExtend.shellyWindowCovering(),
15961644
...shellyModernExtend.shellyCustomClusters(),
1597-
shellyModernExtend.shellyRPCSetup(["2PMInputMode"]),
1645+
shellyModernExtend.shellyRPCSetup(["2PMInputMode", "CoverTiltAuto"]),
15981646
shellyModernExtend.shellyWiFiSetup(),
15991647
],
16001648
configure: async (device, coordinatorEndpoint) => {

test/shelly.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,93 @@
1+
import assert from "node:assert";
12
import {describe, expect, it, vi} from "vitest";
23
import {findByDevice} from "../src/index";
3-
import type {DefinitionExposesFunction} from "../src/lib/types";
4+
import type {DefinitionExposesFunction, Expose} from "../src/lib/types";
45
import {mockDevice} from "./utils";
56

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+
691
describe("Shelly Presence Gen4", () => {
792
const mockShellyPresence = (read?: ReturnType<typeof vi.fn>) =>
893
mockDevice({

0 commit comments

Comments
 (0)