Compare commits

...

6 Commits

Author SHA1 Message Date
Petar Petrov e0b93c2cd4 Only let core pick the tilt direction for unknown covers 2026-07-30 10:12:38 +03:00
Petar Petrov 86f459d2f7 Support tilt-only covers when toggling groups of entities 2026-07-29 08:57:37 +03:00
Petar Petrov 62089c1649 Only fall back to toggle_cover_tilt when no direction can be derived 2026-07-29 08:56:49 +03:00
Petar Petrov c6565d6d76 Prefer directional tilt services when the cover state is known 2026-07-28 17:23:01 +03:00
Petar Petrov e5e6b6f28f Allow toggle in the action editor for tilt-only covers 2026-07-28 16:56:46 +03:00
Petar Petrov 73ea61395a Use toggle_cover_tilt when toggling tilt-only covers 2026-07-28 16:35:02 +03:00
12 changed files with 427 additions and 25 deletions
+9
View File
@@ -1,4 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../types";
import { canToggleDomain } from "./can_toggle_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -26,6 +28,13 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
return false;
}
// Tilt-only covers toggle via toggle_cover_tilt, which requires both tilt features
if (domain === "cover" && isTiltOnly(stateObj)) {
return [CoverEntityFeature.OPEN_TILT, CoverEntityFeature.CLOSE_TILT].every(
(f) => supportsFeature(stateObj, f)
);
}
if (
domain in SPECIAL_TOGGLE_ACTIONS &&
SPECIAL_TOGGLE_ACTIONS[domain].feature
+11 -1
View File
@@ -1,3 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
@@ -63,7 +65,15 @@ export const SPECIAL_TOGGLE_ACTIONS: Record<string, SpecialToggleAction> = {
// This function assumes that the passed domain can toggle, it may otherwise
// return a service that does not exist.
export const getToggleAction = (domain: string, onOff: boolean): string => {
export const getToggleAction = (
domain: string,
onOff: boolean,
stateObj?: HassEntity
): string => {
// Tilt-only covers don't support open_cover/close_cover
if (domain === "cover" && stateObj && isTiltOnly(stateObj)) {
return onOff ? "open_cover_tilt" : "close_cover_tilt";
}
return (
SPECIAL_TOGGLE_ACTIONS[domain]?.[onOff ? "on" : "off"] ||
SPECIAL_TOGGLE_ACTIONS[domain]?.["on"] ||
+35 -10
View File
@@ -1,8 +1,21 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
import { getToggleAction } from "./get_toggle_action";
import { supportsFeature } from "./supports-feature";
// Tilt-only covers can only stop their tilt, and may not support it at all
const getStopAction = (stateObj: HassEntity): string | undefined => {
if (!isTiltOnly(stateObj)) {
return "stop_cover";
}
return supportsFeature(stateObj, CoverEntityFeature.STOP_TILT)
? "stop_cover_tilt"
: undefined;
};
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
if (!states.length) {
@@ -58,17 +71,29 @@ export const toggleGroupEntities = (
const isOn = state === "on" || state === "open";
let service = getToggleAction(domain, !isOn);
if (domain === "cover") {
if (state === "opening" || state === "closing") {
// If the cover is opening or closing, we toggle it to stop it
service = "stop_cover";
// If the cover is opening or closing, we toggle it to stop it
const stopping =
domain === "cover" && (state === "opening" || state === "closing");
// The service can differ per entity, e.g. for tilt-only covers,
// so group the entities by the service they need
const entityIdsByService: Record<string, string[]> = {};
states.forEach((stateObj) => {
const service = stopping
? getStopAction(stateObj)
: getToggleAction(domain, !isOn, stateObj);
if (!service) {
return;
}
}
if (!(service in entityIdsByService)) {
entityIdsByService[service] = [];
}
entityIdsByService[service].push(stateObj.entity_id);
});
const entitiesIds = states.map((stateObj) => stateObj.entity_id);
hass.callService(domain, service, {
entity_id: entitiesIds,
Object.entries(entityIdsByService).forEach(([service, entityIds]) => {
hass.callService(domain, service, {
entity_id: entityIds,
});
});
};
+1 -1
View File
@@ -128,7 +128,7 @@ export class HaEntityToggle extends LitElement {
const serviceDomain =
stateDomain === "group" ? "homeassistant" : stateDomain;
const service = getToggleAction(stateDomain, turnOn);
const service = getToggleAction(stateDomain, turnOn, this.stateObj);
const currentState = this.stateObj;
@@ -1,4 +1,7 @@
import { STATES_OFF } from "../../../../common/const";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { isTiltOnly } from "../../../../data/cover";
import { UNKNOWN } from "../../../../data/entity/entity";
import type { HomeAssistant, ServiceCallResponse } from "../../../../types";
import { turnOnOffEntity } from "./turn-on-off-entity";
@@ -6,6 +9,20 @@ export const toggleEntity = (
hass: HomeAssistant,
entityId: string
): Promise<ServiceCallResponse> => {
const turnOn = STATES_OFF.includes(hass.states[entityId].state);
const stateObj = hass.states[entityId];
// Tilt-only covers may not report a state to pick a direction from,
// let core pick one based on the tilt position instead.
if (
computeDomain(entityId) === "cover" &&
isTiltOnly(stateObj) &&
stateObj.state === UNKNOWN
) {
return hass.callService("cover", "toggle_cover_tilt", {
entity_id: entityId,
});
}
const turnOn = STATES_OFF.includes(stateObj.state);
return turnOnOffEntity(hass, entityId, turnOn);
};
@@ -8,27 +8,31 @@ export const turnOnOffEntities = (
entityIds: string[],
turnOn = true
): void => {
const domainsToCall = {};
const callsToMake: Record<
string,
{ domain: string; service: string; entityIds: string[] }
> = {};
entityIds.forEach((entityId) => {
if (STATES_OFF.includes(hass.states[entityId].state) === turnOn) {
const stateObj = hass.states[entityId];
if (STATES_OFF.includes(stateObj.state) === turnOn) {
const stateDomain = computeDomain(entityId);
// Entities with non-standard toggle action need separate calls
const serviceDomain =
const domain =
getToggleAction(stateDomain, true) !== "turn_on"
? stateDomain
: "homeassistant";
// The service can differ per entity, e.g. for tilt-only covers
const service = getToggleAction(domain, turnOn, stateObj);
if (!(serviceDomain in domainsToCall)) {
domainsToCall[serviceDomain] = [];
const key = `${domain}.${service}`;
if (!(key in callsToMake)) {
callsToMake[key] = { domain, service, entityIds: [] };
}
domainsToCall[serviceDomain].push(entityId);
callsToMake[key].entityIds.push(entityId);
}
});
Object.keys(domainsToCall).forEach((domain) => {
const service = getToggleAction(domain, turnOn);
const entities = domainsToCall[domain];
hass.callService(domain, service, { entity_id: entities });
Object.values(callsToMake).forEach(({ domain, service, entityIds: ids }) => {
hass.callService(domain, service, { entity_id: ids });
});
};
@@ -10,7 +10,7 @@ export const turnOnOffEntity = (
const stateDomain = computeDomain(entityId);
const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain;
const service = getToggleAction(stateDomain, turnOn);
const service = getToggleAction(stateDomain, turnOn, hass.states[entityId]);
return hass.callService(serviceDomain, service, { entity_id: entityId });
};
@@ -2,6 +2,7 @@ import { assert, describe, it } from "vitest";
import { canToggleState } from "../../../src/common/entity/can_toggle_state";
import { ClimateEntityFeature } from "../../../src/data/climate";
import { CoverEntityFeature } from "../../../src/data/feature/cover_entity_feature";
describe("canToggleState", () => {
const hass: any = {
@@ -66,6 +67,39 @@ describe("canToggleState", () => {
assert.isFalse(canToggleState(hass, stateObj));
});
it("Detects cover with toggle", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features: CoverEntityFeature.OPEN + CoverEntityFeature.CLOSE,
},
};
assert.isTrue(canToggleState(hass, stateObj));
});
it("Detects tilt-only cover with toggle", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features:
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT +
CoverEntityFeature.SET_TILT_POSITION,
},
};
assert.isTrue(canToggleState(hass, stateObj));
});
it("Detects tilt-only cover without toggle", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features: CoverEntityFeature.STOP_TILT,
},
};
assert.isFalse(canToggleState(hass, stateObj));
});
it("Detects group with missing entity", () => {
const stateObj: any = {
entity_id: "group.bla",
@@ -0,0 +1,55 @@
import { assert, describe, it } from "vitest";
import { getToggleAction } from "../../../src/common/entity/get_toggle_action";
import { CoverEntityFeature } from "../../../src/data/feature/cover_entity_feature";
describe("getToggleAction", () => {
it("Uses turn_on/turn_off for default domains", () => {
assert.strictEqual(getToggleAction("light", true), "turn_on");
assert.strictEqual(getToggleAction("light", false), "turn_off");
});
it("Uses open_cover/close_cover for covers without a state object", () => {
assert.strictEqual(getToggleAction("cover", true), "open_cover");
assert.strictEqual(getToggleAction("cover", false), "close_cover");
});
it("Uses open_cover/close_cover for covers supporting open/close", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features:
CoverEntityFeature.OPEN +
CoverEntityFeature.CLOSE +
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT,
},
};
assert.strictEqual(getToggleAction("cover", true, stateObj), "open_cover");
assert.strictEqual(
getToggleAction("cover", false, stateObj),
"close_cover"
);
});
it("Uses tilt services for tilt-only covers", () => {
const stateObj: any = {
entity_id: "cover.bla",
state: "open",
attributes: {
supported_features:
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT +
CoverEntityFeature.SET_TILT_POSITION,
},
};
assert.strictEqual(
getToggleAction("cover", true, stateObj),
"open_cover_tilt"
);
assert.strictEqual(
getToggleAction("cover", false, stateObj),
"close_cover_tilt"
);
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it, vi } from "vitest";
import { toggleGroupEntities } from "../../../src/common/entity/group_entities";
import { CoverEntityFeature } from "../../../src/data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../../src/types";
const cover = (
entityId: string,
state: string,
supportedFeatures: number
): HassEntity =>
({
entity_id: entityId,
state,
attributes: { supported_features: supportedFeatures },
}) as HassEntity;
const TILT_ONLY =
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT +
CoverEntityFeature.STOP_TILT;
const OPEN_CLOSE = CoverEntityFeature.OPEN + CoverEntityFeature.CLOSE;
const mockHass = () => {
const callService = vi.fn();
return { hass: { callService } as unknown as HomeAssistant, callService };
};
describe("toggleGroupEntities", () => {
it("Uses tilt services for a group of tilt-only covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [cover("cover.tilt", "closed", TILT_ONLY)]);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Splits the call when a group mixes tilt-only and regular covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.regular", "closed", OPEN_CLOSE),
cover("cover.tilt", "closed", TILT_ONLY),
]);
expect(callService).toHaveBeenCalledTimes(2);
expect(callService).toHaveBeenCalledWith("cover", "open_cover", {
entity_id: ["cover.regular"],
});
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Stops the tilt of tilt-only covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.regular", "opening", OPEN_CLOSE),
cover("cover.tilt", "closed", TILT_ONLY),
]);
expect(callService).toHaveBeenCalledTimes(2);
expect(callService).toHaveBeenCalledWith("cover", "stop_cover", {
entity_id: ["cover.regular"],
});
expect(callService).toHaveBeenCalledWith("cover", "stop_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Skips tilt-only covers that can't stop", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.regular", "opening", OPEN_CLOSE),
cover(
"cover.tilt",
"closed",
CoverEntityFeature.OPEN_TILT + CoverEntityFeature.CLOSE_TILT
),
]);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "stop_cover", {
entity_id: ["cover.regular"],
});
});
it("Uses a single call for a group of regular covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.one", "open", OPEN_CLOSE),
cover("cover.two", "open", OPEN_CLOSE),
]);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "close_cover", {
entity_id: ["cover.one", "cover.two"],
});
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { CoverEntityFeature } from "../../../../../src/data/feature/cover_entity_feature";
import { toggleEntity } from "../../../../../src/panels/lovelace/common/entity/toggle-entity";
import type { HomeAssistant } from "../../../../../src/types";
const TILT_ONLY = CoverEntityFeature.OPEN_TILT + CoverEntityFeature.CLOSE_TILT;
const mockHass = (state: string, supportedFeatures = TILT_ONLY) => {
const callService = vi.fn();
return {
hass: {
states: {
"cover.tilt": {
entity_id: "cover.tilt",
state,
attributes: { supported_features: supportedFeatures },
},
},
callService,
} as unknown as HomeAssistant,
callService,
};
};
describe("toggleEntity", () => {
it("Opens the tilt of a closed tilt-only cover", () => {
const { hass, callService } = mockHass("closed");
toggleEntity(hass, "cover.tilt");
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: "cover.tilt",
});
});
it("Closes the tilt of an open tilt-only cover", () => {
const { hass, callService } = mockHass("open");
toggleEntity(hass, "cover.tilt");
expect(callService).toHaveBeenCalledWith("cover", "close_cover_tilt", {
entity_id: "cover.tilt",
});
});
it("Lets core pick the direction when the tilt-only cover state is unknown", () => {
const { hass, callService } = mockHass("unknown");
toggleEntity(hass, "cover.tilt");
expect(callService).toHaveBeenCalledWith("cover", "toggle_cover_tilt", {
entity_id: "cover.tilt",
});
});
it("Uses open/close for covers supporting it", () => {
const { hass, callService } = mockHass(
"closed",
CoverEntityFeature.OPEN + CoverEntityFeature.CLOSE
);
toggleEntity(hass, "cover.tilt");
expect(callService).toHaveBeenCalledWith("cover", "open_cover", {
entity_id: "cover.tilt",
});
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import { CoverEntityFeature } from "../../../../../src/data/feature/cover_entity_feature";
import { turnOnOffEntities } from "../../../../../src/panels/lovelace/common/entity/turn-on-off-entities";
import type { HomeAssistant } from "../../../../../src/types";
const TILT_ONLY = CoverEntityFeature.OPEN_TILT + CoverEntityFeature.CLOSE_TILT;
const OPEN_CLOSE = CoverEntityFeature.OPEN + CoverEntityFeature.CLOSE;
const mockHass = (states: Record<string, any>) => {
const callService = vi.fn();
return {
hass: { states, callService } as unknown as HomeAssistant,
callService,
};
};
describe("turnOnOffEntities", () => {
it("Batches standard domains into a single homeassistant call", () => {
const { hass, callService } = mockHass({
"light.one": { entity_id: "light.one", state: "off", attributes: {} },
"switch.two": { entity_id: "switch.two", state: "off", attributes: {} },
});
turnOnOffEntities(hass, ["light.one", "switch.two"], true);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("homeassistant", "turn_on", {
entity_id: ["light.one", "switch.two"],
});
});
it("Uses tilt services for tilt-only covers", () => {
const { hass, callService } = mockHass({
"cover.tilt": {
entity_id: "cover.tilt",
state: "closed",
attributes: { supported_features: TILT_ONLY },
},
});
turnOnOffEntities(hass, ["cover.tilt"], true);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Splits the call when covers need different services", () => {
const { hass, callService } = mockHass({
"cover.regular": {
entity_id: "cover.regular",
state: "closed",
attributes: { supported_features: OPEN_CLOSE },
},
"cover.tilt": {
entity_id: "cover.tilt",
state: "closed",
attributes: { supported_features: TILT_ONLY },
},
});
turnOnOffEntities(hass, ["cover.regular", "cover.tilt"], true);
expect(callService).toHaveBeenCalledTimes(2);
expect(callService).toHaveBeenCalledWith("cover", "open_cover", {
entity_id: ["cover.regular"],
});
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Skips entities already in the requested state", () => {
const { hass, callService } = mockHass({
"light.on": { entity_id: "light.on", state: "on", attributes: {} },
"light.off": { entity_id: "light.off", state: "off", attributes: {} },
});
turnOnOffEntities(hass, ["light.on", "light.off"], true);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("homeassistant", "turn_on", {
entity_id: ["light.off"],
});
});
});