Compare commits

...
Author SHA1 Message Date
Paul Bottein 5b25a220df Treat an entity name equal to its device name as no own name (#54585)
* Treat an entity name equal to its device name as no own name

* Use the same device name check when computing entity names

* Only check the user-set name against the device name

* Add tests for the device name check

* Clarify why only the user-set name is matched loosely
2026-10-05 18:54:27 +02:00
Paul Bottein 74c44e7c71 Fix first badges cut off in centered scrolling view header (#54565) 2026-10-05 18:50:36 +02:00
Paul Bottein 7b6561491a Fix option labels overflowing horizontal control selects (#54582) 2026-10-05 15:38:32 +00:00
Paul Bottein 17e0352ecf Show then branch before else in trace graph (#54583) 2026-10-05 17:03:31 +02:00
Maarten Lakerveld 7b379d587c Bump home-assistant-js-websocket to 9.7.1 (#54577)
Exclude our own packages from the 3-day minimumReleaseAge rule so
fixes we publish ourselves can be picked up immediately. The
no-downgrade trust policy still rejects unsigned releases.
2026-10-05 16:54:43 +02:00
Paul Bottein 6b32c87702 Fix map history paths flickering on every update (#54573)
Update map paths and zone circles in place instead of redrawing them
2026-10-05 15:40:34 +03:00
16 changed files with 248 additions and 86 deletions
+1 -1
View File
@@ -107,7 +107,7 @@
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.0",
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.2",
"js-yaml": "5.4.2",
+5 -5
View File
@@ -384,8 +384,8 @@ importers:
specifier: 1.7.3
version: 1.7.3
home-assistant-js-websocket:
specifier: 9.7.0
version: 9.7.0
specifier: 9.7.1
version: 9.7.1
idb-keyval:
specifier: 6.3.0
version: 6.3.0
@@ -5267,8 +5267,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
[email protected].0:
resolution: {integrity: sha512-AE/VtwY+sKi7B3934KuIgvj2g06QY9aFVdd2zh1r5ygbpMo2TZbvN9Zoma8L2wHNYFsngTRiZxYOnLlCysIOzA==}
[email protected].1:
resolution: {integrity: sha512-zOAiyF+gUc/IsiswYdWKns6SCBbvWb8iJtMX9+Dk0wlbdfcp+I38T2Vx36krWeWMk/GPwoSiYwCs5hk/+Hsn7w==}
[email protected]:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
@@ -13073,7 +13073,7 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected]:
dependencies:
+3
View File
@@ -3,6 +3,9 @@ autoInstallPeers: false
# Supply-chain protection
minimumReleaseAge: 4320 # 3 days, in minutes
minimumReleaseAgeExclude:
# Our own packages, published from our own CI
- home-assistant-js-websocket
trustPolicy: no-downgrade
trustPolicyExclude:
# Backports published after provenance-signed 7.x releases
+12 -3
View File
@@ -6,7 +6,10 @@ import type {
import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name";
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
import {
isDeviceName,
stripPrefixFromEntityName,
} from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
@@ -48,8 +51,14 @@ export const computeEntityEntryName = (
const deviceName = computeDeviceName(device);
// An empty entity name or one equal to the device name means the entity uses the device name
if (!name || deviceName === name) {
// An original_name never contains the device name, only a user-set name needs the loose match
const isOnlyDeviceName =
entry.name != null && deviceName
? isDeviceName(entry.name, deviceName)
: deviceName === name;
// An empty entity name or one that is only the device name means the entity uses the device name
if (!name || isOnlyDeviceName) {
return undefined;
}
@@ -30,4 +30,19 @@ export const stripPrefixFromEntityName = (
return undefined;
};
/**
* Whether an entity name is only the device name, which core treats as no name of its own.
*/
export const isDeviceName = (entityName: string, deviceName: string) => {
if (!deviceName) {
return false;
}
const lowerCasedEntityName = entityName.toLowerCase();
const lowerCasedDeviceName = deviceName.toLowerCase();
return (
lowerCasedEntityName.startsWith(lowerCasedDeviceName) &&
/^[ :-]*$/.test(lowerCasedEntityName.slice(lowerCasedDeviceName.length))
);
};
const hasUpperCase = (str: string): boolean => str.toLowerCase() !== str;
+25 -5
View File
@@ -13,16 +13,17 @@ import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleHandle,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
MapPathHandle,
} from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
import { setMarkerAccessibility } from "../marker-accessibility";
@@ -264,16 +265,35 @@ export class LeafletMapEngine implements MapEngine {
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
): MapCircleHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
return {
update: (newCenter, newOptions) => {
circle
.setLatLng(newCenter)
.setRadius(newOptions.radius)
.setStyle({ color: newOptions.color });
},
remove: () => circle.remove(),
};
}
public addPath(path: MapPath): MapItemHandle {
public addPath(path: MapPath): MapPathHandle {
let items = this._drawPath(path);
return {
update: (next) => {
items.forEach((item) => item.remove());
items = this._drawPath(next);
},
remove: () => items.forEach((item) => item.remove()),
};
}
private _drawPath(path: MapPath): (Polyline | CircleMarker)[] {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
@@ -296,7 +316,7 @@ export class LeafletMapEngine implements MapEngine {
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
return items;
}
public setClustering(options: MapClusterOptions | null): void {
+57 -38
View File
@@ -31,6 +31,7 @@ import {
import { deepEqual } from "../../util/deep-equal";
import { isTouch } from "../../../util/is_touch";
import type {
MapCircleHandle,
MapCircleOptions,
MapClusterIcon,
MapClusterOptions,
@@ -49,6 +50,7 @@ import type {
MapLatLng,
MapMarkerOptions,
MapPath,
MapPathHandle,
} from "../map-engine";
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
@@ -121,6 +123,42 @@ const circlePolygon = (
};
};
const coloredCircle = (
center: MapLatLng,
options: MapCircleOptions
): Feature<Polygon> => ({
...circlePolygon(center, options.radius),
properties: { color: options.color },
});
const pathLines = (path: MapPath): FeatureCollection => ({
type: "FeatureCollection",
features: path.segments.map((segment) => ({
type: "Feature",
properties: { color: path.color, opacity: segment.opacity ?? 1 },
geometry: {
type: "LineString",
coordinates: segment.points.map((point) => [point[1], point[0]]),
},
})),
});
const pathPoints = (path: MapPath): FeatureCollection => ({
type: "FeatureCollection",
features: path.markers.map((pathMarker) => ({
type: "Feature",
properties: {
color: path.color,
opacity: pathMarker.opacity ?? 1,
tooltip: pathMarker.tooltipHtml,
},
geometry: {
type: "Point",
coordinates: [pathMarker.location[1], pathMarker.location[0]],
},
})),
});
interface ManagedMarker {
element: HTMLElement;
location: MapLatLng;
@@ -777,25 +815,28 @@ export class MapLibreMapEngine implements MapEngine {
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
): MapCircleHandle {
if (!this._map) {
return { remove: () => undefined };
return { update: () => undefined, remove: () => undefined };
}
const id = `${CUSTOM_PREFIX}circle-${this._idCounter++}`;
this._addCustomSource(id, circlePolygon(center, options.radius));
this._addCustomSource(id, coloredCircle(center, options));
this._addCustomLayer({
id: `${id}-fill`,
type: "fill",
source: id,
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
paint: { "fill-color": ["get", "color"], "fill-opacity": 0.2 },
});
this._addCustomLayer({
id: `${id}-line`,
type: "line",
source: id,
paint: { "line-color": options.color, "line-width": 3 },
paint: { "line-color": ["get", "color"], "line-width": 3 },
});
return {
update: (newCenter, newOptions) => {
this._setCustomSourceData(id, coloredCircle(newCenter, newOptions));
},
remove: () => {
this._removeCustomLayer(`${id}-fill`);
this._removeCustomLayer(`${id}-line`);
@@ -1043,46 +1084,20 @@ export class MapLibreMapEngine implements MapEngine {
};
}
public addPath(path: MapPath): MapItemHandle {
public addPath(path: MapPath): MapPathHandle {
if (!this._map || !this._maplibre) {
return { remove: () => undefined };
return { update: () => undefined, remove: () => undefined };
}
const id = `${CUSTOM_PREFIX}path-${this._idCounter++}`;
const lines: FeatureCollection = {
type: "FeatureCollection",
features: path.segments.map((segment) => ({
type: "Feature",
properties: { opacity: segment.opacity ?? 1 },
geometry: {
type: "LineString",
coordinates: segment.points.map((point) => [point[1], point[0]]),
},
})),
};
const points: FeatureCollection = {
type: "FeatureCollection",
features: path.markers.map((pathMarker) => ({
type: "Feature",
properties: {
opacity: pathMarker.opacity ?? 1,
tooltip: pathMarker.tooltipHtml,
},
geometry: {
type: "Point",
coordinates: [pathMarker.location[1], pathMarker.location[0]],
},
})),
};
this._addCustomSource(`${id}-lines`, lines);
this._addCustomSource(`${id}-points`, points);
this._addCustomSource(`${id}-lines`, pathLines(path));
this._addCustomSource(`${id}-points`, pathPoints(path));
this._addCustomLayer({
id: `${id}-lines`,
type: "line",
source: `${id}-lines`,
paint: {
"line-color": path.color,
"line-color": ["get", "color"],
"line-width": 3,
"line-opacity": ["get", "opacity"],
},
@@ -1093,10 +1108,10 @@ export class MapLibreMapEngine implements MapEngine {
source: `${id}-points`,
paint: {
"circle-radius": isTouch ? 8 : 3,
"circle-color": path.color,
"circle-color": ["get", "color"],
"circle-opacity": ["get", "opacity"],
"circle-stroke-width": 2,
"circle-stroke-color": path.color,
"circle-stroke-color": ["get", "color"],
"circle-stroke-opacity": ["get", "opacity"],
},
});
@@ -1139,6 +1154,10 @@ export class MapLibreMapEngine implements MapEngine {
this._pathPointLayers.add(layerId);
return {
update: (next) => {
this._setCustomSourceData(`${id}-lines`, pathLines(next));
this._setCustomSourceData(`${id}-points`, pathPoints(next));
},
remove: () => {
map.off("mouseenter", layerId, onEnter);
map.off("mouseleave", layerId, onLeave);
+12 -2
View File
@@ -115,6 +115,16 @@ export interface MapMarkerHandle extends MapItemHandle {
readonly clusterData?: unknown;
}
export interface MapCircleHandle extends MapItemHandle {
/** Move, resize or recolor without removing it first */
update(center: MapLatLng, options: MapCircleOptions): void;
}
export interface MapPathHandle extends MapItemHandle {
/** Replace the drawn trail without removing it first */
update(path: MapPath): void;
}
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
@@ -239,13 +249,13 @@ export interface MapEngine {
): MapMarkerHandle;
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
addCircle(center: MapLatLng, options: MapCircleOptions): MapCircleHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
addPath(path: MapPath): MapPathHandle;
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
+16
View File
@@ -334,6 +334,22 @@ export class HaControlSelect extends LitElement {
width: 100%;
hyphens: auto;
}
:host(:not([vertical])) .option {
min-width: 0;
}
:host(:not([vertical])) .option .content {
padding-inline: var(--ha-space-1);
}
:host(:not([vertical])) .option .content span {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
hyphens: manual;
line-height: var(--ha-line-height-condensed);
}
:host([vertical]) {
width: var(--control-select-thickness);
height: auto;
+33 -11
View File
@@ -21,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsVectorMaps } from "../../common/map/base-layer";
import type {
MapCircleHandle,
MapClusterIcon,
MapControlPosition,
MapEngine,
@@ -29,6 +30,7 @@ import type {
MapLatLng,
MapMarkerHandle,
MapPath,
MapPathHandle,
MapPathMarker,
MapPathSegment,
MapEditableCircleHandle,
@@ -419,7 +421,9 @@ export class HaMap extends ReactiveElement {
private _zoneHandles: MapItemHandle[] = [];
private _pathHandles: MapItemHandle[] = [];
private _zoneCircleHandles: MapCircleHandle[] = [];
private _pathHandles: MapPathHandle[] = [];
private _focusPoints: MapLatLng[] = [];
@@ -483,6 +487,7 @@ export class HaMap extends ReactiveElement {
this._entityMarkers.clear();
this._clusterAvatars.clear();
this._zoneHandles = [];
this._zoneCircleHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
@@ -827,6 +832,7 @@ export class HaMap extends ReactiveElement {
this._engine = undefined;
this._entityHandles = [];
this._zoneHandles = [];
this._zoneCircleHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
@@ -1153,11 +1159,9 @@ export class HaMap extends ReactiveElement {
if (!this._i18n || !this._config || !this._engine) {
return;
}
if (this._pathHandles.length) {
this._pathHandles.forEach((handle) => handle.remove());
this._pathHandles = [];
}
if (!this.paths) {
const paths = this.paths ?? [];
this._pathHandles.splice(paths.length).forEach((handle) => handle.remove());
if (!paths.length) {
return;
}
@@ -1165,7 +1169,7 @@ export class HaMap extends ReactiveElement {
"--dark-primary-color"
);
this.paths.forEach((path) => {
paths.forEach((path, index) => {
let opacityStep: number;
let baseOpacity: number;
if (path.gradualOpacity) {
@@ -1257,7 +1261,12 @@ export class HaMap extends ReactiveElement {
segments,
markers,
};
this._pathHandles.push(this._engine!.addPath(enginePath));
const handle = this._pathHandles[index];
if (handle) {
handle.update(enginePath);
} else {
this._pathHandles.push(this._engine!.addPath(enginePath));
}
});
}
@@ -1276,8 +1285,10 @@ export class HaMap extends ReactiveElement {
this._zoneHandles.forEach((handle) => handle.remove());
this._zoneHandles = [];
this._focusZonePoints = [];
let zoneCircleCount = 0;
if (!this.entities) {
this._zoneCircleHandles.splice(0).forEach((handle) => handle.remove());
this._entityMarkers.clear();
this._clusterAvatars.clear();
engine.setClustering(null);
@@ -1359,9 +1370,16 @@ export class HaMap extends ReactiveElement {
);
if (!hideRadius && radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: markerColor })
);
const circleOptions = { radius, color: markerColor };
const circle = this._zoneCircleHandles[zoneCircleCount];
if (circle) {
circle.update(position, circleOptions);
} else {
this._zoneCircleHandles.push(
engine.addCircle(position, circleOptions)
);
}
zoneCircleCount++;
}
const circleEl = createZoneMarkerElement({
@@ -1490,6 +1508,10 @@ export class HaMap extends ReactiveElement {
}
}
this._zoneCircleHandles
.splice(zoneCircleCount)
.forEach((handle) => handle.remove());
const shownIds = new Set(this.entities.map(getEntityId));
for (const cache of [this._entityMarkers, this._clusterAvatars]) {
for (const entityId of cache.keys()) {
+15 -15
View File
@@ -276,6 +276,21 @@ export class HatScriptGraph extends LitElement {
nofocus
aria-hidden="true"
></hat-graph-node>
<div
class="graph-container"
?track=${thenBranch.hasTrace}
?unfinished=${thenBranch.unfinished}
>
<hat-graph-node
.iconPath=${mdiCallReceived}
?track=${thenBranch.hasTrace}
?active=${this.selected === path}
.notEnabled=${thenBranch.disabled}
nofocus
aria-hidden="true"
></hat-graph-node>
${this._renderBranchActions(thenBranch.children, graphEnd)}
</div>
${
config.else
? html`<div
@@ -300,21 +315,6 @@ export class HatScriptGraph extends LitElement {
?track=${elseBranch.hasTrace}
></hat-graph-spacer>`
}
<div
class="graph-container"
?track=${thenBranch.hasTrace}
?unfinished=${thenBranch.unfinished}
>
<hat-graph-node
.iconPath=${mdiCallReceived}
?track=${thenBranch.hasTrace}
?active=${this.selected === path}
.notEnabled=${thenBranch.disabled}
nofocus
aria-hidden="true"
></hat-graph-node>
${this._renderBranchActions(thenBranch.children, graphEnd)}
</div>
</hat-graph-branch>
`;
}
@@ -10,9 +10,13 @@ import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { isDeviceName } from "../../../common/entity/strip_prefix_from_entity_name";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { formatNumber } from "../../../common/number/format_number";
import { stringCompare } from "../../../common/string/compare";
@@ -274,8 +278,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
this._name = this.entry.name || this._originalName;
this._useDeviceName =
!!this._device && !(this.entry.name ?? this._originalName);
this._useDeviceName = !!this._device && !this._isOwnName(this.entry.name);
this._icon = this.entry.icon || "";
this._deviceClass =
this.entry.device_class || this.entry.original_device_class;
@@ -1757,7 +1760,19 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
private get _hasOwnName(): boolean {
return !!(this._computeName() ?? this._originalName);
return this._isOwnName(this._computeName());
}
// Only a user-set name needs the check, original_name is already unprefixed
private _isOwnName(name: string | null): boolean {
if (name === null) {
return !!this._originalName;
}
if (!name) {
return false;
}
const deviceName = this._device && computeDeviceName(this._device);
return !deviceName || !isDeviceName(name, deviceName);
}
private get _useDeviceArea(): boolean {
@@ -1787,6 +1802,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
return this.entry.name;
}
if (this._device && this._useDeviceName) {
if (!this._isOwnName(this.entry.name)) {
return this.entry.name;
}
return this._originalName ? "" : null;
}
const name = this._name.trim();
@@ -423,6 +423,7 @@ export class HuiViewHeader extends LitElement {
.container:not(.edit-mode) .layout.badges-scroll hui-view-badges {
--badges-wrap: nowrap;
width: max-content;
flex-shrink: 0;
}
@media (min-width: 768px) {
@@ -147,6 +147,19 @@ describe("computeEntityEntryName", () => {
);
});
it("matches only a user-set name loosely against the device name", () => {
const devices = { dev1: mockDevice({ id: "dev1", name: "Kitchen" }) };
const entry = mockEntityEntry({ device_id: "dev1", name: "KITCHEN" });
expect(computeEntityEntryName(entry, devices)).toBeUndefined();
expect(
computeEntityEntryName(
{ ...entry, name: null, original_name: "KITCHEN" },
devices
)
).toBe("KITCHEN");
});
it("returns undefined if no name, original_name, or device", () => {
const entry = mockEntity({ entity_id: "light.kitchen" });
const hass = {
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { isDeviceName } from "../../../src/common/entity/strip_prefix_from_entity_name";
describe("isDeviceName", () => {
it.each([
["Kitchen", "Kitchen", true],
["KITCHEN", "Kitchen", true],
["Kitchen -", "Kitchen", true],
["Kitchen:", "Kitchen", true],
["Kitchen Light", "Kitchen", false],
["Kitchenette", "Kitchen", false],
["Kitchen", "", false],
])("%s with device %s is %s", (entityName, deviceName, expected) => {
expect(isDeviceName(entityName, deviceName)).toBe(expected);
});
});
+2 -2
View File
@@ -89,9 +89,9 @@ const fakeEngine = vi.hoisted(() => {
remove: vi.fn(),
}));
addCircle = vi.fn(() => ({ remove: vi.fn() }));
addCircle = vi.fn(() => ({ update: vi.fn(), remove: vi.fn() }));
addPath = vi.fn(() => ({ remove: vi.fn() }));
addPath = vi.fn(() => ({ update: vi.fn(), remove: vi.fn() }));
setClustering = vi.fn();