Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions src/devices/shelly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const NS = "zhc:shelly";
const HA_ELECTRICAL_MEASUREMENT_CLUSTER_ID = 0x0b04;
const HA_ELECTRICAL_MEASUREMENT_POWER_FACTOR_ATTR_ID = 0x0510;

const checkOption = (device: Zh.Device | DummyDevice, options: KeyValue, key: string, defaultValue = false): boolean => {
if (options?.[key] === "true") return true;
if (options?.[key] === "false") return false;
if (!utils.isDummyDevice(device) && device.meta[key] !== undefined) return !!device.meta[key];

return defaultValue;
};

const shellyPresenceEndpointNames = (device: Zh.Device | DummyDevice): string[] => {
const count =
!utils.isDummyDevice(device) && typeof device.meta.presence_zone_count === "number"
Expand Down Expand Up @@ -381,6 +389,25 @@ const shellyModernExtend = {
}),
];
},
shellyWindowCovering(): ModernExtend {
const result = m.windowCovering({controls: ["lift", "tilt"]});
const tiltOption = e
.enum("cover_tilt_enabled", ea.SET, ["auto", "true", "false"])
.withDescription("Expose tilt/slat controls for covers with Shelly slat control enabled");
const exposesFn: DefinitionExposesFunction = (device, options) => {
const cover = e.cover().withPosition();
if (checkOption(device, options, "cover_tilt_enabled")) {
cover.withTilt();
}

return [cover];
};

result.exposes = [exposesFn];
result.options = [...(result.options ?? []), tiltOption];

return result;
},
shellyPresenceOccupancy(): ModernExtend {
const exposesFn: DefinitionExposesFunction = (device) => {
return shellyPresenceEndpointNames(device).map((endpointName) => e.occupancy().withAccess(ea.STATE_GET).withEndpoint(endpointName));
Expand Down Expand Up @@ -421,6 +448,7 @@ const shellyModernExtend = {
const featurePowerstripPowerOnBehavior = features.includes("PowerstripPowerOnBehavior");
const featureTwoPMInputMode = features.includes("2PMInputMode");
const featureOnePMInputMode = features.includes("1PMInputMode");
const featureCoverTiltAuto = features.includes("CoverTiltAuto");
const featurePresenceZonesAuto = features.includes("PresenceZonesAuto");

// Generic helper functions
Expand Down Expand Up @@ -825,6 +853,27 @@ const shellyModernExtend = {
}
});
}
if (featureCoverTiltAuto) {
configure.push(async (device) => {
const ep = device.getEndpoint(SHELLY_ENDPOINT_ID);
if (!ep) return;
try {
const response = await rpcRequest(ep, "Cover.GetConfig", {id: 0});
const result = response?.result ?? response?.params ?? response;
if (!result) return;
assertObject<KeyValue>(result);
if (!result.slat) return;
assertObject<KeyValue>(result.slat);
const enabled = result.slat.enable;
if (typeof enabled === "boolean" && device.meta.cover_tilt_enabled !== enabled) {
device.meta.cover_tilt_enabled = enabled;
device.save();
}
} catch (e) {
logger.warning(`Failed to read cover_tilt_enabled during configure, use manual option to override: ${e}`, NS);
}
});
}
if (featurePresenceZonesAuto) {
configure.push(async (device) => {
const ep = device.getEndpoint(SHELLY_ENDPOINT_ID);
Expand Down Expand Up @@ -1592,9 +1641,9 @@ export const definitions: DefinitionWithExtend[] = [
],
extend: [
m.deviceEndpoints({endpoints: {sw1: 2, sw2: 3}}),
m.windowCovering({controls: ["lift", "tilt"]}),
shellyModernExtend.shellyWindowCovering(),
...shellyModernExtend.shellyCustomClusters(),
shellyModernExtend.shellyRPCSetup(["2PMInputMode"]),
shellyModernExtend.shellyRPCSetup(["2PMInputMode", "CoverTiltAuto"]),
shellyModernExtend.shellyWiFiSetup(),
],
configure: async (device, coordinatorEndpoint) => {
Expand Down
124 changes: 123 additions & 1 deletion test/shelly.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,130 @@
import assert from "node:assert";
import {describe, expect, it, vi} from "vitest";
import {findByDevice} from "../src/index";
import type {DefinitionExposesFunction} from "../src/lib/types";
import type {DefinitionExposesFunction, Expose} from "../src/lib/types";
import {mockDevice} from "./utils";

const getFeatureNames = (expose: Expose): string[] => {
return "features" in expose ? expose.features.map((feature) => feature.name) : [];
};

describe("Shelly 2PM Gen4 cover mode", () => {
const mockShelly2PMCover = (read?: ReturnType<typeof vi.fn>) =>
mockDevice({
modelID: "2PM",
manufacturerName: "Shelly",
endpoints: [
{ID: 1, profileID: 260, deviceID: 514, inputClusterIDs: [0, 3, 4, 5, 258], outputClusterIDs: []},
{ID: 239, profileID: 49153, deviceID: 8193, inputClusterIDs: [64513, 64514], outputClusterIDs: [], read},
{ID: 242, profileID: 41440, deviceID: 97, inputClusterIDs: [], outputClusterIDs: [33]},
],
});

it("exposes lift-only covers by default and opt-in tilt controls", async () => {
const device = mockShelly2PMCover();
const definition = await findByDevice(device);
expect(definition.model).toBe("S4SW-002P16EU-COVER");
expect(typeof definition.exposes).toBe("function");
const exposes = definition.exposes as DefinitionExposesFunction;

const defaultCover = exposes(device, {}).find((expose) => expose.type === "cover");
const tiltCover = exposes(device, {cover_tilt_enabled: "true"}).find((expose) => expose.type === "cover");
device.meta.cover_tilt_enabled = true;
const autoDetectedTiltCover = exposes(device, {cover_tilt_enabled: "auto"}).find((expose) => expose.type === "cover");
assert(defaultCover);
assert(tiltCover);
assert(autoDetectedTiltCover);

expect(getFeatureNames(defaultCover)).toContain("position");
expect(getFeatureNames(defaultCover)).not.toContain("tilt");
expect(getFeatureNames(tiltCover)).toContain("position");
expect(getFeatureNames(tiltCover)).toContain("tilt");
expect(getFeatureNames(autoDetectedTiltCover)).toContain("tilt");
expect(definition.options?.some((option) => option.name === "cover_tilt_enabled")).toBe(true);
});

it("persists Shelly slat control from Cover.GetConfig during configure", async () => {
const response = JSON.stringify({id: 1, result: {id: 0, slat: {enable: true}}});
const read = vi
.fn()
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: response.length})
.mockResolvedValueOnce({data: response});
const device = mockShelly2PMCover(read);
const save = vi.spyOn(device, "save").mockImplementation(() => {});
const definition = await findByDevice(device);

await definition.configure?.(device, mockShelly2PMCover().getEndpoint(1), definition);

expect(device.meta.cover_tilt_enabled).toBe(true);
expect(save).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledWith("shellyRPCCluster", ["rxCtl"], expect.any(Object));
expect(read).toHaveBeenCalledWith("shellyRPCCluster", ["data"], expect.any(Object));
expect(device.getEndpoint(239).write).toHaveBeenCalledWith(
"shellyRPCCluster",
{data: expect.stringContaining("Cover.GetConfig")},
expect.any(Object),
);
});

it("persists Shelly slat control from Cover.GetConfig params during configure", async () => {
const response = JSON.stringify({id: 1, params: {id: 0, slat: {enable: true}}});
const read = vi
.fn()
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: response.length})
.mockResolvedValueOnce({data: response});
const device = mockShelly2PMCover(read);
const save = vi.spyOn(device, "save").mockImplementation(() => {});
const definition = await findByDevice(device);

await definition.configure?.(device, mockShelly2PMCover().getEndpoint(1), definition);

expect(device.meta.cover_tilt_enabled).toBe(true);
expect(save).toHaveBeenCalledTimes(1);
});

it("overwrites stale persisted slat control from Cover.GetConfig during configure", async () => {
const response = JSON.stringify({id: 1, result: {id: 0, slat: {enable: false}}});
const read = vi
.fn()
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: response.length})
.mockResolvedValueOnce({data: response});
const device = mockShelly2PMCover(read);
device.meta.cover_tilt_enabled = true;
const save = vi.spyOn(device, "save").mockImplementation(() => {});
const definition = await findByDevice(device);

await definition.configure?.(device, mockShelly2PMCover().getEndpoint(1), definition);

expect(device.meta.cover_tilt_enabled).toBe(false);
expect(save).toHaveBeenCalledTimes(1);
});

it("leaves persisted slat control unchanged when Cover.GetConfig has no boolean slat flag", async () => {
const response = JSON.stringify({id: 1, result: {id: 0}});
const read = vi
.fn()
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: 0})
.mockResolvedValueOnce({rxCtl: response.length})
.mockResolvedValueOnce({data: response});
const device = mockShelly2PMCover(read);
device.meta.cover_tilt_enabled = true;
const save = vi.spyOn(device, "save").mockImplementation(() => {});
const definition = await findByDevice(device);

await definition.configure?.(device, mockShelly2PMCover().getEndpoint(1), definition);

expect(device.meta.cover_tilt_enabled).toBe(true);
expect(save).not.toHaveBeenCalled();
});
});

describe("Shelly Presence Gen4", () => {
const mockShellyPresence = (read?: ReturnType<typeof vi.fn>) =>
mockDevice({
Expand Down