Skip to content

Commit 8e4b6d2

Browse files
committed
Only offer split devices that can host the device automation
1 parent 402aeaa commit 8e4b6d2

8 files changed

Lines changed: 227 additions & 79 deletions

File tree

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

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ 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,
1514
deviceAutomationsEqual,
16-
findEquivalentDeviceAutomation,
1715
sortDeviceAutomations,
1816
} from "../../data/device/device_automation";
1917
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -181,12 +179,15 @@ export abstract class HaDeviceAutomationPicker<
181179
(a, idx) => value === `${a.device_id}_${idx}`
182180
);
183181

184-
const text = automation
182+
const described =
183+
automation ?? (this.value?.domain ? this.value : undefined);
184+
185+
const text = described
185186
? this._localizeDeviceAutomation(
186187
this.hass.localize,
187188
this.hass.states,
188189
this._entityReg,
189-
automation
190+
described
190191
)
191192
: value === NO_AUTOMATION_KEY
192193
? this.NO_AUTOMATION_TEXT
@@ -196,47 +197,31 @@ export abstract class HaDeviceAutomationPicker<
196197
};
197198

198199
private async _updateDeviceInfo() {
200+
// Asking a removed device for its automations fails rather than returning
201+
// an empty list.
199202
this._automations = this.deviceId
200203
? (
201-
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
204+
await this._fetchDeviceAutomations(
205+
this.hass.callWS,
206+
this.deviceId
207+
).catch(() => [] as T[])
202208
).sort(sortDeviceAutomations)
203209
: // No device, clear the list of automations
204210
[];
205211

212+
// If there is no value, or if we have changed the device ID, reset the value.
206213
if (!this.value || this.value.device_id !== this.deviceId) {
207-
this._updateValueForDevice();
214+
this._setValue(
215+
this._automations.length
216+
? this._automations[0]
217+
: this._createNoAutomation(this.deviceId)
218+
);
208219
}
209220
this._renderEmpty = true;
210221
await this.updateComplete;
211222
this._renderEmpty = false;
212223
}
213224

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-
240225
private _automationChanged(ev: ValueChangedEvent<string>) {
241226
ev.stopPropagation();
242227
const value = ev.detail.value;

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ export class HaDevicePicker extends LitElement {
105105
@property({ attribute: "hide-clear-icon", type: Boolean })
106106
public hideClearIcon = false;
107107

108+
/**
109+
* The split devices that can actually replace the current value, when the
110+
* caller knows better than this picker. Narrows the replacement candidates,
111+
* so the user is only asked to choose when there is a real choice.
112+
*/
113+
@property({ attribute: false })
114+
public replacementDeviceIds?: string[];
115+
108116
@query("ha-generic-picker") private _picker?: HaGenericPicker;
109117

110118
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -188,7 +196,8 @@ export class HaDevicePicker extends LitElement {
188196
value: string | undefined,
189197
_devices: HomeAssistant["devices"],
190198
compositeSplits: DeviceCompositeSplits | undefined,
191-
items: (DevicePickerItem | string)[]
199+
items: (DevicePickerItem | string)[],
200+
replacementDeviceIds: string[] | undefined
192201
) => {
193202
if (!value || !compositeSplits || this.hass.devices[value]) {
194203
return undefined;
@@ -204,7 +213,11 @@ export class HaDevicePicker extends LitElement {
204213
.filter((item): item is DevicePickerItem => typeof item !== "string")
205214
.map((item) => item.id)
206215
);
207-
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
216+
const candidates = split.split_ids.filter(
217+
(id) =>
218+
selectableIds.has(id) &&
219+
(!replacementDeviceIds || replacementDeviceIds.includes(id))
220+
);
208221
return { candidates, primaryId: split.primary_id };
209222
}
210223
);
@@ -400,7 +413,8 @@ export class HaDevicePicker extends LitElement {
400413
this.value,
401414
this.hass.devices,
402415
this._compositeSplits,
403-
this._getItems()
416+
this._getItems(),
417+
this.replacementDeviceIds
404418
)
405419
: undefined;
406420

@@ -507,7 +521,8 @@ export class HaDevicePicker extends LitElement {
507521
this.value,
508522
this.hass.devices,
509523
this._compositeSplits,
510-
this._getItems()
524+
this._getItems(),
525+
this.replacementDeviceIds
511526
);
512527
if (!replacement?.candidates.length) {
513528
return;

src/data/device/device_automation.ts

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -182,17 +182,13 @@ export const deviceAutomationEditorMode = (
182182
: "unknown-device";
183183
};
184184

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.
189185
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
190186
deviceAutomationIdentifiers
191187
.filter((property) => property !== "device_id" && property !== "entity_id")
192188
.every((property) => Object.is(a[property], b[property]));
193189

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.
190+
// An entity can be referenced by its registry id or by its entity id, and the
191+
// two sides do not have to agree.
196192
const deviceAutomationsSameEntity = (
197193
entityRegistry: EntityRegistryEntry[],
198194
a: DeviceAutomation,
@@ -210,42 +206,45 @@ const deviceAutomationsSameEntity = (
210206
);
211207
};
212208

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.
209+
// A device exposes the same automation type once per entity, so the same type
210+
// on another entity is a different automation, not an equivalent one.
219211
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
220212
entityRegistry: EntityRegistryEntry[],
221213
automations: T[],
222214
automation: DeviceAutomation
223-
): T | undefined => {
224-
const sameType = automations.filter((candidate) =>
225-
deviceAutomationsSameType(candidate, automation)
215+
): T | undefined =>
216+
automations.find(
217+
(candidate) =>
218+
deviceAutomationsSameType(candidate, automation) &&
219+
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
226220
);
227-
const sameEntity = sameType.find((candidate) =>
228-
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
229-
);
230-
return sameEntity || sameType[0];
231-
};
232221

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;
222+
// Among the split devices that replaced a removed device, the ones that offer
223+
// the given automation. Nothing in the registry says which of them took it over,
224+
// so each candidate has to be asked.
225+
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
226+
hass: HomeAssistant,
227+
entityRegistry: EntityRegistryEntry[],
228+
automation: DeviceAutomation,
229+
compositeSplits: DeviceCompositeSplits,
230+
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
231+
): Promise<string[]> => {
232+
const candidates =
233+
compositeSplits[automation.device_id]?.split_ids.filter(
234+
(id) => id in hass.devices
235+
) ?? [];
236+
const automationsPerCandidate = await Promise.all(
237+
candidates.map((id) =>
238+
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
239+
)
240+
);
241+
return candidates.filter((_id, index) =>
242+
findEquivalentDeviceAutomation(
243+
entityRegistry,
244+
automationsPerCandidate[index],
245+
automation
246+
)
247+
);
249248
};
250249

251250
const compareEntityIdWithEntityRegId = (

src/panels/config/automation/action/types/ha-automation-action-device_id.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
} from "../../../../../data/device/device_automation";
1515
import {
1616
deviceAutomationEditorMode,
17+
fetchReplacementDevices,
18+
fetchDeviceActions,
1719
deviceAutomationsEqual,
1820
fetchDeviceActionCapabilities,
1921
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceAction extends LitElement {
4042

4143
@state() private _compositeSplits?: DeviceCompositeSplits;
4244

45+
@state() private _replacementDeviceIds?: string[];
46+
4347
private _loadingCompositeSplits = false;
4448

4549
@state()
@@ -101,7 +105,17 @@ export class HaDeviceAction extends LitElement {
101105
}
102106
this._loadingCompositeSplits = true;
103107
try {
104-
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
108+
// Resolve the candidates before exposing the split map, so the picker
109+
// never offers one that cannot host the automation.
110+
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
111+
this._replacementDeviceIds = await fetchReplacementDevices(
112+
this.hass,
113+
this._entityReg,
114+
this.action,
115+
compositeSplits,
116+
fetchDeviceActions
117+
);
118+
this._compositeSplits = compositeSplits;
105119
} catch (_err) {
106120
this._compositeSplits = {};
107121
} finally {
@@ -115,6 +129,7 @@ export class HaDeviceAction extends LitElement {
115129
return html`
116130
<ha-device-picker
117131
.value=${deviceId}
132+
.replacementDeviceIds=${this._replacementDeviceIds}
118133
.disabled=${this.disabled}
119134
@value-changed=${this._devicePicked}
120135
.hass=${this.hass}
@@ -185,6 +200,15 @@ export class HaDeviceAction extends LitElement {
185200

186201
private _devicePicked(ev) {
187202
ev.stopPropagation();
203+
// The automation exists as is on the replacement, so only the reference
204+
// changes and the rest of the configuration is left untouched.
205+
if (this._replacementDeviceIds?.includes(ev.target.value)) {
206+
this._deviceId = undefined;
207+
fireEvent(this, "value-changed", {
208+
value: { ...this.action, device_id: ev.target.value },
209+
});
210+
return;
211+
}
188212
this._deviceId = ev.target.value;
189213
if (this._deviceId === undefined) {
190214
fireEvent(this, "value-changed", {

src/panels/config/automation/condition/types/ha-automation-condition-device.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {
1414
} from "../../../../../data/device/device_automation";
1515
import {
1616
deviceAutomationEditorMode,
17+
fetchReplacementDevices,
18+
fetchDeviceConditions,
1719
deviceAutomationsEqual,
1820
fetchDeviceConditionCapabilities,
1921
localizeExtraFieldsComputeHelperCallback,
@@ -40,6 +42,8 @@ export class HaDeviceCondition extends LitElement {
4042

4143
@state() private _compositeSplits?: DeviceCompositeSplits;
4244

45+
@state() private _replacementDeviceIds?: string[];
46+
4347
private _loadingCompositeSplits = false;
4448

4549
@state()
@@ -102,7 +106,17 @@ export class HaDeviceCondition extends LitElement {
102106
}
103107
this._loadingCompositeSplits = true;
104108
try {
105-
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
109+
// Resolve the candidates before exposing the split map, so the picker
110+
// never offers one that cannot host the automation.
111+
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
112+
this._replacementDeviceIds = await fetchReplacementDevices(
113+
this.hass,
114+
this._entityReg,
115+
this.condition,
116+
compositeSplits,
117+
fetchDeviceConditions
118+
);
119+
this._compositeSplits = compositeSplits;
106120
} catch (_err) {
107121
this._compositeSplits = {};
108122
} finally {
@@ -116,6 +130,7 @@ export class HaDeviceCondition extends LitElement {
116130
return html`
117131
<ha-device-picker
118132
.value=${deviceId}
133+
.replacementDeviceIds=${this._replacementDeviceIds}
119134
@value-changed=${this._devicePicked}
120135
.hass=${this.hass}
121136
.disabled=${this.disabled}
@@ -187,6 +202,15 @@ export class HaDeviceCondition extends LitElement {
187202

188203
private _devicePicked(ev) {
189204
ev.stopPropagation();
205+
// The automation exists as is on the replacement, so only the reference
206+
// changes and the rest of the configuration is left untouched.
207+
if (this._replacementDeviceIds?.includes(ev.target.value)) {
208+
this._deviceId = undefined;
209+
fireEvent(this, "value-changed", {
210+
value: { ...this.condition, device_id: ev.target.value },
211+
});
212+
return;
213+
}
190214
this._deviceId = ev.target.value;
191215
if (this._deviceId === undefined) {
192216
fireEvent(this, "value-changed", {

src/panels/config/automation/target/ha-automation-row-targets.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,12 @@ export class HaAutomationRowTargets extends LitElement {
479479
warning = true;
480480
badgeTargetId = undefined;
481481
badgeTargetType = undefined;
482-
if (targetType === "device" && this._compositeSplits?.[targetId]) {
482+
if (
483+
targetType === "device" &&
484+
this._compositeSplits?.[targetId]?.split_ids.some(
485+
(id) => id in this._registries.devices
486+
)
487+
) {
483488
// The device was replaced by one or more split devices; make clear
484489
// this reference needs to be updated, distinct from "unknown device".
485490
icon = mdiSwapHorizontal;

0 commit comments

Comments
 (0)