Compare commits

...

1 Commits

Author SHA1 Message Date
Paul Bottein 824ab4fbe0 Suggest area from device name when adding devices 2026-07-24 11:52:27 +02:00
5 changed files with 301 additions and 0 deletions
@@ -0,0 +1,85 @@
import { stripBoundaryLabel } from "../string/strip_boundary_label";
export interface AreaForNameMatch {
area_id: string;
name: string;
aliases?: string[];
}
export interface DeviceAreaSuggestion {
name: string;
area?: string;
}
const areaLabels = (area: AreaForNameMatch): string[] =>
[area.name, ...(area.aliases ?? [])].filter(Boolean);
// Longest matching label (name or alias) of a single area, and the device name
// once that label is stripped off. `null` when the area does not match.
const matchArea = (
name: string,
area: AreaForNameMatch
): { strippedName: string; labelLength: number } | null => {
let best: { strippedName: string; labelLength: number } | null = null;
for (const label of areaLabels(area)) {
const strippedName = stripBoundaryLabel(name, label);
if (strippedName === null) {
continue;
}
if (!best || label.length > best.labelLength) {
best = { strippedName, labelLength: label.length };
}
}
return best;
};
// The area with the longest matching label, and the stripped device name.
const bestAreaMatch = (
name: string,
areas: AreaForNameMatch[]
): { areaId: string; strippedName: string; labelLength: number } | null => {
let best: {
areaId: string;
strippedName: string;
labelLength: number;
} | null = null;
for (const area of areas) {
const match = matchArea(name, area);
if (match && (!best || match.labelLength > best.labelLength)) {
best = { areaId: area.area_id, ...match };
}
}
return best;
};
/**
* Suggests a cleaned device name (and an area) when an area name or alias is a
* prefix or suffix of the device name.
*
* - When the device already has an area, only match against that area, so an
* assigned area is never overridden — just the name is cleaned.
* - Otherwise pick the area with the longest matching name/alias and suggest it.
* - The longest match wins with no fallback: if the name is exactly the area,
* nothing is left to keep and the result is a no-op (`null`).
*/
export const computeDeviceAreaSuggestion = (
deviceName: string | null | undefined,
currentAreaId: string | null | undefined,
areas: AreaForNameMatch[]
): DeviceAreaSuggestion | null => {
const name = deviceName?.trim();
if (!name) {
return null;
}
if (currentAreaId) {
const area = areas.find((a) => a.area_id === currentAreaId);
const match = area ? matchArea(name, area) : null;
return match?.strippedName ? { name: match.strippedName } : null;
}
const match = bestAreaMatch(name, areas);
return match?.strippedName
? { name: match.strippedName, area: match.areaId }
: null;
};
+42
View File
@@ -0,0 +1,42 @@
const SEPARATORS = "\\s\\-_.";
const LEADING_SEPARATORS = new RegExp(`^[${SEPARATORS}]+`);
const TRAILING_SEPARATORS = new RegExp(`[${SEPARATORS}]+$`);
const STARTS_WITH_SEPARATOR = new RegExp(`^[${SEPARATORS}]`);
const ENDS_WITH_SEPARATOR = new RegExp(`[${SEPARATORS}]$`);
/**
* Strips `label` from the start or end of `text` on a word boundary,
* case-insensitively, and trims the surrounding separators.
*
* Returns:
* - `""` when `text` equals `label`,
* - the trimmed remainder for a prefix or suffix match,
* - `null` when `label` is not a word-boundary prefix or suffix of `text`.
*/
export const stripBoundaryLabel = (
text: string,
label: string
): string | null => {
const lowerText = text.toLowerCase();
const lowerLabel = label.toLowerCase();
if (lowerText === lowerLabel) {
return "";
}
if (lowerText.startsWith(lowerLabel)) {
const rest = text.slice(label.length);
if (STARTS_WITH_SEPARATOR.test(rest)) {
return rest.replace(LEADING_SEPARATORS, "").trim();
}
}
if (lowerText.endsWith(lowerLabel)) {
const rest = text.slice(0, text.length - label.length);
if (ENDS_WITH_SEPARATOR.test(rest)) {
return rest.replace(TRAILING_SEPARATORS, "").trim();
}
}
return null;
};
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceAreaSuggestion } from "../../common/entity/compute_device_area_suggestion";
import {
computeDeviceName,
computeDeviceNameDisplay,
@@ -72,6 +73,10 @@ class StepFlowCreateEntry extends LitElement {
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("devices")) {
this._suggestDeviceUpdates();
}
if (!changedProps.has("devices") && !changedProps.has("hass")) {
return;
}
@@ -103,6 +108,32 @@ class StepFlowCreateEntry extends LitElement {
}
}
private _suggestDeviceUpdates() {
if (!this.hass || !this.devices?.length) {
return;
}
const areas = Object.values(this.hass.areas);
const updates = { ...this._deviceUpdate };
let changed = false;
for (const device of this.devices) {
if (device.id in updates || device.name_by_user) {
continue;
}
const suggestion = computeDeviceAreaSuggestion(
computeDeviceName(device),
device.area_id,
areas
);
if (suggestion) {
updates[device.id] = suggestion;
changed = true;
}
}
if (changed) {
this._deviceUpdate = updates;
}
}
protected render(): TemplateResult {
const localize = this.hass.localize;
const domains = this.step.result
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import {
computeDeviceAreaSuggestion,
type AreaForNameMatch,
} from "../../../src/common/entity/compute_device_area_suggestion";
const AREAS: AreaForNameMatch[] = [
{ area_id: "living_room", name: "Living Room", aliases: ["Lounge"] },
{ area_id: "kitchen", name: "Kitchen" },
{ area_id: "master", name: "Master" },
{ area_id: "master_bedroom", name: "Master Bedroom" },
];
describe("computeDeviceAreaSuggestion", () => {
describe("device without an area", () => {
it("strips a prefix area and suggests it", () => {
expect(
computeDeviceAreaSuggestion("Living Room Thermostat", null, AREAS)
).toEqual({ name: "Thermostat", area: "living_room" });
});
it("strips a suffix area and suggests it", () => {
expect(
computeDeviceAreaSuggestion("Thermostat Living Room", null, AREAS)
).toEqual({ name: "Thermostat", area: "living_room" });
});
it("matches an area alias", () => {
expect(computeDeviceAreaSuggestion("Lounge Lamp", null, AREAS)).toEqual({
name: "Lamp",
area: "living_room",
});
});
it("prefers the longest matching area name", () => {
expect(
computeDeviceAreaSuggestion("Master Bedroom Lamp", null, AREAS)
).toEqual({ name: "Lamp", area: "master_bedroom" });
});
it("is a no-op when the name equals an area and does not fall back to a shorter one", () => {
expect(
computeDeviceAreaSuggestion("Master Bedroom", null, AREAS)
).toBeNull();
});
it("does not match in the middle of a word", () => {
expect(
computeDeviceAreaSuggestion("Kitchenette Sensor", null, AREAS)
).toBeNull();
});
it("matches case-insensitively and keeps the remainder's casing", () => {
expect(
computeDeviceAreaSuggestion("living room thermostat", null, AREAS)
).toEqual({ name: "thermostat", area: "living_room" });
});
it("trims residual separators after stripping", () => {
expect(
computeDeviceAreaSuggestion("Living Room - Thermostat", null, AREAS)
).toEqual({ name: "Thermostat", area: "living_room" });
});
it("does nothing when no area matches", () => {
expect(
computeDeviceAreaSuggestion("Random Device", null, AREAS)
).toBeNull();
});
});
describe("device with an area already set", () => {
it("strips the name but keeps the area when it matches", () => {
expect(
computeDeviceAreaSuggestion("Kitchen Sensor", "kitchen", AREAS)
).toEqual({ name: "Sensor" });
});
it("never overrides the area when the name does not match it", () => {
expect(
computeDeviceAreaSuggestion("Living Room Sensor", "kitchen", AREAS)
).toBeNull();
});
it("is a no-op when the name equals its own area", () => {
expect(
computeDeviceAreaSuggestion("Kitchen", "kitchen", AREAS)
).toBeNull();
});
});
it("does nothing for an empty name", () => {
expect(computeDeviceAreaSuggestion(" ", null, AREAS)).toBeNull();
expect(computeDeviceAreaSuggestion(undefined, null, AREAS)).toBeNull();
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { stripBoundaryLabel } from "../../../src/common/string/strip_boundary_label";
describe("stripBoundaryLabel", () => {
it("returns an empty string when the text equals the label", () => {
expect(stripBoundaryLabel("Kitchen", "Kitchen")).toBe("");
});
it("strips a prefix on a word boundary", () => {
expect(stripBoundaryLabel("Living Room Thermostat", "Living Room")).toBe(
"Thermostat"
);
});
it("strips a suffix on a word boundary", () => {
expect(stripBoundaryLabel("Thermostat Living Room", "Living Room")).toBe(
"Thermostat"
);
});
it("is case-insensitive and keeps the remainder's casing", () => {
expect(stripBoundaryLabel("living room Thermostat", "Living Room")).toBe(
"Thermostat"
);
});
it("trims different separators", () => {
expect(stripBoundaryLabel("Kitchen - Sensor", "Kitchen")).toBe("Sensor");
expect(stripBoundaryLabel("Kitchen_Sensor", "Kitchen")).toBe("Sensor");
expect(stripBoundaryLabel("Kitchen.Sensor", "Kitchen")).toBe("Sensor");
});
it("does not match in the middle of a word", () => {
expect(stripBoundaryLabel("Kitchenette Sensor", "Kitchen")).toBeNull();
expect(stripBoundaryLabel("Sub Kitchenette", "Kitchen")).toBeNull();
});
it("returns null when the label is absent", () => {
expect(stripBoundaryLabel("Living Room Sensor", "Kitchen")).toBeNull();
});
it("returns an empty string when only separators are left", () => {
expect(stripBoundaryLabel("Kitchen -", "Kitchen")).toBe("");
});
});