Skip to content

Commit 402aeaa

Browse files
committed
Keep the automation config when replacing a device in device automations
1 parent 29cd46e commit 402aeaa

3 files changed

Lines changed: 203 additions & 32 deletions

File tree

src/components/device/ha-device-automation-picker.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import type { LocalizeFunc } from "../../common/translations/localize";
1111
import { fullEntitiesContext } from "../../data/context";
1212
import type { DeviceAutomation } from "../../data/device/device_automation";
1313
import {
14+
deviceAutomationExtraConfig,
1415
deviceAutomationsEqual,
15-
deviceAutomationsSimilar,
16+
findEquivalentDeviceAutomation,
1617
sortDeviceAutomations,
1718
} from "../../data/device/device_automation";
1819
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -202,29 +203,40 @@ export abstract class HaDeviceAutomationPicker<
202203
: // No device, clear the list of automations
203204
[];
204205

205-
// If there is no value, or if we have changed the device ID, reset the
206-
// value. When the device changed (for example after replacing a removed
207-
// device), try to keep the same automation type/subtype on the new device
208-
// before falling back to the first available automation.
209206
if (!this.value || this.value.device_id !== this.deviceId) {
210-
const equivalent =
211-
this.value && this.deviceId
212-
? this._automations.find((automation) =>
213-
deviceAutomationsSimilar(automation, this.value!)
214-
)
215-
: undefined;
216-
this._setValue(
217-
equivalent ||
218-
(this._automations.length
219-
? this._automations[0]
220-
: this._createNoAutomation(this.deviceId))
221-
);
207+
this._updateValueForDevice();
222208
}
223209
this._renderEmpty = true;
224210
await this.updateComplete;
225211
this._renderEmpty = false;
226212
}
227213

214+
// The current value belongs to another device, either because there is no
215+
// value yet or because the device was just changed. Move it to the same
216+
// automation on the new device when there is one, otherwise start over.
217+
private _updateValueForDevice() {
218+
if (this.deviceId && this.value) {
219+
const equivalent = findEquivalentDeviceAutomation(
220+
this._entityReg,
221+
this._automations!,
222+
this.value
223+
);
224+
if (equivalent) {
225+
this._setValue({
226+
...equivalent,
227+
...deviceAutomationExtraConfig(this.value),
228+
});
229+
return;
230+
}
231+
}
232+
233+
this._setValue(
234+
this._automations!.length
235+
? this._automations![0]
236+
: this._createNoAutomation(this.deviceId)
237+
);
238+
}
239+
228240
private _automationChanged(ev: ValueChangedEvent<string>) {
229241
ev.stopPropagation();
230242
const value = ev.detail.value;

src/data/device/device_automation.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -182,26 +182,70 @@ export const deviceAutomationEditorMode = (
182182
: "unknown-device";
183183
};
184184

185-
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
186-
// automation can be matched to the equivalent one on a different device (for
187-
// example when a referenced device was replaced by a split device).
188-
export const deviceAutomationsSimilar = (
185+
// Whether two device automations describe the same kind of automation, meaning
186+
// the same domain, type, subtype and event. Which device and which entity they
187+
// apply to is ignored, so an automation can be matched against the ones another
188+
// device offers.
189+
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
190+
deviceAutomationIdentifiers
191+
.filter((property) => property !== "device_id" && property !== "entity_id")
192+
.every((property) => Object.is(a[property], b[property]));
193+
194+
// Whether two device automations point at the same entity. Both the entity
195+
// registry id and the entity id are accepted as reference, on either side.
196+
const deviceAutomationsSameEntity = (
197+
entityRegistry: EntityRegistryEntry[],
189198
a: DeviceAutomation,
190199
b: DeviceAutomation
191200
) => {
192-
if (typeof a !== typeof b) {
201+
if (!a.entity_id && !b.entity_id) {
202+
return true;
203+
}
204+
if (!a.entity_id || !b.entity_id) {
193205
return false;
194206
}
195-
return deviceAutomationIdentifiers
196-
.filter((property) => property !== "device_id" && property !== "entity_id")
197-
.every((property) => {
198-
const inA = property in a;
199-
const inB = property in b;
200-
if (!inA && !inB) {
201-
return true;
202-
}
203-
return Object.is(a[property], b[property]);
204-
});
207+
return (
208+
a.entity_id === b.entity_id ||
209+
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
210+
);
211+
};
212+
213+
// Finds, among the automations a device offers, the one matching the given
214+
// automation, so a device automation can follow its device after the device was
215+
// replaced. A device exposes the same automation type once per entity, so
216+
// matching on the type alone picks an arbitrary entity. Splitting a device
217+
// leaves the entity registry ids untouched, which makes the entity the reliable
218+
// match, the type only serving as a fallback for entity-less automations.
219+
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
220+
entityRegistry: EntityRegistryEntry[],
221+
automations: T[],
222+
automation: DeviceAutomation
223+
): T | undefined => {
224+
const sameType = automations.filter((candidate) =>
225+
deviceAutomationsSameType(candidate, automation)
226+
);
227+
const sameEntity = sameType.find((candidate) =>
228+
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
229+
);
230+
return sameEntity || sameType[0];
231+
};
232+
233+
// Everything the automation list does not return: the extra fields of the
234+
// capabilities schema (`for`, `above`, ...) and the config of the row holding
235+
// the automation (`enabled`, `id`, `alias`, ...).
236+
export const deviceAutomationExtraConfig = <T extends DeviceAutomation>(
237+
automation: T
238+
): Partial<T> => {
239+
const extraConfig: Partial<T> = {};
240+
for (const property in automation) {
241+
if (
242+
property !== "metadata" &&
243+
!deviceAutomationIdentifiers.includes(property)
244+
) {
245+
extraConfig[property] = automation[property];
246+
}
247+
}
248+
return extraConfig;
205249
};
206250

207251
const compareEntityIdWithEntityRegId = (
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { DeviceTrigger } from "../../src/data/device/device_automation";
3+
import { findEquivalentDeviceAutomation } from "../../src/data/device/device_automation";
4+
import type { EntityRegistryEntry } from "../../src/data/entity/entity_registry";
5+
6+
const entityRegistry = [
7+
{ id: "regid1", entity_id: "binary_sensor.one" },
8+
{ id: "regid2", entity_id: "binary_sensor.two" },
9+
] as EntityRegistryEntry[];
10+
11+
const trigger = (partial: Partial<DeviceTrigger>): DeviceTrigger =>
12+
({
13+
trigger: "device",
14+
domain: "binary_sensor",
15+
device_id: "device1",
16+
...partial,
17+
}) as DeviceTrigger;
18+
19+
describe("findEquivalentDeviceAutomation", () => {
20+
it("picks the automation on the same entity among several of the same type", () => {
21+
const automations = [
22+
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
23+
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
24+
];
25+
26+
expect(
27+
findEquivalentDeviceAutomation(
28+
entityRegistry,
29+
automations,
30+
trigger({ type: "turned_on", entity_id: "regid2" })
31+
)
32+
).toBe(automations[1]);
33+
});
34+
35+
it("matches an entity referenced by entity id against one referenced by registry id", () => {
36+
const automations = [
37+
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
38+
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
39+
];
40+
41+
expect(
42+
findEquivalentDeviceAutomation(
43+
entityRegistry,
44+
automations,
45+
trigger({ type: "turned_on", entity_id: "binary_sensor.two" })
46+
)
47+
).toBe(automations[1]);
48+
});
49+
50+
it("falls back to the first automation of the same type when the entity is elsewhere", () => {
51+
const automations = [
52+
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
53+
trigger({
54+
device_id: "device2",
55+
type: "turned_off",
56+
entity_id: "regid1",
57+
}),
58+
];
59+
60+
expect(
61+
findEquivalentDeviceAutomation(
62+
entityRegistry,
63+
automations,
64+
trigger({ type: "turned_on", entity_id: "regid2" })
65+
)
66+
).toBe(automations[0]);
67+
});
68+
69+
it("matches entity-less automations on their subtype", () => {
70+
const automations = [
71+
trigger({
72+
device_id: "device2",
73+
domain: "zha",
74+
type: "remote_button_short_press",
75+
subtype: "button_1",
76+
}),
77+
trigger({
78+
device_id: "device2",
79+
domain: "zha",
80+
type: "remote_button_short_press",
81+
subtype: "button_2",
82+
}),
83+
];
84+
85+
expect(
86+
findEquivalentDeviceAutomation(
87+
entityRegistry,
88+
automations,
89+
trigger({
90+
domain: "zha",
91+
type: "remote_button_short_press",
92+
subtype: "button_2",
93+
})
94+
)
95+
).toBe(automations[1]);
96+
});
97+
98+
it("returns undefined when the device offers no automation of that type", () => {
99+
const automations = [
100+
trigger({
101+
device_id: "device2",
102+
type: "turned_off",
103+
entity_id: "regid1",
104+
}),
105+
];
106+
107+
expect(
108+
findEquivalentDeviceAutomation(
109+
entityRegistry,
110+
automations,
111+
trigger({ type: "turned_on", entity_id: "regid1" })
112+
)
113+
).toBeUndefined();
114+
});
115+
});

0 commit comments

Comments
 (0)