Compare commits

...
Author SHA1 Message Date
Paul Bottein 7f45689676 Migrate legacy sun and calendar offsets in the trigger editor 2026-09-10 13:46:28 +02:00
Paul Bottein 6ccc78114c Add offset selector 2026-09-10 13:46:10 +02:00
Paul BotteinandGitHub ec4bfc9255 Add parent device to entity context (#53766)
* Add parent device to entity context

* Fix parent device context display edge cases

* Fix parent device context in suggestions and completions

* Order entity name parts by hierarchy

* Remove unused parent device missing string

* Add parent device to logbook entry context

* Show inherited area and parent device for device targets

* Test parent device search in the entity tree

* Show inherited area and parent device in the automation target header

* Move the device domain lookup out of target row item data

* Pass registries to memoized entity name lists

* Document parent device in logbook name detail
2026-09-10 13:38:39 +02:00
Maarten LakerveldandGitHub 3b9e026aff Edit zones and locations on the map engine instead of leaflet-draw (#54023)
* Edit zones and locations on the map engine instead of leaflet-draw

ha-locations-editor (zone config, the location selector, onboarding)
used raw Leaflet layers and leaflet-draw, which pinned it to the Leaflet
engine. Its interaction surface is three gestures - drag a marker, drag
a zone center, drag a zone radius handle - so no editing library is
needed.

MapEngine gains an optional editing capability (draggable markers and an
addEditableCircle primitive) plus panTo and containsLocation. Only the
MapLibre engine implements editing: the circle geometry plus a draggable
center marker and a draggable radius handle on the east edge, the radius
derived from the great-circle distance between them, redrawn per frame
while dragging. The handle has a touch-sized hit target and is a
keyboard-operable slider with an accessible name. The Leaflet engine
stays a viewing fallback: without WebGL2 an editor shows its locations
as static markers and a notice that this browser cannot edit on the map.

ha-map exposes editableLocations and fires
editable-location-moved/resized/clicked and editing-available-changed.
Locations are reconciled by id: position and radius changes move the
existing handles, while a changed icon, name, color, or editability
rebuilds the marker, so edits made in the form fields show up on the
map on either engine. ha-locations-editor becomes a thin layer over
that with its public contract unchanged, so its consumers need no
changes; it reads localize from the internationalization context instead
of taking hass.

The zone panel no longer zooms the map when a selection comes from the
map itself. Clicking or dragging a marker selects its list item, and
mwc-list reports that programmatic selection with the same "property"
request-selected event a click ends with. The two are told apart by
timing: a selection from the map has already set the active entry when
its event arrives, while a click's event arrives before the handler
sets it. Skipping the programmatic event also keeps the item selected,
which the shared request-selected helper would otherwise reset.

The layers property and the engine selector are removed along with the
leaflet-draw dependency and its patch: Leaflet is now purely the viewing
fallback, selected by WebGL2 support alone. The Leaflet engine keeps a
leafletMap field for the ha-map fit tests, which run on it in jsdom.

* Keep the retained circle data current while resizing

The engine keeps its source specifications to rebuild them after a style
swap; the editable circle's redraw updates that copy along with the map.

* Address code review on map editing

Editable markers, the circle center on both engines, and the static
fallback center activate on Enter and Space, as their button role
promises. Locations without a name get a localized one, since onboarding
and the location selector pass none. The resize handle's advertised
maximum follows the radius instead of being exceeded by it, and the
handle stays near the circle at high latitudes.

Clicking the active zone row zoomed to it again upstream but was
swallowed by the check that told a map-driven selection from a click; a
one-shot flag on selections made in code replaces that inference. The
engine rebuilds its sources from its own record on a style swap, so a
radius changed while the style was unloaded is not lost.

* Test the map editing path

The MapLibre engine's editable circle against the fake map: dragging the
center moves the circle and the handle and reports the drop, dragging the
handle resizes and snaps it back, arrow keys resize in steps committed on
release, the advertised maximum follows a larger radius, one update
echoing pre-drag values is ignored, the center activates by click, Enter
and Space but not after a drag, and removal clears markers, layers and
listeners.

ha-map's reconciliation of editable locations through a fake engine with
editing support: circles and markers are drawn, moved in place, rebuilt
when their appearance changes and removed when dropped, move, resize and
activation events carry the location id, and an engine without editing
support shows the locations statically.

* Leave zone row selection to the user

Clicking or dragging a zone on the map, and saving one in the dialog,
selected its row in code. That selection fires the same request-selected
event a click ends with, so the page had to tell the two apart, and the
row could not be shown selected and stay clickable at the same time: the
highlight never actually appeared. The list now only follows the user's
own clicks, which removes that bookkeeping along with the zoom that a
radius drag used to trigger.

* Hand marker elements back clean, and record sources before the style loads

MapLibre leaves its positioning class and transform on a marker element
after removal. Hosts like ha-locations-editor cache their elements and
hand them to whatever engine draws next: after a fatal WebGL failure that
is the Leaflet fallback, which shows the locations statically. A removed
element, and every element still on the map when the engine is destroyed,
is reset so it arrives as a plain element. The editable circle's center
gets the same treatment.

Custom sources and layers are now recorded when added and only inserted
into the map once the style has loaded. A circle moved while a swapped
style was still loading therefore reaches the record, and a swap in that
window carries it over; the deferred insert skips what the swap already
placed.

* Guard a keyboard resize against host updates like a drag

Arrow-key resizing changed the radius without arming the pre-edit guard
that pointer drags use, so a host re-render with the old radius while a
key was held, or its echo right after the commit, snapped the circle
back. A key resize now counts as a drag from the first key press until
the commit.

* Let the engine own the drag cursor, and tidy the fallback notice

The move cursor came from stylesheets keyed on whether a location may be
edited, so it showed on circles that cannot move and on the static
Leaflet fallback. The MapLibre engine now sets it on the elements it
actually made draggable and clears it when handing them back.

The fallback notice said the browser cannot edit locations, while
clicking the map to place one still works there; it now names what is
missing, dragging markers and resizing zones. The editor's
internationalization context is reactive, like its neighbours.

* Show the fallback notice only when a location is editable

The Leaflet fallback showed the notice for every editor, including ones
whose locations are all read-only, where nothing is missing.

* Drop the named icon's move cursor, and describe the fallback as a map state

The Leaflet fallback draws named location icons statically, but the
stylesheet still gave the ones marked editable a move cursor. The engine
sets that cursor on the elements it actually drags, so the rule goes.

The fallback notice blamed the browser, while MapLibre can also be
unavailable after a failed start or a lost WebGL context; it now
describes what the map offers. The resize handle's east point only guards
the pole itself, so its distance agrees with distanceMeters wherever a
zone can be.

* Announce the fallback notice, and let test frames redraw

The notice appears after the engine has been chosen, so helper text
would not reach a screen reader; it is an alert now. The engine test's
frame stub returned an id that landed in the engine's pending frame after
the callback had cleared it, so every redraw after the first was skipped.

* Show the fallback notice over the map, and keep handle clicks off the map

The notice followed a map that fills the editor's fixed height, so it
landed outside the box: clipped in onboarding, spilling into the form in
the location selector. It now sits over the top of the map, clear of the
zoom control.

The resize handle was the one marker whose click reached MapLibre's map
click, which a pin-on-click host takes as a request to move the location
there; the handle stops it like the other markers do.

* Share one circle geometry, and keep arrow keys on the handle

Drawing, fitting, and handle placement each converted meters to degrees
on their own, with different pole guards, so above 89.4 degrees the
resize handle left the drawn circle. They now share one conversion that
also agrees with distanceMeters; only the pole itself is guarded.

Arrow keys on the resize handle also reached MapLibre's keyboard handler
and panned the map while resizing; the handle keeps them.

* Drop the zone page's active entry

It only re-selected the row the user had just clicked, after the shared
request-selected helper had reset it, which fired the selection event a
second time and zoomed to the zone twice. The row highlight comes from
the list itself and nothing else read the value, so the list no longer
mirrors a selection at all.

* Make marker activation opt-in, and relabel handles on language change

Every editable marker was a focusable button whose activation fired an
event, whether or not the host did anything with it: the zone page and
the location selector do not. Activation is now opt-in per location; the
onboarding search results opt in, and a marker that does not act on it is
draggable without button semantics. The engine contract separates taking
pointer input from being a keyboard-focusable button for that.

Marker titles and handle labels are localized when drawn, so a language
change, or localization arriving after the first draw, redraws them.

* Own the accessibility attributes the engine sets, and use spherical circle geometry

setMarkerAccessibility only filled in what was missing and nothing removed
it, so a cached element rebuilt with a new title or a different role kept
the old attributes. The helper now records what it set, replaces exactly
that on a rebuild, and clears it when the element leaves the map, while a
caller's own attributes stay untouched.

The flat meters-to-degrees conversion broke down for large circles near
the poles, wrapping longitude and producing latitudes beyond 90. Handle
placement and the polygon now use the great-circle destination, and the
fitted bounds a spherical box that spans all longitudes once the circle
reaches a pole, so all three agree with distanceMeters everywhere.
2026-09-10 11:31:06 +02:00
Petar PetrovandGitHub 569bcc01da Fix frozen solar-to-grid dot and phantom solar dot in energy distribution card (#54085) 2026-09-10 10:20:38 +01:00
8ea28508c3 Move history timeline names under the bars (#54086)
Co-authored-by: Cursor Agent <[email protected]>
2026-09-10 09:58:48 +01:00
63 changed files with 3023 additions and 745 deletions
File diff suppressed because one or more lines are too long
@@ -319,6 +319,7 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
app: { name: "App", selector: { app: {} } },
number_box: {
name: "Number Box",
-2
View File
@@ -110,7 +110,6 @@
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
@@ -163,7 +162,6 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/leaflet": "1.9.22",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.5",
@@ -0,0 +1,31 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export const durationValueToData = (
value?: HaDurationData | string | number
): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
};
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export const ENTITY_NAME_TYPES = [
"floor",
"area",
"parent_device",
"device",
"entity",
] as const;
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
export type EntityNameItem =
| {
type: "entity" | "device" | "area" | "floor";
type: EntityNameType;
}
| {
type: "text";
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): (string | undefined)[] => {
const { device, area, floor } = getEntityContext(
const { device, parentDevice, area, floor } = getEntityContext(
stateObj,
entities,
devices,
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "parent_device":
return parentDevice ? computeDeviceName(parentDevice) : undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(
hass.language,
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[areaName, entityName ? deviceName : undefined]
[areaName, parentDeviceName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
interface EntityContext {
entity: EntityRegistryDisplayEntry | null;
device: DeviceRegistryEntry | null;
parentDevice: DeviceRegistryEntry | null;
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
@@ -31,6 +32,7 @@ export const getEntityContext = (
return {
entity: null,
device: null,
parentDevice: null,
area: null,
floor: null,
};
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
const entity = entities[entry.entity_id];
const deviceId = entry?.device_id;
const device = deviceId ? devices[deviceId] : undefined;
const parentDevice = device?.parent_device_id
? devices[device.parent_device_id]
: undefined;
const areaId =
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
const area = areaId ? areas[areaId] : undefined;
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
return {
entity: entity,
device: device || null,
parentDevice: parentDevice || null,
area: area || null,
floor: floor || null,
};
+59
View File
@@ -0,0 +1,59 @@
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
/** Hit target for the radius handle; comfortably above touch minimums */
export const RESIZE_HANDLE_SIZE = 24;
/** The visible dot inside the hit target */
export const RESIZE_HANDLE_DOT_SIZE = 12;
/** Relative radius change per arrow key press on the handle */
export const RESIZE_KEY_STEP = 0.1;
/** Advertised slider maximum; a larger radius raises it (see the engine) */
export const RADIUS_ARIA_MAX = 100000;
export const createResizeHandleElement = (label?: string): HTMLElement => {
const element = document.createElement("div");
element.className = "editable-circle-resize";
element.tabIndex = 0;
element.setAttribute("role", "slider");
element.setAttribute("aria-valuemin", "1");
element.setAttribute("aria-valuemax", String(RADIUS_ARIA_MAX));
if (label) {
element.setAttribute("aria-label", label);
}
const dot = document.createElement("div");
dot.className = "editable-circle-resize-dot";
element.appendChild(dot);
return element;
};
/** Styles for the handles, included by ha-map for both engines */
export const editableCircleStyles = `
.editable-circle-center {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
.editable-circle-resize {
width: ${RESIZE_HANDLE_SIZE}px;
height: ${RESIZE_HANDLE_SIZE}px;
display: flex;
align-items: center;
justify-content: center;
cursor: ew-resize;
}
.editable-circle-resize-dot {
width: ${RESIZE_HANDLE_DOT_SIZE}px;
height: ${RESIZE_HANDLE_DOT_SIZE}px;
border-radius: 50%;
background: var(--card-background-color, #fff);
border: 2px solid var(--primary-color);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
}
`;
+12 -14
View File
@@ -34,17 +34,9 @@ interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/**
* The Leaflet implementation of MapEngine. Renders vector tiles through the
* maplibre-gl-leaflet adapter when WebGL2 is available and raster tiles
* otherwise (see createBaseLayer), so it is both the non-WebGL2 fallback and
* the engine ha-locations-editor requires for leaflet-draw.
*/
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/**
* Escape hatch for ha-locations-editor, which manages its own Leaflet
* layers (leaflet-draw). Not for use anywhere else.
*/
/** For the ha-map jsdom tests only */
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
@@ -197,6 +189,14 @@ export class LeafletMapEngine implements MapEngine {
});
}
public panTo(location: MapLatLng): void {
this.leafletMap?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this.leafletMap?.getBounds().contains(location) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
@@ -213,10 +213,8 @@ export class LeafletMapEngine implements MapEngine {
// Leaflet's keyboard support focuses its own wrapper, where the element's
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
if (interactive) {
element.tabIndex = 0;
}
setMarkerAccessibility(element, options.title, interactive);
const focusable = options.focusable ?? interactive;
setMarkerAccessibility(element, options.title, focusable);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
+392 -26
View File
@@ -1,5 +1,6 @@
import type { Feature, FeatureCollection, Polygon } from "geojson";
import type {
GeoJSONSource,
IControl,
LayerSpecification,
Map as MapLibreMap,
@@ -9,7 +10,10 @@ import type {
StyleSpecification,
} from "maplibre-gl";
import type maplibregl from "maplibre-gl";
import { setMarkerAccessibility } from "../marker-accessibility";
import {
clearMarkerAccessibility,
setMarkerAccessibility,
} from "../marker-accessibility";
import {
CONTEXT_RESTORE_GRACE,
ensureRTLTextPlugin,
@@ -29,16 +33,27 @@ import type {
MapCircleOptions,
MapClusterOptions,
MapControlPosition,
MapDraggableMarkerOptions,
MapEditableCircleHandle,
MapEditableCircleOptions,
MapEditableMarkerHandle,
MapEditingSupport,
MapEngine,
MapMarkerHandle,
MapEngineEvents,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
import {
createResizeHandleElement,
RADIUS_ARIA_MAX,
RESIZE_KEY_STEP,
} from "../editable-circle";
type MapLibreModule = typeof maplibregl;
@@ -79,6 +94,7 @@ const resetMarkerElement = (element: HTMLElement): void => {
element.style.transform = "";
element.style.opacity = "";
element.style.pointerEvents = "";
element.style.cursor = "";
};
// A meter-radius circle as a polygon (spherical approximation)
@@ -87,16 +103,10 @@ const circlePolygon = (
radiusMeters: number
): Feature<Polygon> => {
const steps = 64;
const latOffset = radiusMeters / 111320;
const lngOffset =
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
const ring: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const theta = (2 * Math.PI * i) / steps;
ring.push([
center[1] + lngOffset * Math.sin(theta),
center[0] + latOffset * Math.cos(theta),
]);
const point = destinationPoint(center, radiusMeters, (360 * i) / steps);
ring.push([point[1], point[0]]);
}
return {
type: "Feature",
@@ -109,7 +119,12 @@ interface ManagedMarker {
element: HTMLElement;
location: MapLatLng;
options: MapMarkerOptions;
handle: MapMarkerHandle;
handle: MapEditableMarkerHandle;
draggable?: boolean;
onDragEnd?: (location: MapLatLng) => void;
dragging?: boolean;
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
staleLocation?: MapLatLng;
mlMarker?: MapLibreMarker;
decoration?: MapItemHandle;
removed?: boolean;
@@ -183,6 +198,10 @@ export class MapLibreMapEngine implements MapEngine {
private _settleInit?: () => void;
// Caller-owned elements on the map, reset when they leave it: a host may
// reuse them on another engine after a fallback
private _placedElements = new Set<HTMLElement>();
public async init(
container: HTMLElement,
options: MapEngineOptions
@@ -348,6 +367,11 @@ export class MapLibreMapEngine implements MapEngine {
this._clusterGroups = [];
this._markers = [];
this._pendingStyleOps = [];
this._placedElements.forEach((element) => {
resetMarkerElement(element);
clearMarkerAccessibility(element);
});
this._placedElements.clear();
this._map?.remove();
this._map = undefined;
}
@@ -411,12 +435,14 @@ export class MapLibreMapEngine implements MapEngine {
}
private _carryCustomLayers(
previous: StyleSpecification | undefined,
_previous: StyleSpecification | undefined,
next: StyleSpecification
): StyleSpecification {
const sources = { ...next.sources };
// Our record, not MapLibre's serialization: an update made while the
// style was unloaded only reached the record
for (const [id, source] of this._customSources) {
sources[id] = previous?.sources?.[id] ?? source;
sources[id] = source;
}
const nextIds = new Set(next.layers.map((layer) => layer.id));
const customLayers = [...this._customLayers.values()].filter(
@@ -513,36 +539,91 @@ export class MapLibreMapEngine implements MapEngine {
);
}
public panTo(location: MapLatLng): void {
this._map?.panTo([location[1], location[0]]);
}
public containsLocation(location: MapLatLng): boolean {
return this._map?.getBounds().contains([location[1], location[0]]) ?? false;
}
public addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle {
return this._addMarker(element, location, options);
}
public editing: MapEditingSupport = {
addDraggableMarker: (element, location, options) =>
this._addMarker(element, location, options, true),
addEditableCircle: (center, options) =>
this._addEditableCircle(center, options),
};
private _addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions,
draggable = false
): MapEditableMarkerHandle {
element.style.width = `${options.size[0]}px`;
element.style.height = `${options.size[1]}px`;
if (options.title) {
element.title = options.title;
}
if (options.interactive ?? true) {
element.tabIndex = 0;
} else {
const interactive = options.interactive ?? true;
const focusable = options.focusable ?? interactive;
if (!interactive) {
// Leaflet lets input through non-interactive markers; MapLibre does not
element.style.pointerEvents = "none";
}
setMarkerAccessibility(element, options.title, options.interactive ?? true);
setMarkerAccessibility(element, options.title, focusable);
if (draggable) {
// The engine, not the host, knows whether this element really drags
element.style.cursor = "move";
}
this._placedElements.add(element);
const managed: ManagedMarker = {
element,
location,
options,
handle: undefined as unknown as MapMarkerHandle,
draggable,
onDragEnd: options.onDragEnd,
handle: undefined as unknown as MapEditableMarkerHandle,
};
managed.handle = {
location,
get location() {
return managed.location;
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
if (managed.dragging) {
return;
}
// Skip one update echoing the pre-drag location (the save has not returned yet)
const stale = managed.staleLocation;
managed.staleLocation = undefined;
if (
stale &&
newLocation[0] === stale[0] &&
newLocation[1] === stale[1]
) {
return;
}
managed.location = newLocation;
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
},
remove: () => {
managed.removed = true;
this._hideMarker(managed);
// Still inside an open cluster bubble otherwise
element.remove();
resetMarkerElement(element);
clearMarkerAccessibility(element);
this._placedElements.delete(element);
const index = this._markers.indexOf(managed);
if (index !== -1) {
this._markers.splice(index, 1);
@@ -566,6 +647,7 @@ export class MapLibreMapEngine implements MapEngine {
const { options } = managed;
managed.mlMarker = new this._maplibre.Marker({
element: managed.element,
draggable: managed.draggable ?? false,
...(options.anchor
? {
anchor: "top-left" as const,
@@ -578,6 +660,18 @@ export class MapLibreMapEngine implements MapEngine {
})
.setLngLat([managed.location[1], managed.location[0]])
.addTo(this._map);
if (managed.draggable) {
managed.mlMarker.on("dragstart", () => {
managed.dragging = true;
managed.staleLocation = managed.location;
});
managed.mlMarker.on("dragend", () => {
managed.dragging = false;
const lngLat = managed.mlMarker!.getLngLat();
managed.location = [lngLat.lat, lngLat.lng];
managed.onDragEnd?.(managed.location);
});
}
}
if (managed.options.decoration && !managed.decoration) {
managed.decoration = this.addCircle(
@@ -624,6 +718,261 @@ export class MapLibreMapEngine implements MapEngine {
};
}
private _addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle {
if (!this._map || !this._maplibre) {
return {
center,
radius: options.radius,
update: () => undefined,
remove: () => undefined,
};
}
const map = this._map;
const maplibre = this._maplibre;
let currentCenter = center;
let currentRadius = options.radius;
const id = `${CUSTOM_PREFIX}editable-${this._idCounter++}`;
this._addCustomSource(id, circlePolygon(center, options.radius));
this._addCustomLayer({
id: `${id}-fill`,
type: "fill",
source: id,
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
});
this._addCustomLayer({
id: `${id}-line`,
type: "line",
source: id,
paint: { "line-color": options.color, "line-width": 3 },
});
// Redraw at most once per frame while dragging
let frame: number | undefined;
const redraw = () => {
if (frame !== undefined) {
return;
}
frame = requestAnimationFrame(() => {
frame = undefined;
this._setCustomSourceData(
id,
circlePolygon(currentCenter, currentRadius)
);
});
};
const centerSize = options.centerSize ?? [16, 16];
const centerEl = options.centerElement ?? document.createElement("div");
if (!options.centerElement) {
centerEl.className = "editable-circle-center";
}
centerEl.style.width = `${centerSize[0]}px`;
centerEl.style.height = `${centerSize[1]}px`;
if (options.title) {
centerEl.title = options.title;
}
setMarkerAccessibility(centerEl, options.title, !!options.onClick);
if (options.moveable) {
centerEl.style.cursor = "move";
}
this._placedElements.add(centerEl);
const centerMarker = new maplibre.Marker({
element: centerEl,
draggable: options.moveable ?? false,
})
.setLngLat([center[1], center[0]])
.addTo(map);
let resizeMarker: MapLibreMarker | undefined;
let resizeHandle: HTMLElement | undefined;
const placeResizeHandle = () => {
const east = pointEastOf(currentCenter, currentRadius);
resizeMarker?.setLngLat([east[1], east[0]]);
const radiusText = String(Math.round(currentRadius));
resizeHandle?.setAttribute(
"aria-valuemax",
String(Math.max(RADIUS_ARIA_MAX, Math.round(currentRadius)))
);
resizeHandle?.setAttribute("aria-valuenow", radiusText);
// Without a value text the value is read as a percentage of the range
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// Skip one update echoing the pre-edit values (the save has not returned yet)
let dragging = false;
let staleCenter: MapLatLng | undefined;
let staleRadius: number | undefined;
if (options.resizable) {
const east = pointEastOf(center, options.radius);
resizeHandle = createResizeHandleElement(options.resizeLabel);
resizeMarker = new maplibre.Marker({
element: resizeHandle,
draggable: true,
})
.setLngLat([east[1], east[0]])
.addTo(map);
placeResizeHandle();
resizeMarker.on("drag", () => {
const lngLat = resizeMarker!.getLngLat();
currentRadius = Math.max(
1,
distanceMeters(currentCenter, [lngLat.lat, lngLat.lng])
);
redraw();
});
resizeMarker.on("dragend", () => {
// Snap the handle back onto the east edge
placeResizeHandle();
options.onResize?.(currentRadius);
});
// Arrow keys resize in steps, committed on key release
let keyboardRadius: number | undefined;
resizeHandle.addEventListener("keydown", (ev) => {
const direction =
ev.key === "ArrowRight" || ev.key === "ArrowUp"
? 1
: ev.key === "ArrowLeft" || ev.key === "ArrowDown"
? -1
: 0;
if (!direction) {
return;
}
ev.preventDefault();
// MapLibre pans on arrow keys reaching the map
ev.stopPropagation();
if (keyboardRadius === undefined) {
// Host updates treat a key resize like a drag
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
}
currentRadius = Math.max(
1,
currentRadius * (1 + direction * RESIZE_KEY_STEP)
);
keyboardRadius = currentRadius;
placeResizeHandle();
redraw();
});
const commitKeyboardResize = () => {
if (keyboardRadius !== undefined) {
keyboardRadius = undefined;
dragging = false;
options.onResize?.(currentRadius);
}
};
resizeHandle.addEventListener("keyup", commitKeyboardResize);
// Focus can leave while a key is still held
resizeHandle.addEventListener("blur", commitKeyboardResize);
// Like the other markers: a click on the handle is not a map click
resizeHandle.addEventListener("click", (ev) => ev.stopPropagation());
}
[centerMarker, resizeMarker].forEach((handleMarker) => {
handleMarker?.on("dragstart", () => {
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
});
handleMarker?.on("dragend", () => {
dragging = false;
});
});
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
staleCenter !== undefined &&
staleRadius !== undefined &&
candidateCenter[0] === staleCenter[0] &&
candidateCenter[1] === staleCenter[1] &&
candidateRadius === staleRadius;
if (options.moveable) {
centerMarker.on("drag", () => {
const lngLat = centerMarker.getLngLat();
currentCenter = [lngLat.lat, lngLat.lng];
placeResizeHandle();
redraw();
});
centerMarker.on("dragend", () => options.onMove?.(currentCenter));
}
// The center element may be reused for a rebuilt circle; its listeners go with this one
let removeCenterListeners: (() => void) | undefined;
if (options.onClick) {
// A drag can end in a click; only one with its own pointer down counts
let dragged = false;
centerMarker.on("dragstart", () => {
dragged = true;
});
const onPointerDown = () => {
dragged = false;
};
const onClick = (ev: MouseEvent) => {
ev.stopPropagation();
if (dragged) {
return;
}
options.onClick!();
};
const onKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
options.onClick!();
}
};
centerEl.addEventListener("pointerdown", onPointerDown);
centerEl.addEventListener("click", onClick);
centerEl.addEventListener("keydown", onKeydown);
removeCenterListeners = () => {
centerEl.removeEventListener("pointerdown", onPointerDown);
centerEl.removeEventListener("click", onClick);
centerEl.removeEventListener("keydown", onKeydown);
};
}
return {
get center() {
return currentCenter;
},
get radius() {
return currentRadius;
},
update: (newCenter, newRadius) => {
if (dragging) {
return;
}
const stale = isStale(newCenter, newRadius);
staleCenter = undefined;
staleRadius = undefined;
if (stale) {
return;
}
currentCenter = newCenter;
currentRadius = newRadius;
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
placeResizeHandle();
redraw();
},
remove: () => {
if (frame !== undefined) {
cancelAnimationFrame(frame);
}
removeCenterListeners?.();
centerMarker.remove();
resetMarkerElement(centerEl);
clearMarkerAccessibility(centerEl);
this._placedElements.delete(centerEl);
resizeMarker?.remove();
this._removeCustomLayer(`${id}-fill`);
this._removeCustomLayer(`${id}-line`);
this._removeCustomSource(id);
},
};
}
public addPath(path: MapPath): MapItemHandle {
if (!this._map || !this._maplibre) {
return { remove: () => undefined };
@@ -760,40 +1109,58 @@ export class MapLibreMapEngine implements MapEngine {
id: string,
data: Feature<Polygon> | FeatureCollection
): void {
// Recorded now, so an update while the style loads reaches the record and
// a swap in between carries it; the map itself may already have it then
const source: GeoJSONSourceSpecification = { type: "geojson", data };
this._customSources.set(id, source);
this._whenStyleLoaded(() => {
const source: GeoJSONSourceSpecification = { type: "geojson", data };
this._map!.addSource(id, source);
this._customSources.set(id, source);
if (!this._map!.getSource(id)) {
this._map!.addSource(id, source);
}
});
}
private _setCustomSourceData(
id: string,
data: Feature<Polygon> | FeatureCollection
): void {
const source = this._customSources.get(id);
if (source) {
source.data = data;
}
(this._map?.getSource(id) as GeoJSONSource | undefined)?.setData(data);
}
private _addCustomLayer(layer: LayerSpecification): void {
this._customLayers.set(layer.id, layer);
this._whenStyleLoaded(() => {
if (this._map!.getLayer(layer.id)) {
return;
}
// Under the labels, over the base cartography
const symbolLayer = this._map!.getStyle().layers.find(
(styleLayer) =>
styleLayer.type === "symbol" && !this._customLayers.has(styleLayer.id)
);
this._map!.addLayer(layer, symbolLayer?.id);
this._customLayers.set(layer.id, layer);
});
}
private _removeCustomLayer(id: string): void {
this._customLayers.delete(id);
this._whenStyleLoaded(() => {
if (this._map!.getLayer(id)) {
this._map!.removeLayer(id);
}
this._customLayers.delete(id);
});
}
private _removeCustomSource(id: string): void {
this._customSources.delete(id);
this._whenStyleLoaded(() => {
if (this._map!.getSource(id)) {
this._map!.removeSource(id);
}
this._customSources.delete(id);
});
}
@@ -973,7 +1340,6 @@ export class MapLibreMapEngine implements MapEngine {
group.iconMarker?.remove();
this._openGroup(group);
};
icon.element.tabIndex = 0;
setMarkerAccessibility(
icon.element,
group.members
+132 -37
View File
@@ -1,27 +1,14 @@
/**
* Engine abstraction for ha-map.
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
* contract; new capabilities go on MapLibre only, as optional members like
* `editing`.
*
* ha-map keeps all Home Assistant semantics (entities, zones, cluster bubble
* DOM, history path math, fit policy) and delegates the primitive map
* operations to a MapEngine, so the engine can be selected at runtime:
* MapLibre GL native where WebGL2 is available, Leaflet otherwise (and always
* for ha-locations-editor, which edits with leaflet-draw).
*
* The interface exposes no engine types: positions are [latitude, longitude]
* tuples and marker content is caller-owned HTML elements.
*
* Zoom levels use Leaflet semantics (zoom 0 = one world tile), the historical
* convention across Home Assistant map configs. The MapLibre engine converts
* internally (MapLibre zoom = Leaflet zoom - 1).
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: number];
export interface MapPoint {
x: number;
y: number;
}
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
@@ -62,8 +49,10 @@ export interface MapMarkerOptions {
size: [width: number, height: number];
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
anchor?: [x: number, y: number];
/** Takes pointer input and keyboard focus; defaults to true */
/** Takes pointer input; defaults to true */
interactive?: boolean;
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
focusable?: boolean;
/** Accessible name */
title?: string;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
@@ -109,6 +98,58 @@ export interface MapMarkerHandle extends MapItemHandle {
readonly clusterData?: unknown;
}
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
onDragEnd?(location: MapLatLng): void;
}
export interface MapEditableCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
resizable?: boolean;
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
resizeLabel?: string;
onMove?(center: MapLatLng): void;
onResize?(radius: number): void;
onClick?(): void;
}
export interface MapEditingSupport {
/** Place a draggable HTML element marker */
addDraggableMarker(
element: HTMLElement,
location: MapLatLng,
options: MapDraggableMarkerOptions
): MapEditableMarkerHandle;
/** Draw a circle whose center and radius can be dragged */
addEditableCircle(
center: MapLatLng,
options: MapEditableCircleOptions
): MapEditableCircleHandle;
}
/** A circle with drag handles for its center and radius */
export interface MapEditableCircleHandle extends MapItemHandle {
readonly center: MapLatLng;
readonly radius: number;
/** Move and resize without recreating (no-op mid-drag) */
update(center: MapLatLng, radius: number): void;
}
export interface MapEditableMarkerHandle extends MapMarkerHandle {
/** Move without recreating (no-op mid-drag) */
setLocation(location: MapLatLng): void;
}
export interface MapClusterIcon {
element: HTMLElement;
size: [width: number, height: number];
@@ -159,13 +200,13 @@ export interface MapEngine {
/** Fit the given points into view; a single point centers on it */
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
// Content ------------------------------------------------------------
/** Pan to the location, keeping the zoom */
panTo(location: MapLatLng): void;
/**
* Place an HTML element on the map. The element is owned by the caller;
* the engine positions it and, for interactive markers, makes it
* focusable.
*/
/** Whether the location is inside the current viewport */
containsLocation(location: MapLatLng): boolean;
/** Place a caller-owned element on the map */
addMarker(
element: HTMLElement,
location: MapLatLng,
@@ -175,33 +216,87 @@ export interface MapEngine {
/** Draw a meter-radius circle (zone radius) */
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
editing?: MapEditingSupport;
/** Draw one history trail (points with tooltips, connecting segments) */
addPath(path: MapPath): MapItemHandle;
/**
* Enable or disable clustering of the markers added with cluster: true.
* Must be called after each batch of addMarker calls to place clusterable
* markers on the map; null places them unclustered.
*/
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
setClustering(options: MapClusterOptions | null): void;
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
refreshClusters(): void;
}
const EARTH_RADIUS = 6371008.8;
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
/**
* Bounding box corners of a circle, for fitting a radius into view without
* engine-specific circle bounds.
* The point a distance away from center along a bearing (degrees clockwise
* from north), on the great circle. Longitude is left unwrapped so a ring of
* points stays continuous across the antimeridian.
*/
export const destinationPoint = (
center: MapLatLng,
distanceInMeters: number,
bearingDegrees: number
): MapLatLng => {
const angular = distanceInMeters / EARTH_RADIUS;
const lat = toRadians(center[0]);
const bearing = toRadians(bearingDegrees);
const destLat = Math.asin(
Math.sin(lat) * Math.cos(angular) +
Math.cos(lat) * Math.sin(angular) * Math.cos(bearing)
);
const dLng = Math.atan2(
Math.sin(bearing) * Math.sin(angular) * Math.cos(lat),
Math.cos(angular) - Math.sin(lat) * Math.sin(destLat)
);
return [toDegrees(destLat), center[1] + toDegrees(dLng)];
};
/** Great-circle distance in meters */
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
const dLat = toRadians(b[0] - a[0]);
const dLng = toRadians(b[1] - a[1]);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRadians(a[0])) *
Math.cos(toRadians(b[0])) *
Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
};
/** The point the given distance due east of center, e.g. for a resize handle */
export const pointEastOf = (
center: MapLatLng,
distanceInMeters: number
): MapLatLng => destinationPoint(center, distanceInMeters, 90);
/**
* Bounding box corners of a circle, for fitting a radius into view. A circle
* that reaches a pole spans every longitude.
*/
export const circleBoundsPoints = (
center: MapLatLng,
radiusMeters: number
): MapLatLng[] => {
const latOffset = radiusMeters / 111320;
const lngOffset =
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
const angular = radiusMeters / EARTH_RADIUS;
const latMin = Math.max(-90, center[0] - toDegrees(angular));
const latMax = Math.min(90, center[0] + toDegrees(angular));
const sinRatio = Math.sin(angular) / Math.cos(toRadians(center[0]));
if (latMin <= -90 || latMax >= 90 || Math.abs(sinRatio) >= 1) {
return [
[latMin, -180],
[latMax, 180],
];
}
const dLng = toDegrees(Math.asin(sinRatio));
return [
[center[0] - latOffset, center[1] - lngOffset],
[center[0] + latOffset, center[1] + lngOffset],
[latMin, center[1] - dLng],
[latMax, center[1] + dLng],
];
};
+33 -3
View File
@@ -1,23 +1,53 @@
/**
* Marker elements are focusable buttons on both engines, so the caller's
* Marker elements may be focusable buttons on both engines, so the caller's
* element is the keyboard target and needs a name and a role. MapLibre would
* otherwise label it "Map marker".
*
* What the engine sets is recorded on the element, so a rebuilt marker on a
* reused element gets fresh attributes while the caller's own are left alone.
*/
const OWNED = "haMapA11y";
export const setMarkerAccessibility = (
element: HTMLElement,
title: string | undefined,
interactive: boolean
focusable: boolean
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
if (!element.hasAttribute("role")) {
if (interactive) {
if (focusable) {
element.setAttribute("role", "button");
owned.push("role");
} else if (title) {
element.setAttribute("role", "img");
owned.push("role");
} else {
element.setAttribute("aria-hidden", "true");
owned.push("aria-hidden");
}
}
if (focusable && !element.hasAttribute("tabindex")) {
element.tabIndex = 0;
owned.push("tabindex");
}
element.dataset[OWNED] = owned.join(" ");
};
/** Removes what setMarkerAccessibility set on the element */
export const clearMarkerAccessibility = (element: HTMLElement): void => {
const owned = element.dataset[OWNED];
if (owned === undefined) {
return;
}
owned
.split(" ")
.filter(Boolean)
.forEach((name) => element.removeAttribute(name));
delete element.dataset[OWNED];
};
-2
View File
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
attribute: string
) => string;
export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
@@ -23,6 +23,8 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
// Taller rows when the name is drawn under the bar instead of in a column.
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -41,6 +43,11 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render each row's name inside the plot (under its bar) instead of in a
// left-hand category-label column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -70,7 +77,11 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${
this.data.length *
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
GRID_BOTTOM
}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -185,6 +196,7 @@ export class StateHistoryChartTimeline extends LitElement {
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
@@ -196,9 +208,11 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
@@ -227,31 +241,47 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
axisLabel: insideLabels
? {
// Draw the name inside the plot, under each row's bar, matching
// the line charts whose legend sits under the plot. The taller
// rows keep a name clear of the next row's bar.
show: showNames,
inside: true,
margin: 0,
padding: [18, 0, 0, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "top",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
return label;
},
hideOverlap: true,
},
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
},
grid: {
top: 10,
@@ -79,6 +79,11 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render timeline row names inside the plot (under each bar) instead of in a
// left-hand column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +232,7 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -444,6 +450,10 @@ export class StateHistoryCharts extends LitElement {
margin-top: 16px;
}
.entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.container,
lit-virtualizer {
height: 100%;
+11 -8
View File
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import {
ENTITY_NAME_TYPES,
type EntityNameItem,
type EntityNameType,
} from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
</ha-combo-box-item>
`;
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
const formatOptionValue = (item: EntityNameItem) => {
if (item.type === "text" && item.text) {
@@ -170,6 +171,7 @@ export class HaEntityNamePicker extends LitElement {
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
no-sort
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
@@ -387,6 +389,7 @@ export class HaEntityNamePicker extends LitElement {
);
if (context.device) options.add("device");
if (context.parentDevice) options.add("parent_device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
@@ -399,8 +402,8 @@ export class HaEntityNamePicker extends LitElement {
const types = this._validTypes(entityId);
const items = (
["entity", "device", "area", "floor"] as const
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
).map<PickerComboBoxItem>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = types.has(name);
@@ -464,7 +467,7 @@ export class HaEntityNamePicker extends LitElement {
const excludedValues = new Set(
this._items
.filter((item) => UNIQUE_TYPES.has(item.type))
.filter((item) => KNOWN_TYPES.has(item.type))
.map((item) => formatOptionValue(item))
);
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
),
});
}
if (context.parentDevice) {
contextItems.push({
id: "parent_device_name",
primary: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.parent_device_name"
),
});
}
if (context.area) {
contextItems.push({
id: "area_name",
+41 -18
View File
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
);
const primary = entityName || deviceName || statisticId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
+19 -9
View File
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
+12
View File
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
@@ -795,6 +798,15 @@ export class HaCodeEditor extends ReactiveElement {
});
}
if (parentDeviceName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.device-picker.parent_device"
),
value: parentDeviceName,
});
}
if (areaName) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
@@ -42,6 +42,7 @@ const SELECTOR_FALLBACK_VALUES = {
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
offset: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
@@ -74,6 +74,7 @@ const SELECTOR_INITIAL_VALUES = {
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
offset: () => ({ type: "none" }),
period: undefined,
qr_code: undefined,
select: (selector) => {
@@ -1,6 +1,7 @@
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import type { DurationSelector } from "../../data/selector";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
@@ -26,35 +27,7 @@ export class HaTimeDuration extends LitElement {
return this._input?.reportValidity() ?? true;
}
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
);
private _data = memoizeOne(durationValueToData);
protected render() {
return html`
@@ -0,0 +1,182 @@
import {
mdiClockMinusOutline,
mdiClockOutline,
mdiClockPlusOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type {
OffsetSelector,
OffsetSelectorValue,
OffsetType,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
import "../ha-input-helper-text";
import "../ha-select";
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
{ value: "none", iconPath: mdiClockOutline },
{ value: "before", iconPath: mdiClockMinusOutline },
{ value: "after", iconPath: mdiClockPlusOutline },
];
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
typeof value === "object" && value !== null && "type" in value;
@customElement("ha-selector-offset")
export class HaOffsetSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: OffsetSelector;
@property({ attribute: false }) public value?: OffsetSelectorValue;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
public reportValidity(): boolean {
return this._durationInput?.reportValidity() ?? true;
}
private get _value(): OffsetSelectorValue | undefined {
return isOffsetValue(this.value) ? this.value : undefined;
}
private _duration = memoizeOne(durationValueToData);
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
OFFSET_TYPES.map(({ value, iconPath }) => ({
value,
iconPath,
label: localize(`ui.components.selectors.offset.${value}`),
}))
);
protected render() {
const type = this._value?.type ?? "none";
return html`
<div class="container">
${
this.label
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
: nothing
}
<div class="inputs">
<ha-select
.value=${type}
.options=${this._typeOptions(this.hass.localize)}
.disabled=${this.disabled}
@selected=${this._typeChanged}
></ha-select>
${
type !== "none"
? html`<div class="value-row">
<span class="value-label"
>${this.hass.localize(
"ui.components.selectors.offset.duration"
)}${this.required ? "*" : ""}</span
>
<ha-duration-input
.data=${this._duration(this._value?.duration)}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.offset?.enable_day}
.enableMillisecond=${
this.selector.offset?.enable_millisecond
}
@value-changed=${this._durationChanged}
></ha-duration-input>
</div>`
: nothing
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
</div>
`;
}
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
ev.stopPropagation();
const type = this._value?.type;
if (!type || type === "none") {
return;
}
fireEvent(this, "value-changed", {
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
});
}
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const type = ev.detail.value as OffsetType | undefined;
if (!type || type === this._value?.type) {
return;
}
fireEvent(this, "value-changed", {
value:
type === "none"
? { type }
: {
type,
duration:
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
},
});
}
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
}
.inputs,
.value-row {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.value-label {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-select {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-offset": HaOffsetSelector;
}
}
@@ -43,6 +43,7 @@ const LOAD_ELEMENTS = {
number: () => import("./ha-selector-number"),
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
object: () => import("./ha-selector-object"),
offset: () => import("./ha-selector-offset"),
period: () => import("./ha-selector-period"),
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),
+158 -233
View File
@@ -1,22 +1,19 @@
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
Marker,
MarkerOptions,
} from "leaflet";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import type { ThemeMode } from "../../types";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
@@ -41,8 +38,16 @@ export interface MarkerLocation {
radius_color?: string;
location_editable?: boolean;
radius_editable?: boolean;
/** Clicking or activating the marker fires marker-clicked */
clickable?: boolean;
}
const ICON_SIZE = 24;
/**
* A map with draggable markers and zone circles, drawn by ha-map. Without
* editing support (the Leaflet fallback) they are static, with a notice.
*/
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public locations?: MarkerLocation[];
@@ -59,28 +64,13 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Boolean, attribute: "pin-on-click" })
public pinOnClick = false;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
private Leaflet?: LeafletModuleType;
@state() private _editingAvailable = true;
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
private _loadPromise: Promise<boolean | undefined | void>;
constructor() {
super();
this._loadPromise = import("leaflet").then((module) =>
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
return this.updateComplete.then(() => this.fitMap());
})
);
}
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
@@ -97,43 +87,50 @@ export class HaLocationsEditor extends LitElement {
id: string,
options?: { zoom?: number }
): Promise<void> {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) {
await this.updateComplete;
const location = this.locations?.find((loc) => loc.id === id);
if (!location) {
return;
}
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
if ("getBounds" in marker) {
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
const center: MapLatLng = [location.latitude, location.longitude];
if (location.radius) {
// Only the map's maximum caps the zoom, however small the zone
this.map.fitBounds(circleBoundsPoints(center, location.radius), {
pad: 0,
zoom: MAP_MAX_ZOOM,
});
} else {
const circle = this._circles[id];
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
this.map.setView(center, options?.zoom || this.zoom);
}
}
protected render(): TemplateResult {
return html`
<ha-map
engine="leaflet"
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
></ha-map>
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
@editable-location-moved=${this._locationMoved}
@editable-location-resized=${this._radiusChanged}
@editable-location-clicked=${this._markerClicked}
@editing-available-changed=${this._editingAvailableChanged}
></ha-map>
${
!this._editingAvailable &&
this._i18n &&
this.locations?.some(
(location) => location.location_editable || location.radius_editable
)
? html`<ha-alert alert-type="warning">
${this._i18n.localize("ui.components.map.editing_unavailable")}
</ha-alert>`
: ""
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
@@ -142,63 +139,102 @@ export class HaLocationsEditor extends LitElement {
`;
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): (Marker | Circle)[] => {
const layers: (Marker | Circle)[] = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
private _editableLocations = memoizeOne(
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
this._elements.delete(id);
}
}
return layers;
return (locations ?? []).map((location) => ({
id: location.id,
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
activatable: location.clickable,
}));
}
);
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
// Still loading.
if (!this.Leaflet) {
return;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const key = JSON.stringify([
location.icon,
location.iconPath,
location.name,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
const element = this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
if (changedProps.has("locations")) {
this._updateMarkers();
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
}
const el = document.createElement("div");
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
return el;
}
public updated(changedProps: PropertyValues): void {
// Still loading.
if (!this.Leaflet) {
return;
}
if (changedProps.has("locations")) {
const oldLocations = changedProps.get("locations");
fireEvent(this, "markers-updated");
// Follow a location that was edited out of view
const oldLocations = changedProps.get("locations") as
MarkerLocation[] | undefined;
const movedLocations = this.locations?.filter(
(loc, idx) =>
!oldLocations[idx] ||
!oldLocations?.[idx] ||
((loc.latitude !== oldLocations[idx].latitude ||
loc.longitude !== oldLocations[idx].longitude) &&
this.map.leafletMap?.getBounds().contains({
lat: oldLocations[idx].latitude,
lng: oldLocations[idx].longitude,
}) &&
!this.map.leafletMap
?.getBounds()
.contains({ lat: loc.latitude, lng: loc.longitude }))
this.map.containsLocation([
oldLocations[idx].latitude,
oldLocations[idx].longitude,
]) &&
!this.map.containsLocation([loc.latitude, loc.longitude]))
);
if (movedLocations?.length === 1) {
this.map.leafletMap?.panTo({
lat: movedLocations[0].latitude,
lng: movedLocations[0].longitude,
});
this.map.panTo([
movedLocations[0].latitude,
movedLocations[0].longitude,
]);
}
}
}
private _editingAvailableChanged(ev: HASSDomEvent<{ available: boolean }>) {
this._editingAvailable = ev.detail.available;
}
private _normalizeLongitude(longitude: number): number {
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
@@ -207,40 +243,37 @@ export class HaLocationsEditor extends LitElement {
return longitude;
}
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
const location: [number, number] = [
latlng.lat,
this._normalizeLongitude(latlng.lng),
];
private _locationMoved(
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
) {
const [latitude, longitude] = ev.detail.location;
fireEvent(
this,
"location-updated",
{ id: marker.id, location },
{
id: ev.detail.id,
location: [latitude, this._normalizeLongitude(longitude)],
},
{ bubbles: false }
);
}
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
fireEvent(
this,
"radius-updated",
{ id: marker.id, radius: circle.getRadius() },
{ id: ev.detail.id, radius: ev.detail.radius },
{ bubbles: false }
);
}
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
}
private _mapClicked(ev) {
if (this.pinOnClick && this._locationMarkers) {
const id = Object.keys(this._locationMarkers)[0];
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
if (this.pinOnClick && this.locations?.length) {
const id = this.locations[0].id;
const location: [number, number] = [
ev.detail.location[0],
this._normalizeLongitude(ev.detail.location[1]),
@@ -249,136 +282,28 @@ export class HaLocationsEditor extends LitElement {
// If the normalized longitude wraps around the globe, pan to the new location.
if (location[1] !== ev.detail.location[1]) {
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
this.map.panTo(location);
}
}
}
private _updateMarkers(): void {
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor =
getComputedStyle(this).getPropertyValue("--accent-color");
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
if (location.icon || location.iconPath) {
// create icon
const el = document.createElement("div");
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
icon = this.Leaflet!.divIcon({
html: el.outerHTML,
iconSize: [24, 24],
className: "light",
});
}
if (location.radius) {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
circle.addEventListener("add", () => {
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
circles[location.id] = circle;
}
}
if (
!location.radius ||
(!location.radius_editable && !location.location_editable)
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
options.icon = icon;
}
const marker = this.Leaflet!.marker(
[location.latitude, location.longitude],
options
)
.addEventListener("dragend", (ev: DragEndEvent) =>
this._updateLocation(ev)
)
.addEventListener(
// @ts-ignore
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
(marker as any).id = location.id;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
fireEvent(this, "markers-updated");
}
static styles = css`
.map {
position: relative;
height: 100%;
}
ha-map {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
ha-alert {
position: absolute;
top: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
z-index: 1;
}
`;
}
+330 -61
View File
@@ -1,9 +1,8 @@
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { Layer } from "leaflet";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
import { css, ReactiveElement, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { formatDateTime } from "../../common/datetime/format_date_time";
import {
@@ -17,7 +16,6 @@ import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsWebGL2 } from "../../common/map/base-layer";
import type { LeafletMapEngine } from "../../common/map/engines/leaflet-map-engine";
import type {
MapClusterIcon,
MapEngine,
@@ -27,8 +25,12 @@ import type {
MapPath,
MapPathMarker,
MapPathSegment,
MapEditableCircleHandle,
MapEditableMarkerHandle,
MapEditingSupport,
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { filterXSS } from "../../common/util/xss";
import {
configContext,
@@ -82,6 +84,125 @@ export const MAP_CARD_MARKER_LABEL_MODES = [
export type MapCardMarkerLabelMode =
(typeof MAP_CARD_MARKER_LABEL_MODES)[number];
/** A location drawn for editing: a draggable marker, or a circle with a moveable center and radius */
export interface HaMapEditableLocation {
id: string;
location: MapLatLng;
radius?: number;
/** Element shown at the location, e.g. a named icon */
element?: HTMLElement;
elementSize?: [width: number, height: number];
title?: string;
color?: string;
locationEditable?: boolean;
radiusEditable?: boolean;
/** Activating the marker fires editable-location-clicked; otherwise it is not a button */
activatable?: boolean;
}
// Geometry is updated in place; a change to anything else rebuilds the marker
const sameAppearance = (
a: HaMapEditableLocation,
b: HaMapEditableLocation
): boolean =>
a.element === b.element &&
a.title === b.title &&
a.color === b.color &&
a.locationEditable === b.locationEditable &&
a.radiusEditable === b.radiusEditable &&
a.activatable === b.activatable &&
a.elementSize?.[0] === b.elementSize?.[0] &&
a.elementSize?.[1] === b.elementSize?.[1];
// Without editing support (the Leaflet fallback) locations are static and redrawn on edits
const staticEditing = (engine: MapEngine): MapEditingSupport => ({
addDraggableMarker: (element, location, options) => {
let current = location;
let marker = engine.addMarker(element, current, options);
return {
get location() {
return current;
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
marker.remove();
current = newLocation;
marker = engine.addMarker(element, current, options);
},
remove: () => marker.remove(),
};
},
addEditableCircle: (center, options) => {
const centerEl = options.centerElement ?? document.createElement("div");
if (!options.centerElement) {
centerEl.className = "editable-circle-center";
}
// The center element may be reused for a rebuilt circle; the listeners go with this one
let removeListeners: (() => void) | undefined;
if (options.onClick) {
const onClick = (ev: Event) => {
ev.stopPropagation();
options.onClick!();
};
const onKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
options.onClick!();
}
};
centerEl.addEventListener("click", onClick);
centerEl.addEventListener("keydown", onKeydown);
removeListeners = () => {
centerEl.removeEventListener("click", onClick);
centerEl.removeEventListener("keydown", onKeydown);
};
}
let current = { center, radius: options.radius };
let items: MapItemHandle[] = [];
const draw = () => {
items = [
engine.addCircle(current.center, {
radius: current.radius,
color: options.color,
}),
engine.addMarker(centerEl, current.center, {
size: options.centerSize ?? [16, 16],
interactive: !!options.onClick,
title: options.title,
}),
];
};
draw();
return {
get center() {
return current.center;
},
get radius() {
return current.radius;
},
update: (newCenter, newRadius) => {
items.forEach((item) => item.remove());
current = { center: newCenter, radius: newRadius };
draw();
},
remove: () => {
removeListeners?.();
items.forEach((item) => item.remove());
},
};
},
});
declare global {
interface HASSDomEvents {
"editable-location-moved": { id: string; location: MapLatLng };
"editable-location-resized": { id: string; radius: number };
"editable-location-clicked": { id: string };
/** Whether the loaded engine can edit; false on the Leaflet fallback */
"editing-available-changed": { available: boolean };
}
}
export interface HaMapEntity {
entity_id: string;
color: string;
@@ -127,17 +248,9 @@ export class HaMap extends ReactiveElement {
@property({ attribute: false }) public paths?: HaMapPaths[];
/**
* Raw Leaflet layers, for ha-locations-editor only. Requires
* engine="leaflet".
*/
@property({ attribute: false }) public layers?: Layer[];
/**
* Which map engine to use. "leaflet" is required by embedders that manage
* raw Leaflet layers (ha-locations-editor); "auto" picks the best engine.
*/
@property() public engine: "auto" | "leaflet" = "auto";
/** Locations drawn with drag handles for editing (ha-locations-editor) */
@property({ attribute: false })
public editableLocations?: HaMapEditableLocation[];
@property({ type: Boolean }) public clickable = false;
@@ -168,11 +281,18 @@ export class HaMap extends ReactiveElement {
private _engine?: MapEngine;
/** Escape hatch for ha-locations-editor; only set with engine="leaflet" */
public get leafletMap() {
// Only the Leaflet engine has this property
return (this._engine as Partial<LeafletMapEngine> | undefined)?.leafletMap;
}
// Reconciled by id, so updates move handles instead of recreating them
private _editableHandles = new Map<
string,
(
| { kind: "circle"; handle: MapEditableCircleHandle }
| { kind: "marker"; handle: MapEditableMarkerHandle }
) & {
source: HaMapEditableLocation;
/** Detaches what ha-map itself put on the caller's element */
cleanup?: () => void;
}
>();
private _resizeObserver?: ResizeObserver;
@@ -227,6 +347,7 @@ export class HaMap extends ReactiveElement {
this._entityHandles = [];
this._zoneHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
this._focusZonePoints = [];
@@ -282,12 +403,24 @@ export class HaMap extends ReactiveElement {
this._drawPaths();
}
if (changedProps.has("_loaded") || changedProps.has("layers")) {
this._drawLayers(changedProps.get("layers") as Layer[] | undefined);
autoFitRequired = true;
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
// Added or removed locations refit; edits keep the view
if (this._drawEditableLocations()) {
autoFitRequired = true;
}
} else if (changedProps.has("_i18n") && this._editableHandles.size) {
// Titles and handle labels are localized when drawn
this._removeEditableLocations();
this._drawEditableLocations();
}
if (changedProps.has("_loaded") || (this.autoFit && autoFitRequired)) {
if (changedProps.has("_loaded") && this._pendingFit) {
// A fit requested before the engine was ready wins over the default
this._runPendingFit();
} else if (
changedProps.has("_loaded") ||
(this.autoFit && autoFitRequired)
) {
this.fitMap();
}
@@ -336,7 +469,7 @@ export class HaMap extends ReactiveElement {
// Each engine is its own chunk; a map only downloads the one it uses
private async _createEngine(): Promise<MapEngine> {
if (this.engine === "leaflet" || this._forceLeaflet || !supportsWebGL2()) {
if (this._forceLeaflet || !supportsWebGL2()) {
const leaflet =
await import("../../common/map/engines/leaflet-map-engine");
return new leaflet.LeafletMapEngine();
@@ -422,6 +555,9 @@ export class HaMap extends ReactiveElement {
this._engine = engine;
this._updateMapStyle();
this._loaded = true;
fireEvent(this, "editing-available-changed", {
available: !!engine.editing,
});
} finally {
if (attempt === this._setupAttempt) {
this._loading = false;
@@ -451,6 +587,7 @@ export class HaMap extends ReactiveElement {
this._entityHandles = [];
this._zoneHandles = [];
this._pathHandles = [];
this._removeEditableLocations();
this._focusPoints = [];
this._focusZonePoints = [];
this._pendingFit = undefined;
@@ -502,7 +639,7 @@ export class HaMap extends ReactiveElement {
if (
!this._focusPoints.length &&
!this._focusZonePoints.length &&
!this.layers?.length
!this.editableLocations?.length
) {
this._withProgrammaticFit(() => {
this._engine!.setView(
@@ -516,22 +653,14 @@ export class HaMap extends ReactiveElement {
const points = [...this._focusPoints, ...this._focusZonePoints];
// Raw Leaflet layers (ha-locations-editor) contribute their bounds
const leafletMap = this.leafletMap;
if (this.layers?.length && leafletMap) {
this.layers.forEach((layer: any) => {
if ("getBounds" in layer) {
const layerBounds = layer.getBounds();
points.push(
[layerBounds.getSouth(), layerBounds.getWest()],
[layerBounds.getNorth(), layerBounds.getEast()]
);
} else if ("getLatLng" in layer) {
const latLng = layer.getLatLng();
points.push([latLng.lat, latLng.lng]);
}
});
}
// Editable locations contribute their bounds, radius included
this.editableLocations?.forEach((editable) => {
if (editable.radius) {
points.push(...circleBoundsPoints(editable.location, editable.radius));
} else {
points.push(editable.location);
}
});
this._withProgrammaticFit(() => {
this._engine!.fitBounds(points, {
@@ -565,11 +694,30 @@ export class HaMap extends ReactiveElement {
}
}
public panTo(location: MapLatLng): void {
this._engine?.panTo(location);
}
public containsLocation(location: MapLatLng): boolean {
return this._engine?.containsLocation(location) ?? false;
}
public setView(center: MapLatLng, zoom?: number): void {
if (!this._engine) {
this._pendingFit = () => this.setView(center, zoom);
return;
}
this._pendingFit = undefined;
this._engine.setView(center, zoom);
}
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
) {
if (!this._engine) {
// Engine still loading (see _loadMap); runs once it is
this._pendingFit = () => this.fitBounds(boundingbox, options);
return;
}
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
@@ -585,20 +733,146 @@ export class HaMap extends ReactiveElement {
this._hasFitted = true;
}
private _drawLayers(prevLayers: Layer[] | undefined): void {
if (prevLayers) {
prevLayers.forEach((layer) => layer.remove());
// Returns whether locations were added or removed
private _drawEditableLocations(): boolean {
const engine = this._engine;
if (!engine) {
return false;
}
if (!this.layers) {
return;
const staticSupport = staticEditing(engine);
const editing = engine.editing ?? staticSupport;
const wanted = new Set((this.editableLocations ?? []).map((e) => e.id));
let changed = false;
for (const [id, entry] of this._editableHandles) {
if (!wanted.has(id)) {
entry.cleanup?.();
entry.handle.remove();
this._editableHandles.delete(id);
changed = true;
}
}
const leafletMap = this.leafletMap;
if (!leafletMap) {
return;
if (!this.editableLocations) {
return changed;
}
this.layers.forEach((layer) => {
leafletMap.addLayer(layer);
});
const defaultColor =
getComputedStyle(this).getPropertyValue("--accent-color");
for (const editable of this.editableLocations) {
const { id } = editable;
// Markers are buttons, so an unnamed location still gets a name
const title =
editable.title ?? this._i18n?.localize("ui.components.map.location");
const existing = this._editableHandles.get(id);
const kind = editable.radius ? "circle" : "marker";
if (
existing &&
existing.kind === kind &&
sameAppearance(existing.source, editable)
) {
if (existing.kind === "circle") {
existing.handle.update(editable.location, editable.radius!);
} else {
existing.handle.setLocation(editable.location);
}
existing.source = editable;
continue;
}
if (existing) {
existing.cleanup?.();
existing.handle.remove();
} else {
changed = true;
}
if (kind === "circle") {
this._editableHandles.set(id, {
kind,
source: editable,
handle: editing.addEditableCircle(editable.location, {
radius: editable.radius!,
color: editable.color || defaultColor,
centerElement: editable.element,
centerSize: editable.elementSize,
title,
moveable: editable.locationEditable,
resizable: editable.radiusEditable,
resizeLabel: editable.title
? this._i18n?.localize("ui.components.map.radius_of", {
name: editable.title,
})
: this._i18n?.localize("ui.components.map.radius"),
onMove: (location) =>
fireEvent(this, "editable-location-moved", { id, location }),
onResize: (radius) =>
fireEvent(this, "editable-location-resized", { id, radius }),
onClick: editable.activatable
? () => fireEvent(this, "editable-location-clicked", { id })
: undefined,
}),
});
continue;
}
const element = editable.element ?? document.createElement("div");
if (!editable.element) {
element.className = "editable-circle-center";
}
let dragged = false;
let cleanup: (() => void) | undefined;
if (editable.activatable) {
// A drag can end in a click; only one with its own pointer down counts
const onPointerDown = () => {
dragged = false;
};
const onClick = (ev: Event) => {
ev.stopPropagation();
if (dragged) {
return;
}
fireEvent(this, "editable-location-clicked", { id });
};
const onKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
fireEvent(this, "editable-location-clicked", { id });
}
};
element.addEventListener("pointerdown", onPointerDown);
element.addEventListener("click", onClick);
element.addEventListener("keydown", onKeydown);
cleanup = () => {
element.removeEventListener("pointerdown", onPointerDown);
element.removeEventListener("click", onClick);
element.removeEventListener("keydown", onKeydown);
};
}
// A location that cannot be dragged is static on any engine
const support = editable.locationEditable ? editing : staticSupport;
this._editableHandles.set(id, {
kind,
source: editable,
cleanup,
handle: support.addDraggableMarker(element, editable.location, {
size: editable.elementSize ?? [16, 16],
interactive: true,
focusable: !!editable.activatable,
title,
onDragEnd: (location) => {
dragged = true;
fireEvent(this, "editable-location-moved", { id, location });
},
}),
});
}
return changed;
}
// One by one, so the listeners on the caller's elements are detached too
private _removeEditableLocations(): void {
for (const entry of this._editableHandles.values()) {
entry.cleanup?.();
entry.handle.remove();
}
this._editableHandles.clear();
}
private _computePathTooltip(path: HaMapPaths, point: HaMapPathPoint): string {
@@ -1015,8 +1289,9 @@ export class HaMap extends ReactiveElement {
.leaflet-tile-pane .leaflet-tile {
filter: var(--map-filter);
}
/* The adapter path (engine="leaflet" with WebGL2) loads no MapLibre
stylesheet; these are the only two rules its canvas needs. */
/* The Leaflet fallback with WebGL2 renders vectors through the adapter
without MapLibre's stylesheet; these are the only two rules its canvas
needs. */
.maplibregl-map {
position: relative;
overflow: hidden;
@@ -1059,13 +1334,7 @@ export class HaMap extends ReactiveElement {
.dark .leaflet-bar a:hover {
background-color: #313131;
}
.leaflet-marker-draggable {
cursor: move !important;
}
.leaflet-edit-resize {
border-radius: var(--ha-border-radius-circle);
cursor: nesw-resize !important;
}
${unsafeCSS(editableCircleStyles)}
.named-icon {
display: flex;
align-items: center;
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._entities,
this._devices,
this._areas,
this._floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
isRTL: boolean,
stateObj: HassEntity
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -21,7 +21,6 @@ import {
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
@@ -30,6 +29,7 @@ import {
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
@@ -132,10 +132,35 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
private _loadedConfigEntryId?: string;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
}
if (
changedProps.has("itemId") ||
changedProps.has("type") ||
changedProps.has("hass")
) {
this._updateDomain();
}
}
private _updateDomain() {
if (this.type === "entity") {
this._setDomainName(computeDomain(this.itemId));
return;
}
if (this.type !== "device") {
return;
}
const configEntryId =
this.hass.devices?.[this.itemId]?.primary_config_entry;
if (configEntryId && configEntryId !== this._loadedConfigEntryId) {
this._loadedConfigEntryId = configEntryId;
this._getDeviceDomain(configEntryId);
}
}
protected render() {
@@ -674,7 +699,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
}
private _itemData = memoizeOne((type: TargetType, item: string) => {
private _itemData(type: TargetType, item: string) {
if (type === "floor") {
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
return {
@@ -697,9 +722,25 @@ export class HaTargetPickerItemRow extends LitElement {
if (type === "device") {
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
const context = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
return {
name: device
@@ -709,19 +750,17 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.states
)
: item,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
context,
fallbackIconPath: mdiDevices,
notFound: !device,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
const { area, device, parentDevice } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
@@ -729,10 +768,17 @@ export class HaTargetPickerItemRow extends LitElement {
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined };
: { area: undefined, device: undefined, parentDevice: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(
computeRTL(
@@ -760,7 +806,7 @@ export class HaTargetPickerItemRow extends LitElement {
fallbackIconPath: mdiLabel,
notFound: !label,
};
});
}
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
+24 -9
View File
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.deviceName",
weight: 7,
},
{
name: "search_labels.parentDeviceName",
weight: 6,
},
{
name: "search_labels.areaName",
weight: 6,
@@ -129,14 +133,20 @@ export const getEntities = (
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
@@ -146,7 +156,11 @@ export const getEntities = (
}
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -159,6 +173,7 @@ export const getEntities = (
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
+33 -17
View File
@@ -1,14 +1,15 @@
import type {
HassConfig,
HassEntities,
HassEntity,
HassEntityAttributeBase,
MessageBase,
} from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
import type { LocalizeFunc } from "../common/translations/localize";
import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { isNumericSensorDeviceClass } from "./sensor";
import type { FrontendLocaleData } from "./translation";
@@ -336,7 +337,7 @@ const processTimelineEntity = (
localize: LocalizeFunc,
locale: FrontendLocaleData,
config: HassConfig,
entities: HomeAssistant["entities"],
hass: HomeAssistant,
entityId: string,
states: EntityHistoryState[],
current_state: HassEntity | undefined
@@ -358,7 +359,7 @@ const processTimelineEntity = (
localize,
locale,
config,
entities[entityId],
hass.entities[entityId],
entityId,
{
...(state.a || first.a),
@@ -374,10 +375,16 @@ const processTimelineEntity = (
}
return {
name: computeStateNameFromEntityAttributes(
entityId,
current_state?.attributes || first.a
),
name: current_state
? hass.formatEntityName(current_state, DEFAULT_ENTITY_NAME, {
separator: computeRTL(
hass.language,
hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ",
}) || current_state.entity_id
: computeStateNameFromEntityAttributes(entityId, first.a),
entity_id: entityId,
data,
};
@@ -387,9 +394,15 @@ const processLineChartEntities = (
unit: string,
device_class: string | undefined,
entities: HistoryStates,
hassEntities: HassEntities
hass: HomeAssistant
): LineChartUnit => {
const data: LineChartEntity[] = [];
const nameSeparator = computeRTL(
hass.language,
hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ";
const entityIds = Object.keys(entities);
entityIds.forEach((entityId) => {
@@ -436,16 +449,19 @@ const processLineChartEntities = (
processedStates.push(processedState);
}
const attributes =
entityId in hassEntities
? hassEntities[entityId].attributes
: "friendly_name" in first.a
? first.a
: undefined;
const name =
entityId in hass.states
? hass.formatEntityName(hass.states[entityId], DEFAULT_ENTITY_NAME, {
separator: nameSeparator,
}) || entityId
: computeStateNameFromEntityAttributes(
entityId,
"friendly_name" in first.a ? first.a : {}
);
data.push({
domain,
name: computeStateNameFromEntityAttributes(entityId, attributes || {}),
name,
entity_id: entityId,
states: processedStates,
});
@@ -610,7 +626,7 @@ export const computeHistory = (
localize,
hass.locale,
hass.config,
hass.entities,
hass,
entityId,
stateInfo,
currentState
@@ -638,7 +654,7 @@ export const computeHistory = (
unit,
deviceClass,
lineChartDevices[key],
hass.states
hass
);
});
+16
View File
@@ -22,6 +22,7 @@ import type {
EntityRegistryEntry,
} from "./entity/entity_registry";
import type { EntitySources } from "./entity/entity_sources";
import type { HaDurationData } from "../components/ha-duration-input";
export type ThresholdMode = "crossed" | "changed" | "is";
@@ -63,6 +64,7 @@ export type Selector =
| NumberSelector
| NumericThresholdSelector
| ObjectSelector
| OffsetSelector
| PeriodSelector
| AssistPipelineSelector
| QRCodeSelector
@@ -446,6 +448,20 @@ export interface ObjectSelector {
} | null;
}
export type OffsetType = "none" | "before" | "after";
export interface OffsetSelector {
offset: {
enable_day?: boolean;
enable_millisecond?: boolean;
} | null;
}
export interface OffsetSelectorValue {
type: OffsetType;
duration?: HaDurationData | string | number;
}
export type PeriodKey =
| "today"
| "yesterday"
+20 -5
View File
@@ -1,8 +1,11 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
import type { OffsetSelectorValue, Selector } from "../selector";
export const formatSelectorValue = (
hass: HomeAssistant,
@@ -82,10 +85,7 @@ export const formatSelectorValue = (
if (!stateObj) {
return entityId;
}
const name = hass.formatEntityName(stateObj, [
{ type: "device" },
{ type: "entity" },
]);
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
return name || entityId;
})
.join(", ");
@@ -125,6 +125,21 @@ export const formatSelectorValue = (
.join(", ");
}
if ("offset" in selector) {
const { type, duration } = value as OffsetSelectorValue;
const durationData = durationValueToData(duration);
if (type === "none" || !durationData) {
return "";
}
const formattedDuration = formatDurationLong(hass.locale, durationData);
if (!formattedDuration) {
return "";
}
return hass.localize(`ui.components.selectors.offset.summary.${type}`, {
duration: formattedDuration,
});
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
@@ -0,0 +1,106 @@
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import type { HaDurationData } from "../../components/ha-duration-input";
import type { PlatformTrigger } from "../automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../automation";
import type { OffsetSelectorValue } from "../selector";
import type { TriggerDescription } from "../trigger";
const TRIGGER_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"options",
];
const isOffsetSelectorValue = (value: unknown): value is OffsetSelectorValue =>
typeof value === "object" && value !== null && "type" in value;
const absDuration = (duration: HaDurationData): HaDurationData =>
Object.fromEntries(
Object.entries(duration).map(([field, amount]) => [
field,
Math.abs(amount ?? 0),
])
);
const migrateLegacyOffsetOptions = (
options: Record<string, unknown>,
fields: TriggerDescription["fields"]
): Record<string, unknown> => {
let migrated: Record<string, unknown> | undefined;
for (const [key, field] of Object.entries(fields)) {
if (!field.selector || !("offset" in field.selector)) {
continue;
}
const typeKey = `${key}_type`;
const value = options[key] as OffsetSelectorValue["duration"] | undefined;
const hasLegacyType = typeKey in options;
if (
!hasLegacyType &&
(value === undefined || isOffsetSelectorValue(value))
) {
continue;
}
migrated ??= { ...options };
delete migrated[typeKey];
if (isOffsetSelectorValue(value)) {
continue;
}
const duration = durationValueToData(value ?? 0);
if (!duration || Object.values(duration).some((amount) => isNaN(amount))) {
continue;
}
let seconds = durationDataToSeconds(duration);
// The released trigger schema defaulted offset_type to before
if (options[typeKey] !== "after") {
seconds = -seconds;
}
migrated[key] =
seconds === 0
? { type: "none" }
: {
type: seconds < 0 ? "before" : "after",
duration: absDuration(duration),
};
}
return migrated ?? options;
};
const moveStrayKeysToOptions = (
trigger: PlatformTrigger
): PlatformTrigger | undefined => {
let migrated: PlatformTrigger | undefined;
for (const key in trigger) {
if (TRIGGER_KEYS.includes(key as keyof PlatformTrigger)) {
continue;
}
migrated ??= { ...trigger, options: { ...trigger.options } };
migrated.options![key] = trigger[key];
delete migrated[key];
}
return migrated;
};
export const migratePlatformTrigger = (
trigger: PlatformTrigger,
description?: TriggerDescription
): PlatformTrigger | undefined => {
let migrated = moveStrayKeysToOptions(trigger);
const options = (migrated ?? trigger).options;
if (options && description?.fields) {
const migratedOptions = migrateLegacyOffsetOptions(
options,
description.fields
);
if (migratedOptions !== options) {
migrated = { ...(migrated ?? trigger), options: migratedOptions };
}
}
return migrated;
};
+9 -3
View File
@@ -591,11 +591,17 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
+16 -1
View File
@@ -82,7 +82,7 @@ class HaMoreInfoRelated extends LitElement {
? (domain as ItemType)
: "entity";
const { floor, area, device } = getEntityContext(
const { floor, area, device, parentDevice } = getEntityContext(
this._stateObj,
this.hass.entities,
this.hass.devices,
@@ -91,6 +91,13 @@ class HaMoreInfoRelated extends LitElement {
);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states
)
: undefined;
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
: undefined;
@@ -124,6 +131,14 @@ class HaMoreInfoRelated extends LitElement {
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (parentDevice && parentDeviceName) {
contextEntries.push({
translationKey: "ui.dialogs.more_info_control.parent_device",
value: parentDeviceName,
href: `/config/devices/device/${parentDevice.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (device && deviceName) {
contextEntries.push({
translationKey: "ui.components.related-items.device",
+1
View File
@@ -267,6 +267,7 @@ class OnboardingLocation extends LitElement {
? location[1]
: Number(place.lon),
location_editable: place.place_id === highlightedMarker,
clickable: true,
}))
: [];
}
@@ -13,9 +13,11 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { isNumericState } from "../../../common/number/format_number";
import { stringCompare } from "../../../common/string/compare";
import type {
@@ -901,6 +903,12 @@ class DialogAddAutomationElement
const [targetType, targetId] = this._extractTypeAndIdFromTarget(
this._selectedTarget
);
const separator = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ ";
if (targetId) {
if (targetType === "area") {
@@ -913,11 +921,21 @@ class DialogAddAutomationElement
);
}
} else if (targetType === "device") {
const areaId = this.hass.devices[targetId]?.area_id;
if (areaId) {
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
const device = this.hass.devices[targetId];
const area = device
? getDeviceArea(device, this.hass.areas, this.hass.devices)
: undefined;
const parentDevice = device?.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
if (area) {
subtitle = [
computeAreaName(area) || area.area_id,
parentDevice ? computeDeviceName(parentDevice) : undefined,
]
.filter(Boolean)
.join(separator);
} else {
const device = this.hass.devices[targetId];
subtitle = this.hass.localize(
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
);
@@ -933,25 +951,28 @@ class DialogAddAutomationElement
);
} else {
const stateObj = this.hass.states[targetId];
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
subtitle = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(separator);
}
}
@@ -10,7 +10,6 @@ import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformTrigger } from "../../../../../data/automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
@@ -20,6 +19,7 @@ import {
getTriggerObjectId,
type TriggerDescription,
} from "../../../../../data/trigger";
import { migratePlatformTrigger } from "../../../../../data/trigger/migrate_platform_trigger";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
@@ -28,13 +28,6 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
!field.required &&
!("boolean" in field.selector && field.default);
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"options",
];
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -61,34 +54,21 @@ export class HaPlatformTrigger extends LitElement {
this.hass.loadBackendTranslation("triggers");
this.hass.loadBackendTranslation("selector");
}
if (
changedProperties.has("trigger") ||
changedProperties.has("description")
) {
const migrated = migratePlatformTrigger(this.trigger, this.description);
if (migrated) {
fireEvent(this, "value-changed", { value: migrated });
this.trigger = migrated;
}
}
if (!changedProperties.has("trigger")) {
return;
}
let newValue: PlatformTrigger | undefined;
for (const key in this.trigger) {
// Migrate old options to `options`
if (DEFAULT_KEYS.includes(key as keyof PlatformTrigger)) {
continue;
}
if (newValue === undefined) {
newValue = {
...this.trigger,
options: { [key]: this.trigger[key] },
};
} else {
newValue.options![key] = this.trigger[key];
}
delete newValue[key];
}
if (newValue !== undefined) {
fireEvent(this, "value-changed", {
value: newValue,
});
this.trigger = newValue;
}
const oldValue = changedProperties.get("trigger") as
undefined | this["trigger"];
@@ -31,6 +31,7 @@ const SEARCH_KEYS = [
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.parentDeviceName", weight: 6 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "id", weight: 2 },
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const friendlyName = computeStateName(stateObj); // Keep this for search
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
const deviceName = context?.device
? computeDeviceName(context.device)
: undefined;
const parentDeviceName = context?.parentDevice
? computeDeviceName(context.parentDevice)
: undefined;
const areaName = context?.area ? computeAreaName(context.area) : undefined;
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
return html`
<ha-dialog
@@ -86,6 +86,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
const entities = this._filterEntities(
this.params.exposedEntities,
this._registries,
this._filter
);
@@ -180,6 +181,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
private _filterEntities = memoizeOne(
(
exposedEntities: Record<string, ExposeEntitySettings>,
registries: ContextType<typeof registriesContext>,
filter?: string
): FilteredEntity[] => {
const lowerFilter = filter?.toLowerCase();
@@ -196,11 +198,16 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
const nameList = computeEntityNameList(
entity,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._registries.entities,
this._registries.devices,
this._registries.areas,
this._registries.floors
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
registries.entities,
registries.devices,
registries.areas,
registries.floors
);
if (!lowerFilter) {
@@ -219,13 +226,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
continue;
}
const [, deviceName, areaName] = nameList;
const [, deviceName, parentDeviceName, areaName] = nameList;
if (deviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (areaName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
@@ -238,14 +250,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
private _renderItem = (item: FilteredEntity) => {
const { entity: entityState, nameList } = item;
const [entityName, deviceName, areaName] = nameList;
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const primary = entityName || deviceName || entityState.entity_id;
const context = [areaName, entityName ? deviceName : undefined]
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const showEntityId = this._config?.userData?.showEntityIdPicker;
+1 -17
View File
@@ -61,8 +61,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@state() private _stateItems?: HassEntity[];
@state() private _activeEntry = "";
@state() private _canEditCore = false;
@query("ha-locations-editor") private _map?: HaLocationsEditor;
@@ -151,7 +149,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
?selected=${this._activeEntry === entry.id}
>
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
${entry.name}
@@ -185,7 +182,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
?selected=${this._activeEntry === stateObject.entity_id}
.noEdit=${
stateObject.entity_id !== "zone.home" ||
!this._canEditCore
@@ -273,7 +269,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
@marker-clicked=${this._markerClicked}
></ha-locations-editor>
<div class="overflow">${listBox}</div>
</div>
@@ -365,7 +360,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private async _locationUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
latitude: ev.detail.location[0],
@@ -384,7 +378,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private async _radiusUpdated(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
if (ev.detail.id === "zone.home" && this._canEditCore) {
await saveCoreConfig(this.hass, {
radius: Math.round(ev.detail.radius),
@@ -400,10 +393,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
});
}
private _markerClicked(ev: CustomEvent) {
this._activeEntry = ev.detail.id;
}
private _createZone() {
this._openDialog();
}
@@ -417,9 +406,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
this._openEditEntry(ev);
return;
}
const entryId: string = (ev.currentTarget! as any).value;
this._zoomZone(entryId);
this._activeEntry = entryId;
this._zoomZone((ev.currentTarget! as any).value);
}
private _stateItemClicked(ev: CustomEvent) {
@@ -435,7 +422,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
this._zoomZone(entryId);
this._activeEntry = entryId;
}
private async _zoomZone(id: string) {
@@ -478,7 +464,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow) {
return;
}
this._activeEntry = created.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(created.id);
@@ -505,7 +490,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
if (this.narrow || !fitMap) {
return;
}
this._activeEntry = entry.id;
await this.updateComplete;
await this._map?.updateComplete;
this._map?.fitMarker(entry.id);
+8 -1
View File
@@ -270,6 +270,7 @@ class HaPanelHistory extends LitElement {
.endTime=${this._endDate}
.narrow=${this.narrow}
sync-charts
inside-labels
>
</state-history-charts>
`
@@ -812,7 +813,13 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px;
padding: var(--ha-space-4);
}
/* Narrow screens: keep the vertical rhythm but give the charts the
horizontal space back. */
:host([narrow]) .results {
padding-inline: var(--ha-space-2);
}
.progress-wrapper {
+18 -12
View File
@@ -41,8 +41,8 @@ export const isRunRow = (item: LogbookEntry): boolean =>
classifyLogbookEntry(item) === "automation";
// How much naming detail an entity row shows, from least to most. The value is
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
// entity), `area` (area ▸ device ▸ entity).
// the broadest part shown: `none` (name hidden), `entity`, `device` (parent
// device ▸ device ▸ entity), `area` (area ▸ parent device ▸ device ▸ entity).
export type LogbookNameDetail = "none" | "entity" | "device" | "area";
export interface EntityDisplay {
@@ -60,14 +60,20 @@ export const entityDisplay = (
return {};
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || entityId;
@@ -82,11 +88,11 @@ export const entityDisplay = (
parts = [];
break;
case "device":
parts = [deviceQualifier];
parts = [parentDeviceName, deviceQualifier];
break;
case "area":
default:
parts = [areaName, deviceQualifier];
parts = [areaName, parentDeviceName, deviceQualifier];
}
const filtered = parts.filter(Boolean) as string[];
@@ -36,9 +36,14 @@ import { hasConfigChanged } from "../../common/has-changed";
import type { LovelaceCard } from "../../types";
import type { EnergyDistributionCardConfig } from "../types";
import { formatNumber } from "../../../../common/number/format_number";
import { round } from "../../../../common/number/round";
const CIRCLE_CIRCUMFERENCE = 238.76104;
// Flows are differences of sums; anything that rounds to 0 Wh is noise
const hasFlow = (value: number | null): value is number =>
round(value ?? 0, 3) > 0;
const periodIncludesNow = (data: EnergyData): boolean =>
!data.end || data.end.getTime() >= Date.now();
@@ -843,8 +848,8 @@ class HuiEnergyDistrubutionCard
? svg`<path
id="battery-grid"
class=${classMap({
"battery-from-grid": Boolean(batteryFromGrid),
"battery-to-grid": Boolean(batteryToGrid),
"battery-from-grid": hasFlow(batteryFromGrid),
"battery-to-grid": hasFlow(batteryToGrid),
})}
d="M45,100 v-15 c0,-35 -10,-30 -30,-30 h-20"
vector-effect="non-scaling-stroke"
@@ -875,14 +880,14 @@ class HuiEnergyDistrubutionCard
: nothing
}
${
solarToGrid && this._animate
hasFlow(solarToGrid) && this._animate
? svg`<circle
r="1"
class="return"
vector-effect="non-scaling-stroke"
>
<animateMotion
dur="${6 - (solarToGrid / totalLines) * 6}s"
dur="${6 - (solarToGrid / totalLines) * 5}s"
repeatCount="indefinite"
calcMode="linear"
>
@@ -892,7 +897,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
solarConsumption && this._animate
hasFlow(solarConsumption) && this._animate
? svg`<circle
r="1"
class="solar"
@@ -909,7 +914,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
gridConsumption && this._animate
hasFlow(gridConsumption) && this._animate
? svg`<circle
r="1"
class="grid"
@@ -926,7 +931,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
solarToBattery && this._animate
hasFlow(solarToBattery) && this._animate
? svg`<circle
r="1"
class="battery-solar"
@@ -943,7 +948,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryConsumption && this._animate
hasFlow(batteryConsumption) && this._animate
? svg`<circle
r="1"
class="battery-house"
@@ -960,7 +965,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryFromGrid && this._animate
hasFlow(batteryFromGrid) && this._animate
? svg`<circle
r="1"
class="battery-from-grid"
@@ -978,7 +983,7 @@ class HuiEnergyDistrubutionCard
: ""
}
${
batteryToGrid && this._animate
hasFlow(batteryToGrid) && this._animate
? svg`<circle
r="1"
class="battery-to-grid"
@@ -63,8 +63,8 @@ export class HuiEntityEditor extends LitElement {
this.hass.formatEntityName(
stateObj,
useDeviceName
? [{ type: "area" }]
: [{ type: "area" }, { type: "device" }],
? [{ type: "area" }, { type: "parent_device" }]
: [{ type: "area" }, { type: "parent_device" }, { type: "device" }],
{
separator: isRTL ? " ◂ " : " ▸ ",
}
@@ -12,6 +12,7 @@ import { computeDeviceName } from "../../../../common/entity/compute_device_name
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { stringCompare } from "../../../../common/string/compare";
import { entityComboBoxKeys } from "../../../../data/entity/entity_picker";
import { domainToName } from "../../../../data/integration";
@@ -53,12 +54,14 @@ export interface SearchableEntity {
id: string;
name: string;
area: string;
parentDevice: string;
device: string;
domain: string;
search_labels: {
entityName: string | null;
friendlyName: string | null;
deviceName: string | null;
parentDeviceName: string | null;
areaName: string | null;
domainName: string | null;
entityId: string;
@@ -136,14 +139,22 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
const entry = entityReg[entityId];
if (entry?.hidden) continue;
const device = entry?.device_id ? deviceReg[entry.device_id] : undefined;
const areaId = entry?.area_id ?? device?.area_id;
const area = areaId ? areaReg[areaId] : undefined;
const { device, parentDevice, area } = getEntityContext(
stateObj,
entityReg,
deviceReg,
areaReg,
floorReg
);
const areaId = area?.area_id;
const domain = computeDomain(entityId);
const entityName = computeEntityName(stateObj, entityReg, deviceReg);
const friendlyName = computeStateName(stateObj);
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const domainName = domainToName(localize, domain);
@@ -151,12 +162,14 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
id: entityId,
name: entityName || friendlyName || entityId,
area: areaName ?? "",
parentDevice: parentDeviceName ?? "",
device: deviceName ?? "",
domain: domainName,
search_labels: {
entityName: entityName || null,
friendlyName: friendlyName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
entityId,
@@ -32,6 +32,7 @@ interface EntityPickerTableRowData extends DataTableRowData {
name: string;
entity_name?: string;
device_name?: string;
parent_device_name?: string;
area_name?: string;
domain_name: string;
last_changed: string;
@@ -53,21 +54,31 @@ export class HuiEntityPickerTable extends LitElement {
private _data = memoizeOne(
(
states: HomeAssistant["states"],
entityRegistry: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
localize: LocalizeFunc,
entities?: string[]
): EntityPickerTableRowData[] =>
(entities || Object.keys(states)).map<EntityPickerTableRowData>(
(entity) => {
const stateObj = this.hass.states[entity];
const stateObj = states[entity];
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entityRegistry,
devices,
areas,
floors
);
const name = [deviceName, entityName].filter(Boolean).join(" ");
const domain = computeDomain(entity);
@@ -78,6 +89,7 @@ export class HuiEntityPickerTable extends LitElement {
name: name,
entity_name: entityName,
device_name: deviceName,
parent_device_name: parentDeviceName,
area_name: areaName,
domain_name: domainToName(localize, domain),
last_changed: stateObj!.last_changed,
@@ -89,6 +101,10 @@ export class HuiEntityPickerTable extends LitElement {
protected render(): TemplateResult {
const data = this._data(
this.hass.states,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors,
this.hass.localize,
this.entities
);
@@ -152,6 +168,7 @@ export class HuiEntityPickerTable extends LitElement {
entity.entity_name || entity.device_name || entity.entity_id;
const secondary = [
entity.area_name,
entity.parent_device_name,
entity.entity_name ? entity.device_name : undefined,
]
.filter(Boolean)
@@ -191,6 +208,12 @@ export class HuiEntityPickerTable extends LitElement {
hidden: true,
};
columns.parent_device_name = {
title: "parent_device_name",
filterable: true,
hidden: true,
};
columns.area_name = {
title: "area_name",
filterable: true,
@@ -287,7 +287,9 @@ export class HuiSuggestionEntityTree extends LitElement {
this.hass.translationMetadata.translations
);
const separator = rtl ? " ◂ " : " ▸ ";
const secondary = [item.area, item.device].filter(Boolean).join(separator);
const secondary = [item.area, item.parentDevice, item.device]
.filter(Boolean)
.join(separator);
return html`
<ha-combo-box-item
type="button"
@@ -172,14 +172,20 @@ export class HuiHeadingBadgesEditor extends LitElement {
`;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
@@ -187,7 +193,11 @@ export class HuiHeadingBadgesEditor extends LitElement {
);
const primary = entityName || deviceName || entityId;
const secondary = [entityName ? deviceName : undefined, areaName]
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
@@ -1,4 +1,5 @@
import { array, literal, object, string, union } from "superstruct";
import { array, enums, literal, object, string, union } from "superstruct";
import { ENTITY_NAME_TYPES } from "../../../../common/entity/compute_entity_name_display";
const entityNameItemStruct = union([
object({
@@ -6,12 +7,7 @@ const entityNameItemStruct = union([
text: string(),
}),
object({
type: union([
literal("entity"),
literal("device"),
literal("area"),
literal("floor"),
]),
type: enums(ENTITY_NAME_TYPES),
}),
string(),
]);
+13 -7
View File
@@ -5,6 +5,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { join } from "lit/directives/join";
import { ensureArray } from "../common/array/ensure-array";
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
STRINGS_SEPARATOR_DOT,
@@ -56,6 +57,13 @@ export const DEFAULT_STATE_CONTENT_DOMAINS: Record<string, StateContent> = {
valve: ["state", "current_position"],
};
const NAME_CONTENT_TYPES = new Map<string, EntityNameType>([
["device_name", "device"],
["parent_device_name", "parent_device"],
["area_name", "area"],
["floor_name", "floor"],
]);
const TIMESTAMP_STATE_PROPS = ["last_updated", "last_changed"];
const TIMESTAMP_CONTENTS = [...TIMESTAMP_STATE_PROPS, "last_triggered"];
@@ -190,13 +198,11 @@ class StateDisplay extends LitElement {
if (content === "entity-id") {
return stateObj.entity_id;
}
if (
content === "device_name" ||
content === "area_name" ||
content === "floor_name"
) {
const type = content.replace("_name", "") as "device" | "area" | "floor";
return this.hass.formatEntityName(stateObj, { type }) || undefined;
const nameType = NAME_CONTENT_TYPES.get(content);
if (nameType) {
return (
this.hass.formatEntityName(stateObj, { type: nameType }) || undefined
);
}
let relativeDateTime: string | number | undefined;
+19 -1
View File
@@ -660,6 +660,16 @@
"outside": "Outside range"
}
},
"offset": {
"none": "No offset",
"before": "Before",
"after": "After",
"duration": "Duration",
"summary": {
"before": "{duration} before",
"after": "{duration} after"
}
},
"automation_behavior": {
"trigger": {
"options": {
@@ -771,6 +781,7 @@
"types": {
"floor": "Floor",
"area": "Area",
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
"device": "Device",
"entity": "Entity",
"area_missing": "No area assigned",
@@ -929,6 +940,7 @@
"no_devices": "No devices available",
"no_match": "No devices found for {term}",
"device": "Device",
"parent_device": "Parent device",
"unnamed_device": "Unnamed device",
"no_area": "No area",
"placeholder": "Select a device",
@@ -1154,7 +1166,11 @@
"category": "Category"
},
"map": {
"error": "Unable to load map"
"error": "Unable to load map",
"editing_unavailable": "Dragging markers and resizing zones aren't available on this map.",
"location": "Location",
"radius": "Radius in meters",
"radius_of": "Radius of {name} in meters"
},
"statistics_charts": {
"loading_statistics": "Loading statistics…",
@@ -1473,6 +1489,7 @@
"remaining_time": "Remaining time",
"install_status": "Install status",
"device_name": "Device name",
"parent_device_name": "Parent device name",
"area_name": "Area name",
"floor_name": "Floor name"
},
@@ -1702,6 +1719,7 @@
"state": "State",
"context": "Context",
"floor": "Floor",
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
"entity_id": "ID",
"copy_value": "Copy {label}: {value}",
"labels": "Labels",
@@ -245,6 +245,43 @@ describe("computeEntityNameDisplay", () => {
expect(result).toBe("Kitchen Smart Light");
});
it("returns parent device name for an entity on a child device", () => {
const stateObj = mockStateObj({ entity_id: "switch.outlet_1" });
const hass = {
entities: {
"switch.outlet_1": mockEntity({
entity_id: "switch.outlet_1",
name: "Switch",
device_id: "child_1",
}),
},
devices: {
child_1: mockDevice({
id: "child_1",
name: "Outlet 1",
parent_device_id: "parent_1",
}),
parent_1: mockDevice({
id: "parent_1",
name: "Power strip",
}),
},
areas: {},
floors: {},
} as unknown as HomeAssistant;
const result = computeEntityNameDisplay(
stateObj,
[{ type: "parent_device" }, { type: "device" }, { type: "entity" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
expect(result).toBe("Power strip Outlet 1 Switch");
});
it("returns floor name", () => {
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
const hass = {
@@ -37,6 +37,7 @@ describe("getEntityContext", () => {
expect(result).toEqual({
entity,
device: null,
parentDevice: null,
area: null,
floor: null,
});
@@ -88,6 +89,7 @@ describe("getEntityContext", () => {
expect(result).toEqual({
entity,
device,
parentDevice: null,
area,
floor,
});
@@ -144,6 +146,7 @@ describe("getEntityContext", () => {
expect(result).toEqual({
entity,
device: childDevice,
parentDevice,
area,
floor,
});
@@ -184,6 +187,7 @@ describe("getEntityContext", () => {
expect(result).toEqual({
entity,
device: null,
parentDevice: null,
area,
floor,
});
@@ -223,8 +227,38 @@ describe("getEntityContext", () => {
expect(result).toEqual({
entity,
device: null,
parentDevice: null,
area,
floor: null,
});
});
it("should not resolve a parent device for an entity on a main device", () => {
const entity = mockEntity({
entity_id: "switch.strip",
device_id: "parent_1",
});
const parentDevice = mockDevice({
id: "parent_1",
});
const stateObj = mockStateObj({
entity_id: "switch.strip",
});
const result = getEntityContext(
stateObj,
{ "switch.strip": entity },
{ parent_1: parentDevice },
{},
{}
);
expect(result).toEqual({
entity,
device: parentDevice,
parentDevice: null,
area: null,
floor: null,
});
});
});
+396 -3
View File
@@ -1,6 +1,11 @@
import type { LayerSpecification, StyleSpecification } from "maplibre-gl";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CONTEXT_RESTORE_GRACE } from "../../../src/common/map/base-layer";
import {
circleBoundsPoints,
distanceMeters,
pointEastOf,
} from "../../../src/common/map/map-engine";
import { MapLibreMapEngine } from "../../../src/common/map/engines/maplibre-map-engine";
import type {
MapEngineEvents,
@@ -34,10 +39,16 @@ const fakes = vi.hoisted(() => {
onMap = false;
handlers: Record<string, Listener[]> = {};
constructor(options: any) {
this.options = options;
}
fire(type: string) {
(this.handlers[type] ?? []).forEach((handler) => handler());
}
setLngLat(lngLat: [number, number]) {
this.lngLat = lngLat;
return this;
@@ -50,7 +61,13 @@ const fakes = vi.hoisted(() => {
addTo() {
this.onMap = true;
FakeMarker.all.push(this);
document.body.appendChild(this.options.element);
const element = this.options.element as HTMLElement;
element.classList.add(
"maplibregl-marker",
"maplibregl-marker-anchor-center"
);
element.style.transform = "translate(10px, 10px)";
document.body.appendChild(element);
return this;
}
@@ -68,7 +85,8 @@ const fakes = vi.hoisted(() => {
return this.options.element as HTMLElement;
}
on() {
on(type: string, handler: Listener) {
(this.handlers[type] ??= []).push(handler);
return this;
}
}
@@ -226,8 +244,17 @@ const fakes = vi.hoisted(() => {
this.style.sources[id] = source;
}
// A live source updates the spec it was created from, as MapLibre's does
getSource(id: string) {
return this.style.sources[id];
const spec = this.style.sources[id] as { data?: unknown } | undefined;
return spec
? {
...spec,
setData: (data: unknown) => {
spec.data = data;
},
}
: undefined;
}
removeSource(id: string) {
@@ -368,6 +395,7 @@ describe("MapLibreMapEngine", () => {
afterEach(() => {
document.body.innerHTML = "";
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("style lifecycle", () => {
@@ -577,6 +605,99 @@ describe("MapLibreMapEngine", () => {
});
describe("markers", () => {
it("hands a removed element back without MapLibre's positioning", async () => {
const { engine, ready } = await createEngine();
await ready;
const element = document.createElement("div");
const handle = engine.addMarker(element, [52, 4], { size: [36, 36] });
expect(element.classList.contains("maplibregl-marker")).toBe(true);
handle.remove();
expect(element.className).toBe("");
expect(element.style.transform).toBe("");
});
it("resets every element it placed when destroyed", async () => {
const { engine, ready } = await createEngine();
await ready;
const element = document.createElement("div");
engine.addMarker(element, [52, 4], { size: [36, 36] });
const center = document.createElement("div");
engine.editing.addEditableCircle([52, 4], {
radius: 100,
color: "red",
centerElement: center,
});
engine.destroy();
expect(element.className).toBe("");
expect(center.className).toBe("");
expect(center.style.transform).toBe("");
});
it("shows a move cursor only on elements it made draggable", async () => {
const { engine, ready } = await createEngine();
await ready;
const plain = document.createElement("div");
engine.addMarker(plain, [52, 4], { size: [36, 36] });
const draggable = document.createElement("div");
const handle = engine.editing.addDraggableMarker(draggable, [52, 4], {
size: [36, 36],
});
const center = document.createElement("div");
engine.editing.addEditableCircle([52, 4], {
radius: 100,
color: "red",
centerElement: center,
moveable: true,
});
expect(plain.style.cursor).toBe("");
expect(draggable.style.cursor).toBe("move");
expect(center.style.cursor).toBe("move");
handle.remove();
expect(draggable.style.cursor).toBe("");
});
it("relabels a reused element and leaves the caller's own attributes alone", async () => {
const { engine, ready } = await createEngine();
await ready;
const element = document.createElement("div");
engine
.addMarker(element, [52, 4], { size: [36, 36], title: "Thuis" })
.remove();
expect(element.hasAttribute("aria-label")).toBe(false);
expect(element.tabIndex).toBe(-1);
engine.addMarker(element, [52, 4], {
size: [36, 36],
title: "Home",
focusable: false,
});
expect(element.getAttribute("aria-label")).toBe("Home");
expect(element.getAttribute("role")).toBe("img");
const named = document.createElement("div");
named.setAttribute("aria-label", "Mine");
engine.addMarker(named, [52, 4], { size: [36, 36], title: "Engine" });
expect(named.getAttribute("aria-label")).toBe("Mine");
});
it("gives button semantics only to focusable markers", async () => {
const { engine, ready } = await createEngine();
await ready;
const element = document.createElement("div");
engine.addMarker(element, [52, 4], {
size: [36, 36],
title: "Pin",
focusable: false,
});
expect(element.tabIndex).toBe(-1);
expect(element.getAttribute("role")).toBe("img");
expect(element.style.pointerEvents).toBe("");
});
it("lets input through non-interactive markers", async () => {
const { engine, ready } = await createEngine();
await ready;
@@ -786,4 +907,276 @@ describe("MapLibreMapEngine", () => {
expect(map.fitBounds).toHaveBeenCalledTimes(2);
});
});
describe("editing", () => {
const circleSource = (map: InstanceType<typeof fakeMap>) =>
Object.entries(map.style.sources).find(([id]) =>
id.startsWith("ha-map-editable")
)?.[1] as { data: unknown } | undefined;
const addCircle = (
engine: MapLibreMapEngine,
overrides: Partial<
Parameters<typeof engine.editing.addEditableCircle>[1]
> = {}
) => {
const callbacks = {
onMove: vi.fn(),
onResize: vi.fn(),
onClick: vi.fn(),
};
const handle = engine.editing.addEditableCircle([52, 4], {
radius: 100,
color: "red",
moveable: true,
resizable: true,
title: "Home",
...callbacks,
...overrides,
});
const [center, resize] = fakeMarker.all;
return { handle, center, resize, ...callbacks };
};
beforeEach(() => {
// The circle redraws once per frame; run frames synchronously
// Returning a frame id here would land in the engine's pending frame
// after the callback already cleared it, suppressing later redraws
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
callback();
return undefined;
});
vi.stubGlobal("cancelAnimationFrame", () => undefined);
});
it("draws a circle with a draggable center and a resize handle", async () => {
const { engine, map, ready } = await createEngine();
await ready;
const { center, resize } = addCircle(engine);
expect(fakeMarker.all).toHaveLength(2);
expect(center.options.draggable).toBe(true);
expect(center.options.element.getAttribute("role")).toBe("button");
expect(resize.options.draggable).toBe(true);
expect(resize.options.element.getAttribute("role")).toBe("slider");
expect(resize.options.element.getAttribute("aria-valuenow")).toBe("100");
expect(circleSource(map)).toBeDefined();
expect(
layerIds(map).filter((id) => id.startsWith("ha-map-editable"))
).toHaveLength(2);
});
it("moves with its center and reports the drop", async () => {
const { engine, map, ready } = await createEngine();
await ready;
const { handle, center, resize, onMove } = addCircle(engine);
const before = circleSource(map)!.data;
center.lngLat = [4.01, 52];
center.fire("dragstart");
center.fire("drag");
expect(onMove).not.toHaveBeenCalled();
// The circle and the handle follow while dragging
expect(circleSource(map)!.data).not.toBe(before);
expect(resize.lngLat![0]).toBeCloseTo(pointEastOf([52, 4.01], 100)[1], 6);
center.fire("dragend");
expect(onMove).toHaveBeenCalledWith([52, 4.01]);
expect(handle.center).toEqual([52, 4.01]);
});
it("resizes from the handle and snaps it back onto the edge", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, resize, onResize } = addCircle(engine);
const east = pointEastOf([52, 4], 250);
resize.lngLat = [east[1], east[0]];
resize.fire("dragstart");
resize.fire("drag");
resize.fire("dragend");
expect(onResize).toHaveBeenCalledOnce();
expect(onResize.mock.calls[0][0]).toBeCloseTo(250, 0);
expect(handle.radius).toBeCloseTo(250, 0);
expect(resize.options.element.getAttribute("aria-valuenow")).toBe("250");
});
it("resizes in steps with the arrow keys, committing on key release", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, resize, onResize } = addCircle(engine);
const element = resize.options.element as HTMLElement;
element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" }));
element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" }));
expect(handle.radius).toBeCloseTo(121, 5);
expect(onResize).not.toHaveBeenCalled();
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
expect(onResize).toHaveBeenCalledOnce();
expect(onResize.mock.calls[0][0]).toBeCloseTo(121, 5);
});
it("holds a keyboard resize against host updates like a drag", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, resize } = addCircle(engine);
const element = resize.options.element as HTMLElement;
element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" }));
// A re-render with the old radius while the key is held changes nothing
handle.update([52, 4], 100);
expect(handle.radius).toBeCloseTo(110, 5);
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
// The host echoes the old radius once before its save returns
handle.update([52, 4], 100);
expect(handle.radius).toBeCloseTo(110, 5);
// A later identical update is a real change
handle.update([52, 4], 100);
expect(handle.radius).toBe(100);
});
it("keeps arrow keys on the resize handle from reaching the map", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, resize } = addCircle(engine);
const reachedMap = vi.fn();
document.body.addEventListener("keydown", reachedMap);
(resize.options.element as HTMLElement).dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })
);
expect(handle.radius).toBeCloseTo(110, 5);
expect(reachedMap).not.toHaveBeenCalled();
document.body.removeEventListener("keydown", reachedMap);
});
it("places the handle on the drawn circle at any latitude", () => {
for (const [latitude, radius] of [
[0, 1000],
[52, 1000],
[89.9, 1000],
[89.9, 100000],
]) {
const center: [number, number] = [latitude, 4];
const east = pointEastOf(center, radius);
expect(distanceMeters(center, east)).toBeCloseTo(radius, 0);
const [southWest, northEast] = circleBoundsPoints(center, radius);
// Valid bounds that contain the handle
expect(southWest[0]).toBeGreaterThanOrEqual(-90);
expect(northEast[0]).toBeLessThanOrEqual(90);
expect(east[1]).toBeGreaterThanOrEqual(southWest[1] - 1e-9);
expect(east[1]).toBeLessThanOrEqual(northEast[1] + 1e-9);
}
// A circle over the pole covers every longitude
expect(circleBoundsPoints([89.9, 4], 100000)[1]).toEqual([90, 180]);
});
it("keeps a click on the resize handle from reaching the map", async () => {
const { engine, ready } = await createEngine();
await ready;
const { resize } = addCircle(engine);
const reachedMap = vi.fn();
document.body.addEventListener("click", reachedMap);
(resize.options.element as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(reachedMap).not.toHaveBeenCalled();
document.body.removeEventListener("click", reachedMap);
});
it("raises the advertised maximum above a larger radius", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, resize } = addCircle(engine);
const element = resize.options.element as HTMLElement;
expect(element.getAttribute("aria-valuemax")).toBe("100000");
handle.update([52, 4], 150000);
expect(element.getAttribute("aria-valuemax")).toBe("150000");
expect(element.getAttribute("aria-valuenow")).toBe("150000");
});
it("ignores one update echoing the values from before a drag", async () => {
const { engine, ready } = await createEngine();
await ready;
const { handle, center } = addCircle(engine);
center.lngLat = [4.01, 52];
center.fire("dragstart");
// Nothing moves while a drag is in progress
handle.update([53, 5], 500);
expect(handle.center).toEqual([52, 4]);
center.fire("drag");
center.fire("dragend");
// The host saves and echoes the old values once; that is not a move back
handle.update([52, 4], 100);
expect(handle.center).toEqual([52, 4.01]);
// A later identical update is a real change
handle.update([52, 4], 100);
expect(handle.center).toEqual([52, 4]);
});
it("activates the center by click, Enter and Space, but not after a drag", async () => {
const { engine, ready } = await createEngine();
await ready;
const { center, onClick } = addCircle(engine);
const element = center.options.element as HTMLElement;
element.dispatchEvent(new MouseEvent("pointerdown"));
element.click();
element.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
element.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
expect(onClick).toHaveBeenCalledTimes(3);
// A drag ends with a click on the element; that one is not an activation
element.dispatchEvent(new MouseEvent("pointerdown"));
center.fire("dragstart");
center.fire("dragend");
element.click();
expect(onClick).toHaveBeenCalledTimes(3);
});
it("keeps a circle moved while a swapped style was loading", async () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.setDarkMode(true);
await flush();
expect(map.isStyleLoaded()).toBe(false);
const handle = engine.editing.addEditableCircle([52, 4], {
radius: 100,
color: "red",
});
handle.update([53, 5], 200);
map.loadStyle();
const source = circleSource(map)!;
const ring = (source.data as { geometry: { coordinates: number[][][] } })
.geometry.coordinates[0];
// The polygon sits around the moved center, not the original one
expect(ring[0][1]).toBeCloseTo(53, 1);
expect(ring[0][0]).toBeCloseTo(5, 1);
});
it("removes its markers, layers, and listeners", async () => {
const { engine, map, ready } = await createEngine();
await ready;
const { handle, center, onClick } = addCircle(engine);
const element = center.options.element as HTMLElement;
handle.remove();
expect(fakeMarker.all).toHaveLength(0);
expect(circleSource(map)).toBeUndefined();
expect(
layerIds(map).filter((id) => id.startsWith("ha-map-editable"))
).toHaveLength(0);
element.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
expect(onClick).not.toHaveBeenCalled();
});
});
});
+223 -1
View File
@@ -1,12 +1,19 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
MapEditableCircleHandle,
MapEditableCircleOptions,
MapEditableMarkerHandle,
MapEditingSupport,
MapEngine,
MapEngineOptions,
MapMarkerHandle,
} from "../../../src/common/map/map-engine";
import "../../../src/components/map/ha-map";
import type { HaMap } from "../../../src/components/map/ha-map";
import type {
HaMap,
HaMapEditableLocation,
} from "../../../src/components/map/ha-map";
// ha-map picks its engine at runtime: MapLibre GL where WebGL2 is available,
// Leaflet otherwise or after MapLibre fails. jsdom has no WebGL2 and cannot
@@ -89,6 +96,47 @@ const fakeEngine = vi.hoisted(() => {
setClustering = vi.fn();
refreshClusters = vi.fn();
/** Editable circles and markers, with the callbacks ha-map passed */
circles: (MapEditableCircleHandle & {
options: MapEditableCircleOptions;
})[] = [];
draggables: (MapEditableMarkerHandle & {
element: HTMLElement;
options: { onDragEnd?: (location: [number, number]) => void };
})[] = [];
editing: MapEditingSupport = {
addDraggableMarker: vi.fn((element, location, options) => {
const handle = {
location,
element,
options,
clusterData: options.clusterData,
setLocation: vi.fn((next: [number, number]) => {
handle.location = next;
}),
remove: vi.fn(),
};
this.draggables.push(handle);
return handle;
}),
addEditableCircle: vi.fn((center, options) => {
const handle = {
center,
radius: options.radius,
options,
update: vi.fn((nextCenter: [number, number], nextRadius: number) => {
handle.center = nextCenter;
handle.radius = nextRadius;
}),
remove: vi.fn(),
};
this.circles.push(handle);
return handle;
}),
};
}
return FakeMapLibreEngine;
});
@@ -275,3 +323,177 @@ describe("ha-map engine selection", () => {
expect(entityHandles(el)).not.toBe(handlesBefore);
});
});
describe("ha-map editable locations", () => {
const HOME: HaMapEditableLocation = {
id: "home",
location: [52, 4],
radius: 100,
title: "Home",
locationEditable: true,
radiusEditable: true,
activatable: true,
};
const PIN: HaMapEditableLocation = {
id: "pin",
location: [52, 5],
locationEditable: true,
activatable: true,
};
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
webgl2.supported = true;
fakeEngine.failInit = false;
fakeEngine.initGate = undefined;
fakeEngine.instances.length = 0;
});
afterEach(() => {
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
const createEditor = async (locations: HaMapEditableLocation[]) => {
const el = document.createElement("ha-map");
el.editableLocations = locations;
(el as any)._config = {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
const availability: boolean[] = [];
el.addEventListener("editing-available-changed", (ev) => {
availability.push((ev as CustomEvent).detail.available);
});
document.body.appendChild(el);
await vi.waitUntil(() => isLoaded(el));
await el.updateComplete;
return { el, engine: fakeEngine.instances[0], availability };
};
it("draws circles and markers through the engine's editing support", async () => {
const { engine, availability } = await createEditor([HOME, PIN]);
expect(availability).toEqual([true]);
expect(engine.circles).toHaveLength(1);
expect(engine.circles[0].center).toEqual([52, 4]);
expect(engine.circles[0].radius).toBe(100);
expect(engine.circles[0].options.title).toBe("Home");
expect(engine.circles[0].options.moveable).toBe(true);
expect(engine.circles[0].options.resizable).toBe(true);
expect(engine.draggables).toHaveLength(1);
expect(engine.draggables[0].location).toEqual([52, 5]);
});
it("moves existing handles instead of recreating them", async () => {
const { el, engine } = await createEditor([HOME, PIN]);
el.editableLocations = [
{ ...HOME, location: [52.1, 4.1], radius: 250 },
{ ...PIN, location: [52.2, 5.2] },
];
await el.updateComplete;
expect(engine.editing.addEditableCircle).toHaveBeenCalledOnce();
expect(engine.circles[0].update).toHaveBeenCalledWith([52.1, 4.1], 250);
expect(engine.editing.addDraggableMarker).toHaveBeenCalledOnce();
expect(engine.draggables[0].setLocation).toHaveBeenCalledWith([52.2, 5.2]);
});
it("rebuilds a handle whose appearance changed and removes dropped ones", async () => {
const { el, engine } = await createEditor([HOME, PIN]);
el.editableLocations = [{ ...HOME, title: "Work" }];
await el.updateComplete;
expect(engine.circles[0].remove).toHaveBeenCalledOnce();
expect(engine.circles).toHaveLength(2);
expect(engine.circles[1].options.title).toBe("Work");
expect(engine.draggables[0].remove).toHaveBeenCalledOnce();
});
it("forwards moves, resizes, and activation with the location id", async () => {
const { el, engine } = await createEditor([HOME, PIN]);
const events: { type: string; detail: unknown }[] = [];
for (const type of [
"editable-location-moved",
"editable-location-resized",
"editable-location-clicked",
]) {
el.addEventListener(type, (ev) => {
events.push({ type, detail: (ev as CustomEvent).detail });
});
}
engine.circles[0].options.onMove!([52.1, 4.1]);
engine.circles[0].options.onResize!(250);
engine.circles[0].options.onClick!();
engine.draggables[0].options.onDragEnd!([52.2, 5.2]);
engine.draggables[0].element.dispatchEvent(new MouseEvent("pointerdown"));
engine.draggables[0].element.click();
engine.draggables[0].element.dispatchEvent(
new KeyboardEvent("keydown", { key: " " })
);
expect(events).toEqual([
{
type: "editable-location-moved",
detail: { id: "home", location: [52.1, 4.1] },
},
{
type: "editable-location-resized",
detail: { id: "home", radius: 250 },
},
{ type: "editable-location-clicked", detail: { id: "home" } },
{
type: "editable-location-moved",
detail: { id: "pin", location: [52.2, 5.2] },
},
{ type: "editable-location-clicked", detail: { id: "pin" } },
{ type: "editable-location-clicked", detail: { id: "pin" } },
]);
});
it("makes markers buttons only when they act on activation", async () => {
const { el, engine } = await createEditor([
{ ...HOME, activatable: false },
{ ...PIN, activatable: false },
]);
const clicked = vi.fn();
el.addEventListener("editable-location-clicked", clicked);
expect(engine.circles[0].options.onClick).toBeUndefined();
expect((engine.draggables[0].options as any).focusable).toBe(false);
engine.draggables[0].element.click();
engine.draggables[0].element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter" })
);
expect(clicked).not.toHaveBeenCalled();
});
it("relabels its handles when the language changes", async () => {
const { el, engine } = await createEditor([PIN]);
expect((engine.draggables[0].options as any).title).toBeUndefined();
(el as any)._i18n = { localize: (key: string) => `nl:${key}` };
await el.updateComplete;
expect(engine.draggables[0].remove).toHaveBeenCalledOnce();
expect((engine.draggables[1].options as any).title).toBe(
"nl:ui.components.map.location"
);
});
it("shows locations statically on an engine without editing support", async () => {
webgl2.supported = false;
const { el, availability } = await createEditor([HOME, PIN]);
expect(availability).toEqual([false]);
// Drawn with the plain viewing primitives: a circle plus two markers
expect(leafletMap(el)).toBeDefined();
let layers = 0;
leafletMap(el).eachLayer(() => {
layers++;
});
expect(layers).toBeGreaterThanOrEqual(3);
});
});
+7 -3
View File
@@ -49,6 +49,10 @@ const STATES = {
},
} as unknown as HassEntities;
// jsdom has no WebGL2, so ha-map runs its Leaflet fallback engine here; the
// Leaflet map underneath is what the fit assertions read.
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
const createMap = async (): Promise<HaMap> => {
const el = document.createElement("ha-map");
el.entities = [
@@ -61,7 +65,7 @@ const createMap = async (): Promise<HaMap> => {
config: { latitude: 52.3731339, longitude: 4.8903147 },
};
document.body.appendChild(el);
await vi.waitUntil(() => el.leafletMap !== undefined && (el as any)._loaded);
await vi.waitUntil(() => leafletMap(el) !== undefined && (el as any)._loaded);
await el.updateComplete;
return el;
};
@@ -104,7 +108,7 @@ describe("ha-map", () => {
fireResizeObservers();
await el.updateComplete;
const map = el.leafletMap!;
const map = leafletMap(el)!;
// The map should be fitted to the markers, not zoomed out to the world.
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
@@ -131,7 +135,7 @@ describe("ha-map", () => {
try {
const el = await createMap();
const map = el.leafletMap!;
const map = leafletMap(el)!;
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
expect(map.getCenter().lng).toBeCloseTo(4.8925, 2);
+152 -2
View File
@@ -1,6 +1,12 @@
import { describe, it, assert, vi } from "vitest";
import { HistoryStream } from "../../src/data/history";
import { describe, it, assert, expect, vi } from "vitest";
import { HistoryStream, computeHistory } from "../../src/data/history";
import type { HomeAssistant } from "../../src/types";
import { mockDevice, mockEntity } from "../common/entity/context/context-mock";
import {
createMockEntityState,
createMockHass,
mockLocalize,
} from "../fixtures/hass";
const mockHass = {} as HomeAssistant;
@@ -108,3 +114,147 @@ describe("HistoryStream.processMessage", () => {
assert.equal(firstState.lu, purgeBeforePythonTime + 100);
});
});
const namedHistoryHass = (options: {
entityId: string;
entityName?: string;
deviceName: string;
attributes?: Record<string, unknown>;
rtl?: boolean;
}): HomeAssistant => {
const hass = createMockHass(
{
[options.entityId]: createMockEntityState(
options.entityId,
"on",
options.attributes ?? { friendly_name: "Caméra Allée Battery state" }
),
},
{
entities: {
[options.entityId]: mockEntity({
entity_id: options.entityId,
name: options.entityName,
device_id: "device_1",
}),
},
devices: {
device_1: mockDevice({
id: "device_1",
name: options.deviceName,
}),
},
}
);
if (options.rtl) {
hass.language = "he";
hass.translationMetadata = {
fragments: [],
translations: {
he: { nativeName: "Hebrew", isRTL: true, hash: "" },
},
};
}
return hass;
};
const binaryHistory = (entityId: string, friendlyName?: string) => ({
[entityId]: [
{
s: "on",
a: friendlyName ? { friendly_name: friendlyName } : {},
lu: 1_700_000_000,
},
],
});
const numericHistory = (entityId: string, friendlyName?: string) => ({
[entityId]: [
{
s: "12",
a: {
unit_of_measurement: "W",
...(friendlyName ? { friendly_name: friendlyName } : {}),
},
lu: 1_700_000_000,
},
],
});
describe("computeHistory entity names", () => {
it("labels a timeline entity as Device ▸ Entity when the entity has its own name", () => {
const entityId = "binary_sensor.allee_battery";
const result = computeHistory(
namedHistoryHass({
entityId,
entityName: "Battery state",
deviceName: "Caméra Allée",
}),
binaryHistory(entityId),
[],
mockLocalize
);
expect(result.timeline[0]?.name).toBe("Caméra Allée ▸ Battery state");
});
it("labels a line entity as Device ▸ Entity when the entity has its own name", () => {
const entityId = "sensor.allee_power";
const result = computeHistory(
namedHistoryHass({
entityId,
entityName: "Power",
deviceName: "Caméra Allée",
attributes: {
unit_of_measurement: "W",
friendly_name: "Caméra Allée Power",
},
}),
numericHistory(entityId),
[],
mockLocalize
);
expect(result.line[0]?.data[0]?.name).toBe("Caméra Allée ▸ Power");
});
it("uses just the device name when the entity has no name of its own", () => {
const entityId = "binary_sensor.desk";
const result = computeHistory(
namedHistoryHass({
entityId,
deviceName: "Desk",
attributes: { friendly_name: "Desk" },
}),
binaryHistory(entityId),
[],
mockLocalize
);
expect(result.timeline[0]?.name).toBe("Desk");
});
it("falls back to friendly_name when the entity has no current state", () => {
const entityId = "binary_sensor.removed";
const result = computeHistory(
createMockHass(),
binaryHistory(entityId, "Gone sensor"),
[],
mockLocalize
);
expect(result.timeline[0]?.name).toBe("Gone sensor");
});
it("uses the RTL separator when the language is RTL", () => {
const entityId = "binary_sensor.allee_battery";
const result = computeHistory(
namedHistoryHass({
entityId,
entityName: "Battery state",
deviceName: "Caméra Allée",
rtl: true,
}),
binaryHistory(entityId),
[],
mockLocalize
);
expect(result.timeline[0]?.name).toBe("Caméra Allée ◂ Battery state");
});
});
@@ -159,6 +159,57 @@ describe("entityDisplay", () => {
});
});
describe("with a parent device", () => {
const h = baseHass({
states: {
"sensor.outlet_1_power": mockStateObj({
entity_id: "sensor.outlet_1_power",
}),
},
entities: {
"sensor.outlet_1_power": mockEntity({
entity_id: "sensor.outlet_1_power",
name: "Power",
device_id: "outlet_1",
}),
},
devices: {
outlet_1: mockDevice({
id: "outlet_1",
name: "Outlet 1",
parent_device_id: "strip",
}),
strip: mockDevice({
id: "strip",
name: "Power strip",
area_id: "area_1",
}),
},
areas: { area_1: mockArea({ area_id: "area_1", name: "Garage" }) },
});
it("shows 'Area ▸ Parent ▸ Device' by default", () => {
expect(entityDisplay(h, "sensor.outlet_1_power")).toEqual({
primary: "Power",
secondary: "Garage ▸ Power strip ▸ Outlet 1",
});
});
it("shows 'Parent ▸ Device' for the 'device' name detail", () => {
expect(entityDisplay(h, "sensor.outlet_1_power", "device")).toEqual({
primary: "Power",
secondary: "Power strip ▸ Outlet 1",
});
});
it("shows no context for the 'entity' name detail", () => {
expect(entityDisplay(h, "sensor.outlet_1_power", "entity")).toEqual({
primary: "Power",
secondary: undefined,
});
});
});
it("returns an empty display for a deleted entity", () => {
expect(entityDisplay(baseHass({}), "light.removed")).toEqual({});
});
@@ -263,11 +263,20 @@ describe("searchEntities", () => {
"light.kitchen_ceiling": state("light.kitchen_ceiling"),
"light.living_lamp": state("light.living_lamp"),
"sensor.orphan": state("sensor.orphan"),
"sensor.outlet_1_power": state("sensor.outlet_1_power"),
},
entities: {
"light.kitchen_ceiling": entity({ area_id: "kitchen" }),
"light.living_lamp": entity({ area_id: "living" }),
"sensor.orphan": entity({}),
"sensor.outlet_1_power": entity({ device_id: "outlet_1" }),
},
devices: {
outlet_1: device("outlet_1", {
name: "Outlet 1",
parent_device_id: "strip",
}),
strip: device("strip", { name: "Power strip", area_id: "kitchen" }),
},
areas: {
kitchen: area("kitchen", { floor_id: "ground", name: "Kitchen" }),
@@ -299,6 +308,12 @@ describe("searchEntities", () => {
expect(ids).toContain("light.living_lamp");
});
it("matches on parent device name", () => {
const { entities, index } = buildSample();
const ids = searchEntities(entities, index, "Power strip").map((s) => s.id);
expect(ids).toContain("sensor.outlet_1_power");
});
it("respects the result limit", () => {
const { entities, index } = buildSample();
expect(searchEntities(entities, index, "light", 1)).toHaveLength(1);
-25
View File
@@ -6063,15 +6063,6 @@ __metadata:
languageName: node
linkType: hard
"@types/leaflet-draw@npm:1.0.13":
version: 1.0.13
resolution: "@types/leaflet-draw@npm:1.0.13"
dependencies:
"@types/leaflet": "npm:^1.9"
checksum: 10/1a6c3a8b3011f15362108b522fa6d17cca888a7f866e2e582dac921e2c748d9e24685d83b2a5ed1d97e3a1448b0f5b1879a42f1143ffdeb36b71c7fb9b94e9f5
languageName: node
linkType: hard
"@types/leaflet.markercluster@npm:1.5.6":
version: 1.5.6
resolution: "@types/leaflet.markercluster@npm:1.5.6"
@@ -10430,7 +10421,6 @@ __metadata:
"@types/culori": "npm:4.0.1"
"@types/html-minifier-terser": "npm:7.0.2"
"@types/leaflet": "npm:1.9.22"
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.5"
@@ -10488,7 +10478,6 @@ __metadata:
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lightningcss: "npm:1.33.0"
@@ -11688,20 +11677,6 @@ __metadata:
languageName: node
linkType: hard
"leaflet-draw@npm:1.0.4":
version: 1.0.4
resolution: "leaflet-draw@npm:1.0.4"
checksum: 10/180cc222a2d63de9d1ea197bca107ba5b977e1ce323dd714028be0ba30d0b8a2963619b2555bff5b2efb7befd240a45ad973c0e1947ca355ee4e7e2d11231cbe
languageName: node
linkType: hard
"leaflet-draw@patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch::locator=home-assistant-frontend%40workspace%3A.":
version: 1.0.4
resolution: "leaflet-draw@patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch::version=1.0.4&hash=790842&locator=home-assistant-frontend%40workspace%3A."
checksum: 10/70e43ef0307dd3995dc392609a627b349070f25a57b908a4ab07e17b579878fdd0ff090847ca42826b933c547e324a4e560f35a8a1417f8209e702c3512e4d8a
languageName: node
linkType: hard
"leaflet.markercluster@npm:1.5.3":
version: 1.5.3
resolution: "leaflet.markercluster@npm:1.5.3"