mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-26 15:00:42 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d3a5c2b7 | ||
|
|
cdb68d0b56 | ||
|
|
5960dfea0e | ||
|
|
d830220eff | ||
|
|
c6f72c9cf5 | ||
|
|
60f7728d54 | ||
|
|
6014ee7744 | ||
|
|
f32833fcbe | ||
|
|
a9295ac21b | ||
|
|
66cc3f62c5 | ||
|
|
fb1a75edd4 | ||
|
|
8f7184023a | ||
|
|
f3aba8f142 | ||
|
|
9c28c21792 | ||
|
|
ccceddfd35 | ||
|
|
e633e5fb3e | ||
|
|
9a311b0518 | ||
|
|
c07f421f74 | ||
|
|
62acda8c08 |
@@ -186,6 +186,11 @@ export class LeafletMapEngine implements MapEngine {
|
||||
this.leafletMap.fitBounds(bounds, {
|
||||
maxZoom: options?.maxZoom,
|
||||
animate: options?.animate,
|
||||
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
|
||||
paddingBottomRight: [
|
||||
options?.padding?.right ?? 0,
|
||||
options?.padding?.bottom ?? 0,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,8 @@ interface ManagedMarker {
|
||||
interface ClusterGroup {
|
||||
/** Members are shown in a bubble at their spot instead of an icon */
|
||||
open?: boolean;
|
||||
/** Grouped by key (a zone), so it bubbles even with a single member */
|
||||
keyed?: boolean;
|
||||
members: ManagedMarker[];
|
||||
center: MapLatLng;
|
||||
iconMarker?: MapLibreMarker;
|
||||
@@ -516,13 +518,26 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
options?.maxZoom !== undefined
|
||||
? options.maxZoom - ZOOM_OFFSET
|
||||
: undefined;
|
||||
// Passed per fit: easeTo's padding would stick to the map
|
||||
const padding = {
|
||||
top: options?.padding?.top ?? 0,
|
||||
right: options?.padding?.right ?? 0,
|
||||
bottom: options?.padding?.bottom ?? 0,
|
||||
left: options?.padding?.left ?? 0,
|
||||
};
|
||||
if (minLat === maxLat && minLng === maxLng) {
|
||||
// Zero-area bounds: center on the point
|
||||
this._map.easeTo({
|
||||
center: [minLng, minLat],
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
});
|
||||
// Zero-area bounds: center on the point, keeping the zoom unless given
|
||||
this._map.fitBounds(
|
||||
[
|
||||
[minLng, minLat],
|
||||
[minLng, minLat],
|
||||
],
|
||||
{
|
||||
maxZoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
padding,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
@@ -533,7 +548,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[minLng - lngPad, minLat - latPad],
|
||||
[maxLng + lngPad, maxLat + latPad],
|
||||
],
|
||||
{ maxZoom, animate: options?.animate }
|
||||
{ maxZoom, animate: options?.animate, padding }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1223,6 +1238,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const groups: {
|
||||
seed: { x: number; y: number };
|
||||
members: ManagedMarker[];
|
||||
keyed?: boolean;
|
||||
}[] = [];
|
||||
|
||||
// Keyed groups first; one spread too wide falls through to proximity
|
||||
@@ -1245,8 +1261,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
Math.max(...xs) - Math.min(...xs),
|
||||
Math.max(...ys) - Math.min(...ys)
|
||||
);
|
||||
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members });
|
||||
// A keyed group (a zone) bubbles even with a single member, so a lone
|
||||
// person or device in a zone still shows in a bubble pinned to it.
|
||||
if (members.length === 1 || spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members, keyed: true });
|
||||
} else {
|
||||
ungrouped.push(...members);
|
||||
}
|
||||
@@ -1272,6 +1290,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
this._clusterGroups = groups.map((group) => ({
|
||||
members: group.members,
|
||||
keyed: group.keyed,
|
||||
center: [
|
||||
group.members.reduce((sum, m) => sum + m.location[0], 0) /
|
||||
group.members.length,
|
||||
@@ -1283,7 +1302,9 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
for (const group of this._clusterGroups) {
|
||||
group.iconMarker = undefined;
|
||||
if (group.members.length === 1) {
|
||||
// A lone non-keyed marker shows plainly; a lone zone occupant falls
|
||||
// through to the bubble path so it renders in a bubble at its zone.
|
||||
if (group.members.length === 1 && !group.keyed) {
|
||||
this._showMarker(group.members[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,21 @@ export const entityMapColor = (
|
||||
computedStyles
|
||||
);
|
||||
|
||||
/** The color a new zone will take once created, from the next creation-order slot */
|
||||
export const nextZoneColor = (
|
||||
passive: boolean,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string => {
|
||||
if (passive) {
|
||||
return computedStyles.getPropertyValue("--secondary-text-color");
|
||||
}
|
||||
return getColorByIndex(
|
||||
Object.keys(creationIndex(entries)).length,
|
||||
computedStyles
|
||||
);
|
||||
};
|
||||
|
||||
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
|
||||
export const zoneColor = (
|
||||
entityId: string,
|
||||
|
||||
@@ -42,6 +42,15 @@ export interface MapFitOptions {
|
||||
pad?: number;
|
||||
/** Ease the camera to the bounds instead of jumping; defaults to true */
|
||||
animate?: boolean;
|
||||
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
|
||||
padding?: MapFitPadding;
|
||||
}
|
||||
|
||||
export interface MapFitPadding {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export interface MapMarkerOptions {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
|
||||
import { uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type {
|
||||
@@ -58,6 +59,16 @@ const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
const DEFAULT_CHART_WIDTH = 500;
|
||||
// Slack so a chart is up to date before a scroll can reach it. A phone screen
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
] as const;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -106,7 +117,7 @@ export type CustomLegendOption = ECOption["legend"] & {
|
||||
};
|
||||
|
||||
@customElement("ha-chart-base")
|
||||
export class HaChartBase extends LitElement {
|
||||
export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -183,20 +194,38 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private _layoutTransitionActive = false;
|
||||
|
||||
// Both start visible so a chart on screen renders on its first update rather
|
||||
// than waiting a frame for the observers. A chart that starts off screen is
|
||||
// therefore still built once; only its later updates are gated.
|
||||
private _intersecting = true;
|
||||
|
||||
private _hasSize = true;
|
||||
|
||||
// Reported by the ResizeObserver, so rendering and downsampling need not
|
||||
// measure layout themselves once it has fired.
|
||||
private _contentWidth?: number;
|
||||
|
||||
// @ts-ignore
|
||||
private _resizeController = new ResizeController(this, {
|
||||
callback: () => {
|
||||
callback: (entries) => {
|
||||
// The controller also fires once with no entries when it starts observing.
|
||||
const contentRect = entries[entries.length - 1]?.contentRect;
|
||||
if (contentRect) {
|
||||
this._contentWidth = contentRect.width;
|
||||
this._hasSize = contentRect.width > 0 && contentRect.height > 0;
|
||||
}
|
||||
if (this.chart) {
|
||||
if (this._suspendResize) {
|
||||
this._shouldResizeChart = true;
|
||||
return;
|
||||
}
|
||||
if (!this.chart.getZr().animation.isFinished()) {
|
||||
} else if (!this.chart.getZr().animation.isFinished()) {
|
||||
this._shouldResizeChart = true;
|
||||
} else {
|
||||
this.chart.resize();
|
||||
}
|
||||
}
|
||||
if (!this._suspendResize) {
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,10 +239,24 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private _pendingSetup = false;
|
||||
|
||||
private _pendingUpdate?: Set<PropertyKey>;
|
||||
|
||||
private _pendingOptions?: HaECOption;
|
||||
|
||||
private _pendingZoom?: [number, number, boolean];
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._legendPointerCancel();
|
||||
this._pendingSetup = false;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
this._pendingZoom = undefined;
|
||||
// The observers are about to be torn down, so nothing would correct a stale
|
||||
// value if this element is reattached inside a hidden container.
|
||||
this._intersecting = false;
|
||||
this._hasSize = false;
|
||||
this._contentWidth = undefined;
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
@@ -227,14 +270,31 @@ export class HaChartBase extends LitElement {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._pendingSetup = true;
|
||||
afterNextRender(() => {
|
||||
if (this.isConnected && this._pendingSetup) {
|
||||
this._pendingSetup = false;
|
||||
this._setupChart();
|
||||
}
|
||||
});
|
||||
afterNextRender(() => this._applyDeferredWork());
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => this._applyDeferredWork();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
this._listeners.push(() =>
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
);
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
this._intersecting = entries[entries.length - 1].isIntersecting;
|
||||
if (!this._suspendResize) {
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: this._isMobileSize
|
||||
? VISIBILITY_ROOT_MARGIN_NARROW
|
||||
: VISIBILITY_ROOT_MARGIN,
|
||||
}
|
||||
);
|
||||
intersectionObserver.observe(this);
|
||||
this._listeners.push(() => intersectionObserver.disconnect());
|
||||
|
||||
this._listeners.push(
|
||||
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
|
||||
if (this._reducedMotion !== matches) {
|
||||
@@ -300,6 +360,7 @@ export class HaChartBase extends LitElement {
|
||||
this._suspendResize = this._layoutTransitionActive;
|
||||
if (!this._suspendResize) {
|
||||
this._resizeChartIfNeeded();
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
};
|
||||
window.addEventListener("hass-layout-transition", handleLayoutTransition);
|
||||
@@ -312,24 +373,105 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (this.isConnected) {
|
||||
this._setupChart();
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
// The only measurement taken here: neither observer has reported yet, and a
|
||||
// chart first rendered inside a hidden container has to defer its setup
|
||||
// rather than build against a guessed width.
|
||||
this._hasSize = this.clientWidth > 0 && this.clientHeight > 0;
|
||||
if (this._isVisible()) {
|
||||
this._setupChart();
|
||||
} else {
|
||||
this._pendingSetup = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _isVisible() {
|
||||
return (
|
||||
document.visibilityState !== "hidden" &&
|
||||
this._hasSize &&
|
||||
this._intersecting
|
||||
);
|
||||
}
|
||||
|
||||
private _deferUpdate(changedProps: PropertyValues) {
|
||||
for (const prop of DEFERRED_PROPS) {
|
||||
if (!changedProps.has(prop)) {
|
||||
continue;
|
||||
}
|
||||
if (!this._pendingUpdate) {
|
||||
this._pendingUpdate = new Set();
|
||||
}
|
||||
if (prop === "options" && !this._pendingUpdate.has(prop)) {
|
||||
// The one previous value a replay reads, and it has to stay the options
|
||||
// the chart currently renders, not those of a later deferred change.
|
||||
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
|
||||
}
|
||||
this._pendingUpdate.add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
private async _applyDeferredWork() {
|
||||
if (!this._pendingSetup && !this._pendingUpdate) {
|
||||
return;
|
||||
}
|
||||
if (!this.isConnected || !this._isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (this._pendingSetup) {
|
||||
await this._setupChart();
|
||||
return;
|
||||
}
|
||||
const pending = this._pendingUpdate!;
|
||||
const previousOptions = this._pendingOptions;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
this._applyChartUpdate(pending, previousOptions);
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("_themes") && this.hasUpdated) {
|
||||
this._setupChart();
|
||||
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
|
||||
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
|
||||
return;
|
||||
}
|
||||
const invisible = !this._isVisible();
|
||||
if (themeChanged) {
|
||||
if (invisible) {
|
||||
this._pendingSetup = true;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
} else {
|
||||
this._setupChart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("options")) {
|
||||
// Separate 'if' from below since this must updated before _getSeries()
|
||||
// Separate 'if' from below since this must be updated before _getSeries().
|
||||
// It stays out of _applyChartUpdate so a replay cannot request another
|
||||
// update and turn one catch-up render into two.
|
||||
this._updateHiddenStatsFromOptions(this.options);
|
||||
}
|
||||
if (invisible) {
|
||||
if (!this._pendingSetup) {
|
||||
this._deferUpdate(changedProps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._applyChartUpdate(
|
||||
changedProps,
|
||||
changedProps.get("options") as HaECOption | undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _applyChartUpdate(
|
||||
changedProps: { has: (prop: string) => boolean },
|
||||
previousOptions: HaECOption | undefined
|
||||
) {
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
|
||||
chartOptions.series = this._getSeries();
|
||||
// New data, or a series shown again, may well be convertible where the
|
||||
@@ -347,12 +489,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
if (
|
||||
this._compareCustomLegendOptions(
|
||||
changedProps.get("options"),
|
||||
this.options
|
||||
)
|
||||
) {
|
||||
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
|
||||
// custom legend changes may require a resize to layout properly
|
||||
this._shouldResizeChart = true;
|
||||
this._resizeAnimationDuration = 250;
|
||||
@@ -463,10 +600,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
const overflowLimit = isMobile
|
||||
const overflowLimit = this._isMobileSize
|
||||
? LEGEND_OVERFLOW_LIMIT_MOBILE
|
||||
: LEGEND_OVERFLOW_LIMIT;
|
||||
return html`<div
|
||||
@@ -578,7 +712,11 @@ export class HaChartBase extends LitElement {
|
||||
// costs the user their place in the tab order, so stay programmatically
|
||||
// focusable for as long as we hold focus, however we stop being sonifiable.
|
||||
this._sonificationFocusHeld = true;
|
||||
if (this._sonification || this._sonificationLoading || !this.chart) {
|
||||
if (this._sonification || this._sonificationLoading) {
|
||||
return;
|
||||
}
|
||||
await this._applyDeferredWork();
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
this._sonificationLoading = true;
|
||||
@@ -635,14 +773,22 @@ export class HaChartBase extends LitElement {
|
||||
);
|
||||
|
||||
private async _setupChart() {
|
||||
if (this._loading) return;
|
||||
if (this._loading) {
|
||||
this._pendingSetup = true;
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._pendingSetup = false;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
try {
|
||||
// The connection holds a reference to the chart instance, so it cannot
|
||||
// outlive it. Focusing the chart again reconnects.
|
||||
this._disposeSonification();
|
||||
if (this.chart) {
|
||||
this.chart.dispose();
|
||||
this.chart = undefined;
|
||||
this._originalZrFlush = undefined;
|
||||
}
|
||||
const echarts = (await import("../../resources/echarts/echarts")).default;
|
||||
|
||||
@@ -780,8 +926,14 @@ export class HaChartBase extends LitElement {
|
||||
series: this._getSeries(),
|
||||
});
|
||||
this._updateSankeyRoam();
|
||||
if (this._pendingZoom) {
|
||||
const [start, end, silent] = this._pendingZoom;
|
||||
this._pendingZoom = undefined;
|
||||
this.chart.dispatchAction({ type: "dataZoom", start, end, silent });
|
||||
}
|
||||
} finally {
|
||||
this._loading = false;
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,9 +1079,7 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
|
||||
if (options.tooltip) {
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
const isMobile = this._isMobileSize;
|
||||
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
|
||||
// back into the caller's options.tooltip reference (callers may cache the
|
||||
// options object via memoizeOne, in which case in-place mutation would
|
||||
@@ -1166,8 +1316,8 @@ export class HaChartBase extends LitElement {
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) *
|
||||
window.devicePixelRatio,
|
||||
((this._contentWidth ?? this.clientWidth) ||
|
||||
DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
@@ -1180,7 +1330,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
private _getDefaultHeight() {
|
||||
return Math.max(this.clientWidth / 2, 200);
|
||||
return Math.max((this._contentWidth ?? this.clientWidth) / 2, 200);
|
||||
}
|
||||
|
||||
private _setChartOptions(options: ECOption) {
|
||||
@@ -1247,7 +1397,16 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
|
||||
public zoom(start: number, end: number, silent = false) {
|
||||
this.chart?.dispatchAction({
|
||||
if (!this.chart || this._pendingSetup) {
|
||||
// Sibling charts sync their zoom imperatively, so a range that arrives
|
||||
// before a deferred setup or rebuild has to be replayed rather than
|
||||
// dropped. A reset to the full range is what a fresh chart is built with,
|
||||
// so it just clears.
|
||||
this._pendingZoom =
|
||||
start === 0 && end === 100 ? undefined : [start, end, silent];
|
||||
return;
|
||||
}
|
||||
this.chart.dispatchAction({
|
||||
type: "dataZoom",
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -375,7 +375,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
const chartBase =
|
||||
chartComponent.renderRoot?.querySelector("ha-chart-base");
|
||||
|
||||
if (chartBase && chartBase.chart) {
|
||||
if (chartBase) {
|
||||
chartBase.zoom(0, 100);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -125,7 +125,23 @@ export class StatisticsChart extends LitElement {
|
||||
private _yAxisFractionDigits = 1;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
return changedProps.size > 1 || !changedProps.has("hass");
|
||||
return (
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass") ||
|
||||
this._entityNamesChanged(changedProps)
|
||||
);
|
||||
}
|
||||
|
||||
// Series names are resolved once and cached in _chartData, so a hass update
|
||||
// that only replaces the formatters has to regenerate them. The formatters are
|
||||
// swapped as a set whenever the registries change, which is what renames a
|
||||
// series.
|
||||
private _entityNamesChanged(changedProps: PropertyValues): boolean {
|
||||
if (!changedProps.has("hass")) {
|
||||
return false;
|
||||
}
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
return !!oldHass && oldHass.formatEntityName !== this.hass.formatEntityName;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
@@ -135,7 +151,8 @@ export class StatisticsChart extends LitElement {
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats") ||
|
||||
changedProps.has("names")
|
||||
changedProps.has("names") ||
|
||||
this._entityNamesChanged(changedProps)
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
@@ -532,17 +549,26 @@ export class StatisticsChart extends LitElement {
|
||||
this.unit = data.unit;
|
||||
this._yAxisFractionDigits = data.yAxisFractionDigits;
|
||||
this._chartData = data.datasets;
|
||||
if (data.legendData.length !== this._legendData?.length) {
|
||||
const legendData =
|
||||
data.legendData.length > 1
|
||||
? data.legendData.map(({ id, name, noLabelClick }) => ({
|
||||
id,
|
||||
name,
|
||||
noLabelClick,
|
||||
}))
|
||||
: // if there is only one entity, let the base chart handle the legend
|
||||
undefined;
|
||||
if (
|
||||
legendData?.length !== this._legendData?.length ||
|
||||
legendData?.some(
|
||||
(item, index) =>
|
||||
item.id !== this._legendData?.[index]?.id ||
|
||||
item.name !== this._legendData?.[index]?.name ||
|
||||
item.noLabelClick !== this._legendData?.[index]?.noLabelClick
|
||||
)
|
||||
) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
this._legendData =
|
||||
data.legendData.length > 1
|
||||
? data.legendData.map(({ id, name, noLabelClick }) => ({
|
||||
id,
|
||||
name,
|
||||
noLabelClick,
|
||||
}))
|
||||
: // if there is only one entity, let the base chart handle the legend
|
||||
undefined;
|
||||
this._legendData = legendData;
|
||||
}
|
||||
this._statisticIds = data.statisticIds;
|
||||
}
|
||||
|
||||
@@ -1202,6 +1202,20 @@ export class HaDataTable extends LitElement {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table {
|
||||
width: calc(
|
||||
100% + var(--safe-area-inset-left, 0px) +
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
|
||||
margin-right: calc(-1 * var(--safe-area-inset-right, 0px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__header-row {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.mdc-data-table__header-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -1499,6 +1513,16 @@ export class HaDataTable extends LitElement {
|
||||
overflow: overlay !important;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__row {
|
||||
box-sizing: border-box;
|
||||
padding-left: var(--safe-area-inset-left, 0px);
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__row:has(.group-header) {
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.mdc-data-table__table.auto-height .scroller {
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
|
||||
@@ -11,39 +11,80 @@ import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
* the sheet by dragging the handle at the top. It supports both mouse and touch
|
||||
* interactions and automatically closes when dragged below a 20% of screen height.
|
||||
*
|
||||
* A persistent sheet never closes by dragging; it stops at its minimum height
|
||||
* instead, so a host can keep a collapsed strip of content reachable.
|
||||
*
|
||||
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
|
||||
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels once
|
||||
* it has opened and whenever a drag ends
|
||||
*
|
||||
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
|
||||
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
|
||||
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
|
||||
* @cssprop --ha-bottom-sheet-handle-padding - How far below the handle the
|
||||
* grab area reaches; shrink it when content sits right under the handle
|
||||
*/
|
||||
@customElement("ha-resizable-bottom-sheet")
|
||||
export class HaResizableBottomSheet extends LitElement {
|
||||
@query("dialog") private _dialog!: HTMLDialogElement;
|
||||
|
||||
/** Dragging down stops at the minimum height instead of closing the sheet */
|
||||
@property({ type: Boolean }) public persistent = false;
|
||||
|
||||
/**
|
||||
* The height in pixels the sheet cannot be dragged below, e.g. what a host
|
||||
* measures for the strip it wants to keep visible. Without it the sheet
|
||||
* stops at 20% of the viewport.
|
||||
*/
|
||||
@property({ type: Number, attribute: "min-height" })
|
||||
public minHeight?: number;
|
||||
|
||||
/**
|
||||
* The largest share of the viewport the sheet opens at, in percent. It
|
||||
* opens at its content height up to this, and can be dragged to 90 after.
|
||||
*/
|
||||
@property({ type: Number, attribute: "open-max-viewport-height" })
|
||||
public openMaxViewportHeight = 70;
|
||||
|
||||
/**
|
||||
* Whether the sheet may open smaller than 55% of the viewport, down to its
|
||||
* content height and the minimum it can be dragged to.
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "open-at-content-height" })
|
||||
public openAtContentHeight = false;
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _dragStartY = 0;
|
||||
|
||||
private _initialSize = 0;
|
||||
|
||||
@state() private _dialogMaxViewpointHeight = 70;
|
||||
private _opened = false;
|
||||
|
||||
@state() private _dialogMinViewpointHeight = 55;
|
||||
@state() private _dialogMaxViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogMinViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogViewportHeight?: number;
|
||||
|
||||
render() {
|
||||
// Until it has opened, the sheet sizes to its content within the opening
|
||||
// bounds; afterwards it keeps the height it settled on or was dragged to
|
||||
const maxHeight =
|
||||
this._dialogMaxViewpointHeight ?? this.openMaxViewportHeight;
|
||||
const minHeight =
|
||||
this._dialogMinViewpointHeight ??
|
||||
(this.openAtContentHeight ? this._minViewportHeight() : 55);
|
||||
return html`<dialog
|
||||
open
|
||||
@transitionend=${this._handleTransitionEnd}
|
||||
style=${`
|
||||
--height: ${this._dialogViewportHeight}vh;
|
||||
--height: ${this._dialogViewportHeight}dvh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}vh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}dvh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}vh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}dvh;
|
||||
--max-height: ${maxHeight}vh;
|
||||
--max-height: ${maxHeight}dvh;
|
||||
--min-height: ${minHeight}vh;
|
||||
--min-height: ${minHeight}dvh;
|
||||
`}
|
||||
>
|
||||
<div class="handle-wrapper">
|
||||
@@ -83,7 +124,9 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight =
|
||||
(this._dialog.offsetHeight / window.innerHeight) * 100;
|
||||
this._dialogMaxViewpointHeight = 90;
|
||||
this._dialogMinViewpointHeight = 20;
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this._opened = true;
|
||||
this._fireResized();
|
||||
} else {
|
||||
// after close animation is done close dialog element and fire closed event
|
||||
this._dialog.close();
|
||||
@@ -102,6 +145,7 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
});
|
||||
document.addEventListener("touchend", this._handleTouchEnd);
|
||||
document.addEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.addEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -113,8 +157,16 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
document.removeEventListener("touchmove", this._handleTouchMove);
|
||||
document.removeEventListener("touchend", this._handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.removeEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
// minHeight is a pixel value, so its viewport-relative minimum depends on the
|
||||
// window height; recompute it when the viewport changes (for example on
|
||||
// rotation) so the cached percentage cannot clip the persistent peek content.
|
||||
private _handleViewportResize = () => {
|
||||
this._applyMinViewportHeight();
|
||||
};
|
||||
|
||||
private _handleMouseDown = (ev: MouseEvent) => {
|
||||
this._startDrag(ev.clientY);
|
||||
};
|
||||
@@ -156,8 +208,10 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
let newSize = this._initialSize + deltaVh;
|
||||
newSize = Math.max(10, Math.min(90, newSize));
|
||||
|
||||
// on drag down and below 20vh
|
||||
if (newSize < 20 && deltaY < 0) {
|
||||
if (this.persistent) {
|
||||
newSize = Math.max(this._minViewportHeight(), newSize);
|
||||
} else if (newSize < 20 && deltaY < 0) {
|
||||
// on drag down and below 20vh
|
||||
this._endDrag();
|
||||
this.closeSheet();
|
||||
return;
|
||||
@@ -166,6 +220,45 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight = newSize;
|
||||
}
|
||||
|
||||
private _minViewportHeight(): number {
|
||||
return this.minHeight === undefined
|
||||
? 20
|
||||
: (this.minHeight / window.innerHeight) * 100;
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
// A minimum set after opening applies right away
|
||||
if (changedProperties.has("minHeight") && this._opened) {
|
||||
this._applyMinViewportHeight();
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the current pixel minimum as a viewport percentage and, once it has
|
||||
// rendered, reports the size if it changed: raising the minimum can grow a
|
||||
// sheet resting at the old one, and the host needs the new height to keep its
|
||||
// overlay padding in step.
|
||||
private _applyMinViewportHeight() {
|
||||
if (!this._opened) {
|
||||
return;
|
||||
}
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this.updateComplete.then(() => {
|
||||
if (this._dialog.offsetHeight !== this._lastReportedHeight) {
|
||||
this._fireResized();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _lastReportedHeight?: number;
|
||||
|
||||
private _fireResized() {
|
||||
this._lastReportedHeight = this._dialog.offsetHeight;
|
||||
fireEvent(this, "bottom-sheet-resized", {
|
||||
height: this._lastReportedHeight,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleMouseUp = () => {
|
||||
this._endDrag();
|
||||
};
|
||||
@@ -180,6 +273,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
}
|
||||
this._dragging = false;
|
||||
document.body.style.removeProperty("cursor");
|
||||
// Hosts lay out around the sheet once, not on every move
|
||||
this.updateComplete.then(() => this._fireResized());
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -201,7 +296,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 7;
|
||||
padding-bottom: 76px;
|
||||
/* Extends the grab area over the content below the handle */
|
||||
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
|
||||
}
|
||||
.handle-wrapper .handle::after {
|
||||
content: "";
|
||||
@@ -234,8 +330,20 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
position: fixed;
|
||||
--sheet-inset-left: var(
|
||||
--ha-bottom-sheet-inset-left,
|
||||
var(--safe-area-inset-left)
|
||||
);
|
||||
--sheet-inset-right: var(
|
||||
--ha-bottom-sheet-inset-right,
|
||||
var(--safe-area-inset-right)
|
||||
);
|
||||
--sheet-border-width: var(--ha-bottom-sheet-border-width, 0px);
|
||||
--sheet-side-borders: calc(2 * var(--sheet-border-width));
|
||||
width: calc(
|
||||
100% - 4px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
100% - var(--sheet-side-borders) - var(--sheet-inset-left) - var(
|
||||
--sheet-inset-right
|
||||
)
|
||||
);
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
@@ -257,14 +365,14 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
);
|
||||
transform: translateY(100%);
|
||||
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
|
||||
border-top-width: var(--ha-bottom-sheet-border-width);
|
||||
border-right-width: var(--ha-bottom-sheet-border-width);
|
||||
border-left-width: var(--ha-bottom-sheet-border-width);
|
||||
border-top-width: var(--sheet-border-width);
|
||||
border-right-width: var(--sheet-border-width);
|
||||
border-left-width: var(--sheet-border-width);
|
||||
border-bottom-width: 0;
|
||||
border-style: var(--ha-bottom-sheet-border-style);
|
||||
border-color: var(--ha-bottom-sheet-border-color);
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
margin-left: var(--sheet-inset-left);
|
||||
margin-right: var(--sheet-inset-right);
|
||||
}
|
||||
|
||||
dialog.show {
|
||||
@@ -280,5 +388,6 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"bottom-sheet-closed": undefined;
|
||||
"bottom-sheet-resized": { height: number };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +127,12 @@ export class HaLocationSelector extends LitElement {
|
||||
? this.hass.config.longitude
|
||||
: value.longitude,
|
||||
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
|
||||
radius_color: zoneRadiusColor,
|
||||
radius_color: selector.location?.color || zoneRadiusColor,
|
||||
name: selector.location?.name,
|
||||
icon:
|
||||
selector.location?.icon || selector.location?.radius
|
||||
? "mdi:map-marker-radius"
|
||||
: "mdi:map-marker",
|
||||
selector.location?.icon ||
|
||||
// No icon: show the name's initials, else a default marker
|
||||
(selector.location?.name ? undefined : "mdi:map-marker"),
|
||||
location_editable: true,
|
||||
radius_editable:
|
||||
!!selector.location?.radius && !selector.location?.radius_readonly,
|
||||
|
||||
@@ -52,12 +52,23 @@ export const haTopAppBarFixedStyles = css`
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
border-bottom: var(--app-header-border-bottom);
|
||||
}
|
||||
|
||||
:host([narrow]) .row,
|
||||
:host([narrow]) .sub-row {
|
||||
width: calc(
|
||||
100% + var(--safe-area-inset-left, 0px) +
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
|
||||
padding-left: var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.top-app-bar.has-sub-row .row {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@@ -65,7 +76,8 @@ export const haTopAppBarFixedStyles = css`
|
||||
.sub-row {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
overflow: hidden;
|
||||
border-bottom: var(--app-header-border-bottom);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import { consumeEntityState } from "../../common/decorators/consume-context-entr
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-state-icon";
|
||||
|
||||
/**
|
||||
* @csspart marker - The framed avatar.
|
||||
* @csspart picture - The entity picture inside the frame.
|
||||
*/
|
||||
@customElement("ha-entity-marker")
|
||||
class HaEntityMarker extends LitElement {
|
||||
@property({ attribute: "entity-id", reflect: true }) public entityId?: string;
|
||||
@@ -29,6 +33,7 @@ class HaEntityMarker extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
part="marker"
|
||||
class="marker ${this.entityPicture ? "picture" : ""}"
|
||||
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
|
||||
@click=${this._badgeTap}
|
||||
@@ -36,6 +41,7 @@ class HaEntityMarker extends LitElement {
|
||||
${
|
||||
this.entityPicture
|
||||
? html`<div
|
||||
part="picture"
|
||||
class="entity-picture"
|
||||
style=${styleMap({
|
||||
"background-image": `url(${this.entityPicture})`,
|
||||
|
||||
@@ -18,7 +18,9 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapFitPadding,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
ZONE_CIRCLE_SIZE,
|
||||
zoneMarkerStyles,
|
||||
} from "../../common/map/zone-marker";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
configContext,
|
||||
@@ -300,6 +303,12 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
|
||||
|
||||
/** Part of the map an overlay covers; automatic fits keep clear of it */
|
||||
@property({ attribute: false }) public fitPadding?: MapFitPadding;
|
||||
|
||||
@property({ attribute: "zoom-position" })
|
||||
public zoomPosition: MapControlPosition = "topleft";
|
||||
|
||||
private _zonePositions: Record<string, MapLatLng> = {};
|
||||
|
||||
@property({ attribute: "theme-mode", type: String })
|
||||
@@ -341,6 +350,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _entityHandles: MapMarkerHandle[] = [];
|
||||
|
||||
private _clusterAvatars = new Map<
|
||||
string,
|
||||
HTMLElementTagNameMap["ha-entity-marker"]
|
||||
>();
|
||||
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
|
||||
private _pathHandles: MapItemHandle[] = [];
|
||||
@@ -403,6 +417,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._startingEngine = undefined;
|
||||
this._loading = false;
|
||||
this._entityHandles = [];
|
||||
this._clusterAvatars.clear();
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
@@ -447,6 +462,18 @@ export class HaMap extends ReactiveElement {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
// An overlay that grew or shrank may cover the fitted markers
|
||||
if (
|
||||
changedProps.has("fitPadding") &&
|
||||
!deepEqual(changedProps.get("fitPadding"), this.fitPadding)
|
||||
) {
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
}
|
||||
|
||||
if (changedProps.has("zoomPosition")) {
|
||||
this._engine?.setZoomControlPosition(this.zoomPosition);
|
||||
}
|
||||
|
||||
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
|
||||
if (
|
||||
changedProps.has("_loaded") ||
|
||||
@@ -597,7 +624,7 @@ export class HaMap extends ReactiveElement {
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
rasterOnly: this._forceLeaflet,
|
||||
zoomControlPosition: "topleft",
|
||||
zoomControlPosition: this.zoomPosition,
|
||||
events: {
|
||||
click: (location) => this._handleEngineClick(location),
|
||||
zoomStart: () => {
|
||||
@@ -692,6 +719,7 @@ export class HaMap extends ReactiveElement {
|
||||
public fitMap(options?: {
|
||||
zoom?: number;
|
||||
pad?: number;
|
||||
padding?: MapFitPadding;
|
||||
unpause_autofit?: boolean;
|
||||
}): void {
|
||||
if (options?.unpause_autofit) {
|
||||
@@ -735,6 +763,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._engine!.fitBounds(points, {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
padding: options?.padding ?? this.fitPadding,
|
||||
animate: this._hasFitted,
|
||||
});
|
||||
});
|
||||
@@ -782,8 +811,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
|
||||
) {
|
||||
// An explicit fit is user intent, even while it waits for the engine or
|
||||
// a size; an auto-fit must not take its place in the meantime
|
||||
this._pauseAutoFit = true;
|
||||
if (!this._engine) {
|
||||
// Engine still loading (see _loadMap); runs once it is
|
||||
this._pendingFit = () => this.fitBounds(boundingbox, options);
|
||||
@@ -797,6 +829,7 @@ export class HaMap extends ReactiveElement {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
padding: options?.padding,
|
||||
});
|
||||
});
|
||||
this._hasFitted = true;
|
||||
@@ -1097,6 +1130,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._focusZonePoints = [];
|
||||
|
||||
if (!this.entities) {
|
||||
this._clusterAvatars.clear();
|
||||
engine.setClustering(null);
|
||||
return;
|
||||
}
|
||||
@@ -1299,6 +1333,13 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
const shownIds = new Set(this.entities.map(getEntityId));
|
||||
for (const entityId of this._clusterAvatars.keys()) {
|
||||
if (!shownIds.has(entityId)) {
|
||||
this._clusterAvatars.delete(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
engine.setClustering(
|
||||
this.clusterMarkers
|
||||
? {
|
||||
@@ -1326,7 +1367,19 @@ export class HaMap extends ReactiveElement {
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "cluster-bubble";
|
||||
for (const member of shown) {
|
||||
const avatar = document.createElement("ha-entity-marker");
|
||||
let avatar = member?.entityId
|
||||
? this._clusterAvatars.get(member.entityId)
|
||||
: undefined;
|
||||
// Leaflet keeps the outgoing bubble on screen during its zoom animation
|
||||
if (avatar && (avatar.isConnected || avatar.parentElement === bubble)) {
|
||||
avatar = undefined;
|
||||
}
|
||||
if (!avatar) {
|
||||
avatar = document.createElement("ha-entity-marker");
|
||||
if (member?.entityId) {
|
||||
this._clusterAvatars.set(member.entityId, avatar);
|
||||
}
|
||||
}
|
||||
avatar.entityId = member?.entityId;
|
||||
avatar.entityName = member?.label ?? "";
|
||||
avatar.entityUnit = member?.unit ?? "";
|
||||
@@ -1339,10 +1392,11 @@ export class HaMap extends ReactiveElement {
|
||||
member?.color ?? "var(--primary-color)"
|
||||
);
|
||||
avatar.style.setProperty("--ha-marker-border-width", "2px");
|
||||
} else {
|
||||
avatar.style.removeProperty("--ha-marker-color");
|
||||
avatar.style.removeProperty("--ha-marker-border-width");
|
||||
}
|
||||
if (member?.selected) {
|
||||
avatar.selected = true;
|
||||
}
|
||||
avatar.selected = member?.selected ?? false;
|
||||
bubble.appendChild(avatar);
|
||||
}
|
||||
|
||||
@@ -1426,12 +1480,19 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
#map {
|
||||
height: 100%;
|
||||
/* A cluster bubble and its tail cast a single shadow around their
|
||||
combined silhouette (drop-shadow on the wrapper), so no shadow seam
|
||||
appears between the bubble and its tail. */
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
#map.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
#map.dark {
|
||||
background: #090909;
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
#map.forced-dark {
|
||||
color: #ffffff;
|
||||
@@ -1453,6 +1514,7 @@ export class HaMap extends ReactiveElement {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
}
|
||||
.cluster-open-members {
|
||||
display: flex;
|
||||
@@ -1464,7 +1526,6 @@ export class HaMap extends ReactiveElement {
|
||||
max-width: calc(6 * var(--ha-marker-size, 48px) + 5 * 4px + 12px);
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
/* Both tails are a rotated square whose upper half sits under the bubble;
|
||||
drawn behind it, so it never covers a member's frame or selected ring */
|
||||
@@ -1498,6 +1559,19 @@ export class HaMap extends ReactiveElement {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.maplibregl-ctrl-bottom-left,
|
||||
.maplibregl-ctrl-bottom-right {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.maplibregl-ctrl-top-left,
|
||||
.maplibregl-ctrl-bottom-left {
|
||||
margin-left: var(--ha-map-left-inset, 0);
|
||||
}
|
||||
.maplibregl-ctrl-top-right,
|
||||
.maplibregl-ctrl-bottom-right {
|
||||
margin-right: var(--ha-map-right-inset, 0);
|
||||
}
|
||||
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
@@ -1551,6 +1625,12 @@ export class HaMap extends ReactiveElement {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
}
|
||||
/* The wrapper carries the shadow around the bubble-plus-tail outline, so
|
||||
the bubble itself drops its own to avoid a seam at the tail. */
|
||||
.cluster-marker .cluster-bubble {
|
||||
filter: none;
|
||||
}
|
||||
.cluster-bubble-tail {
|
||||
width: ${CLUSTER_TAIL_SIZE}px;
|
||||
@@ -1568,7 +1648,7 @@ export class HaMap extends ReactiveElement {
|
||||
box-sizing: border-box;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
filter: var(--ha-cluster-shadow);
|
||||
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
|
||||
--ha-marker-color: transparent;
|
||||
--ha-marker-border-width: 1px;
|
||||
@@ -1599,6 +1679,16 @@ export class HaMap extends ReactiveElement {
|
||||
--ha-marker-border-radius: 10px;
|
||||
}
|
||||
${unsafeCSS(zoneMarkerStyles)}
|
||||
.leaflet-bottom {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.leaflet-left {
|
||||
margin-left: var(--ha-map-left-inset, 0);
|
||||
}
|
||||
.leaflet-right {
|
||||
margin-right: var(--ha-map-right-inset, 0);
|
||||
}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
|
||||
@@ -69,7 +69,10 @@ export class HaTracePathDetails extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public renderedNodes: Record<string, any> = {};
|
||||
|
||||
@property({ attribute: false }) public trackedNodes!: Record<string, any>;
|
||||
@property({ attribute: false }) public trackedNodes!: Record<
|
||||
string,
|
||||
NodeInfo
|
||||
>;
|
||||
|
||||
@state() private _view: (typeof TRACE_PATH_TABS)[number] = "step_config";
|
||||
|
||||
@@ -192,11 +195,16 @@ export class HaTracePathDetails extends LitElement {
|
||||
const nestPath = curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.split("/");
|
||||
let currentDetail = this.selected.config;
|
||||
let currentDetail: unknown = this.selected.config;
|
||||
for (const part of nestPath) {
|
||||
if (!["undefined", "string"].includes(typeof currentDetail[part])) {
|
||||
currentDetail = currentDetail[part];
|
||||
if (typeof currentDetail !== "object" || currentDetail === null) {
|
||||
break;
|
||||
}
|
||||
const child = (currentDetail as Record<string, unknown>)[part];
|
||||
if (child === undefined || typeof child === "string") {
|
||||
break;
|
||||
}
|
||||
currentDetail = child;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
@@ -282,6 +290,9 @@ export class HaTracePathDetails extends LitElement {
|
||||
: html`<pre>${dump(rest)}</pre>`
|
||||
}
|
||||
${
|
||||
typeof currentDetail === "object" &&
|
||||
currentDetail !== null &&
|
||||
"entity_id" in currentDetail &&
|
||||
currentDetail.entity_id &&
|
||||
curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
@@ -486,7 +497,9 @@ export class HaTracePathDetails extends LitElement {
|
||||
const trackedPaths = Object.keys(this.trackedNodes);
|
||||
const index = trackedPaths.indexOf(this.selected.path);
|
||||
|
||||
if (index === -1) {
|
||||
// Synthetic choose-option nodes have no direct trace records, so there is
|
||||
// no start timestamp to slice the logbook with.
|
||||
if (index === -1 || !startTrace) {
|
||||
return html`<div class="padded-box">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.step_not_executed"
|
||||
@@ -496,7 +509,11 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
let entries: LogbookEntry[];
|
||||
|
||||
if (index === trackedPaths.length - 1) {
|
||||
const nextTrace =
|
||||
index < trackedPaths.length - 1
|
||||
? paths[trackedPaths[index + 1]]
|
||||
: undefined;
|
||||
if (!nextTrace) {
|
||||
// it's the last entry. Find all logbook entries after start.
|
||||
const startTime = new Date(startTrace[0].timestamp);
|
||||
const idx = this.logbookEntries.findIndex(
|
||||
@@ -508,8 +525,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
entries = this.logbookEntries.slice(idx);
|
||||
}
|
||||
} else {
|
||||
const nextTrace = paths[trackedPaths[index + 1]];
|
||||
|
||||
const startTime = new Date(startTrace[0].timestamp);
|
||||
const endTime = new Date(nextTrace[0].timestamp);
|
||||
|
||||
|
||||
@@ -18,32 +18,32 @@ import {
|
||||
mdiRoomService,
|
||||
mdiShuffleDisabled,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { PropertyValues } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { Condition, Trigger } from "../../data/automation";
|
||||
import { flattenTriggers } from "../../data/automation";
|
||||
import type {
|
||||
Action,
|
||||
ChooseAction,
|
||||
IfAction,
|
||||
ManualScriptConfig,
|
||||
ParallelAction,
|
||||
RepeatAction,
|
||||
SequenceAction,
|
||||
ServiceAction,
|
||||
WaitAction,
|
||||
WaitForTriggerAction,
|
||||
import {
|
||||
getActionType,
|
||||
type ChooseAction,
|
||||
type IfAction,
|
||||
type ParallelAction,
|
||||
type RepeatAction,
|
||||
type SequenceAction,
|
||||
type ServiceAction,
|
||||
type WaitAction,
|
||||
type WaitForTriggerAction,
|
||||
} from "../../data/script";
|
||||
import { getActionType } from "../../data/script";
|
||||
import type { TraceExtended } from "../../data/trace";
|
||||
import { TraceTree } from "../../data/trace-tree";
|
||||
import type {
|
||||
ChooseActionTraceStep,
|
||||
ConditionTraceStep,
|
||||
IfActionTraceStep,
|
||||
TraceExtended,
|
||||
} from "../../data/trace";
|
||||
NodeInfo,
|
||||
TraceActionNode,
|
||||
TraceNode,
|
||||
} from "../../data/trace-tree";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-service-icon";
|
||||
import "./hat-graph-branch";
|
||||
@@ -52,13 +52,7 @@ import "./hat-graph-node";
|
||||
import "./hat-graph-spacer";
|
||||
import { ACTION_ICONS } from "../../data/action";
|
||||
|
||||
type NodeType = "trigger" | "condition" | "action" | "chooseOption" | undefined;
|
||||
|
||||
export interface NodeInfo {
|
||||
path: string;
|
||||
config: any;
|
||||
type?: NodeType;
|
||||
}
|
||||
export type { NodeInfo };
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -68,6 +62,10 @@ declare global {
|
||||
|
||||
@customElement("hat-script-graph")
|
||||
export class HatScriptGraph extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public trace!: TraceExtended;
|
||||
|
||||
@property({ attribute: false }) public selected?: string;
|
||||
@@ -75,57 +73,43 @@ export class HatScriptGraph extends LitElement {
|
||||
@query("hat-graph-node[active], hat-graph-branch[active]")
|
||||
private _activeNode?: HTMLElement;
|
||||
|
||||
public renderedNodes: Record<string, NodeInfo> = {};
|
||||
private _buildTree = memoizeOne(
|
||||
(trace: TraceExtended) => new TraceTree(trace)
|
||||
);
|
||||
|
||||
public trackedNodes: Record<string, NodeInfo> = {};
|
||||
public get renderedNodes(): Record<string, NodeInfo> {
|
||||
return this._buildTree(this.trace).renderedNodes;
|
||||
}
|
||||
|
||||
private _selectNode(config, path, type?) {
|
||||
public get trackedNodes(): Record<string, NodeInfo> {
|
||||
return this._buildTree(this.trace).trackedNodes;
|
||||
}
|
||||
|
||||
private _selectNode(config: unknown, path: string, type?: NodeInfo["type"]) {
|
||||
return () => {
|
||||
fireEvent(this, "graph-node-selected", { config, path, type });
|
||||
};
|
||||
}
|
||||
|
||||
private _renderTrigger(config: Trigger, i: number) {
|
||||
const path = `trigger/${i}`;
|
||||
const tracked = this.trace && path in this.trace.trace;
|
||||
// A not-triggered trace records the trigger that evaluated a change but
|
||||
// decided not to fire. It is still selectable (to view the reason), but
|
||||
// must not be shown as the path that ran.
|
||||
const notTriggered = !!(tracked && this.trace.not_triggered);
|
||||
const track = tracked && !notTriggered;
|
||||
this.renderedNodes[path] = { config, path, type: "trigger" };
|
||||
if (tracked) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
private _renderTrigger(node: TraceNode<Trigger>) {
|
||||
const { config, path, track, hasTrace } = node;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
graph-start
|
||||
?track=${track}
|
||||
?not-triggered=${notTriggered}
|
||||
?not-triggered=${node.notTriggered}
|
||||
@focus=${this._selectNode(config, path, "trigger")}
|
||||
?active=${this.selected === path}
|
||||
.iconPath=${mdiAsterisk}
|
||||
.notEnabled=${"enabled" in config && config.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${tracked ? "0" : "-1"}
|
||||
.notEnabled=${node.disabled}
|
||||
.error=${node.error}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderCondition(config: Condition, i: number) {
|
||||
const path = `condition/${i}`;
|
||||
this.renderedNodes[path] = { config, path, type: "condition" };
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
return this._renderConditionNode(config, path);
|
||||
}
|
||||
|
||||
private _typeRenderers = {
|
||||
condition: this._renderConditionNode,
|
||||
and: this._renderConditionNode,
|
||||
or: this._renderConditionNode,
|
||||
not: this._renderConditionNode,
|
||||
service: this._renderServiceNode,
|
||||
wait_template: this._renderWaitNode,
|
||||
wait_for_trigger: this._renderWaitNode,
|
||||
@@ -137,240 +121,142 @@ export class HatScriptGraph extends LitElement {
|
||||
other: this._renderOtherNode,
|
||||
};
|
||||
|
||||
private _renderActionNode(
|
||||
node: Action,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
private _renderActionNode(node: TraceActionNode, graphStart = false) {
|
||||
// The modern `action:` key has no dedicated renderer. The old
|
||||
// `key in node` lookup fell through to the generic node for it, so keep
|
||||
// that here for visual parity. The generic node still picks the service
|
||||
// icon through the node's action type.
|
||||
const type =
|
||||
Object.keys(this._typeRenderers).find((key) => key in node) || "other";
|
||||
this.renderedNodes[path] = { config: node, path, type: "action" };
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
return this._typeRenderers[type].bind(this)(
|
||||
"action" in node.config ? "other" : (node.actionType ?? "other");
|
||||
return (this._typeRenderers[type] ?? this._renderOtherNode).bind(this)(
|
||||
node,
|
||||
path,
|
||||
graphStart,
|
||||
disabled
|
||||
graphStart
|
||||
);
|
||||
}
|
||||
|
||||
private _renderChooseNode(
|
||||
config: ChooseAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
node: TraceActionNode<ChooseAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined;
|
||||
const tracePath = trace
|
||||
? trace.map((trc) =>
|
||||
trc.result === undefined || trc.result.choice === "default"
|
||||
? "default"
|
||||
: trc.result.choice
|
||||
)
|
||||
: [];
|
||||
const trackDefault = tracePath.includes("default");
|
||||
const { config, path, track } = node;
|
||||
const defaultBranch = node.branches[node.branches.length - 1];
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${trace !== undefined}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
.notEnabled=${node.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiArrowDecision}
|
||||
?track=${trace !== undefined}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
.notEnabled=${node.disabled}
|
||||
.error=${node.error}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
|
||||
${
|
||||
config.choose
|
||||
? ensureArray(config.choose)?.map((branch, i) => {
|
||||
const branchPath = `${path}/choose/${i}`;
|
||||
const trackThis = tracePath.includes(i);
|
||||
this.renderedNodes[branchPath] = {
|
||||
config: branch,
|
||||
path: branchPath,
|
||||
type: "chooseOption",
|
||||
};
|
||||
if (trackThis) {
|
||||
this.trackedNodes[branchPath] =
|
||||
this.renderedNodes[branchPath];
|
||||
${node.branches.slice(0, -1).map(
|
||||
(branch) => html`
|
||||
<div class="graph-container" ?track=${branch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${
|
||||
!track || branch.hasTrace
|
||||
? mdiCheckboxMarkedOutline
|
||||
: mdiCheckboxBlankOutline
|
||||
}
|
||||
return html`
|
||||
<div class="graph-container" ?track=${trackThis}>
|
||||
<hat-graph-node
|
||||
.iconPath=${
|
||||
!trace || trackThis
|
||||
? mdiCheckboxMarkedOutline
|
||||
: mdiCheckboxBlankOutline
|
||||
}
|
||||
@focus=${this._selectNode(
|
||||
branch,
|
||||
branchPath,
|
||||
"chooseOption"
|
||||
)}
|
||||
?track=${trackThis}
|
||||
?active=${this.selected === branchPath}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
></hat-graph-node>
|
||||
${
|
||||
branch.sequence !== null
|
||||
? ensureArray<Action>(branch.sequence).map(
|
||||
(action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${branchPath}/sequence/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
)
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
: ""
|
||||
}
|
||||
<div ?track=${trackDefault}>
|
||||
<hat-graph-spacer ?track=${trackDefault}></hat-graph-spacer>
|
||||
${
|
||||
config.default !== null
|
||||
? ensureArray<Action | undefined>(config.default)?.map(
|
||||
(action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/default/${i}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
)
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIfNode(
|
||||
config: IfAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as IfActionTraceStep[] | undefined;
|
||||
let trackThen = false;
|
||||
let trackElse = false;
|
||||
for (const trc of trace || []) {
|
||||
if (!trackThen && trc.result?.choice === "then") {
|
||||
trackThen = true;
|
||||
}
|
||||
if ((!trackElse && trc.result?.choice === "else") || !trc.result) {
|
||||
trackElse = true;
|
||||
}
|
||||
if (trackElse && trackThen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${trace !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCallSplit}
|
||||
?track=${trace !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${
|
||||
config.else
|
||||
? html`<div class="graph-container" ?track=${trackElse}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallMissed}
|
||||
?track=${trackElse}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
nofocus
|
||||
></hat-graph-node
|
||||
>${ensureArray<Action>(config.else).map((action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/else/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
@focus=${this._selectNode(
|
||||
branch.option,
|
||||
branch.path,
|
||||
"chooseOption"
|
||||
)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer ?track=${trackElse}></hat-graph-spacer>`
|
||||
}
|
||||
<div class="graph-container" ?track=${trackThen}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${trackThen}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray<Action>(config.then ?? []).map((action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/then/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
?track=${branch.hasTrace}
|
||||
?active=${this.selected === branch.path}
|
||||
.notEnabled=${branch.disabled}
|
||||
></hat-graph-node>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div ?track=${defaultBranch.hasTrace}>
|
||||
<hat-graph-spacer ?track=${defaultBranch.hasTrace}></hat-graph-spacer>
|
||||
${defaultBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIfNode(node: TraceActionNode<IfAction>, graphStart = false) {
|
||||
const { config, path, track } = node;
|
||||
const [thenBranch, elseBranch] = node.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCallSplit}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${
|
||||
config.else
|
||||
? html`<div class="graph-container" ?track=${elseBranch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallMissed}
|
||||
?track=${elseBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${elseBranch.disabled}
|
||||
nofocus
|
||||
></hat-graph-node
|
||||
>${elseBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer
|
||||
?track=${elseBranch.hasTrace}
|
||||
></hat-graph-spacer>`
|
||||
}
|
||||
<div class="graph-container" ?track=${thenBranch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${thenBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${thenBranch.disabled}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${thenBranch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderConditionNode(
|
||||
node: Condition,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceNode<Condition>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined;
|
||||
let track = false;
|
||||
let trackPass = false;
|
||||
let trackFailed = false;
|
||||
if (trace) {
|
||||
for (const trc of trace) {
|
||||
if (trc.result) {
|
||||
track = true;
|
||||
if (trc.result.result) {
|
||||
trackPass = true;
|
||||
} else {
|
||||
trackFailed = true;
|
||||
}
|
||||
}
|
||||
if (trackPass && trackFailed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { config: node, path, track, hasTrace } = model;
|
||||
const passed = model.condition?.passed ?? false;
|
||||
const failed = model.condition?.failed ?? false;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
@focus=${this._selectNode(node, path, "condition")}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
.notEnabled=${model.disabled}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
short
|
||||
>
|
||||
<hat-graph-node
|
||||
@@ -378,7 +264,7 @@ export class HatScriptGraph extends LitElement {
|
||||
slot="head"
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
.iconPath=${mdiAbTesting}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
@@ -387,81 +273,71 @@ export class HatScriptGraph extends LitElement {
|
||||
graph-start
|
||||
graph-end
|
||||
></div>
|
||||
<div ?track=${trackPass}></div>
|
||||
<div ?track=${passed}></div>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiClose}
|
||||
nofocus
|
||||
?track=${trackFailed}
|
||||
?track=${failed}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
></hat-graph-node>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRepeatNode(
|
||||
node: RepeatAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<RepeatAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length;
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiRefresh}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiArrowUp}
|
||||
?track=${repeats > 1}
|
||||
?track=${model.badge !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
nofocus
|
||||
.badge=${repeats > 1 ? repeats : undefined}
|
||||
.badge=${model.badge}
|
||||
></hat-graph-node>
|
||||
<div ?track=${trace}>
|
||||
${ensureArray<Action>(node.repeat.sequence).map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/repeat/sequence/${i}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
<div ?track=${model.hasTrace}>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderServiceNode(
|
||||
node: ServiceAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<ServiceAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${node.action ? undefined : mdiRoomService}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
>
|
||||
${
|
||||
node.action
|
||||
@@ -476,145 +352,110 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
|
||||
private _renderWaitNode(
|
||||
node: WaitAction | WaitForTriggerAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<WaitAction | WaitForTriggerAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCodeBraces}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSequenceNode(
|
||||
node: SequenceAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<SequenceAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<div class="graph-container" ?track=${path in this.trace.trace}>
|
||||
<div class="graph-container" ?track=${branch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiFormatListNumbered}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray(node.sequence ?? []).map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/sequence/${i}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderParallelNode(
|
||||
node: ParallelAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<ParallelAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiShuffleDisabled}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray<Action>(node.parallel).map((action, i) =>
|
||||
"sequence" in action
|
||||
? html`<div ?track=${path in this.trace.trace}>
|
||||
${ensureArray<Action>(
|
||||
(action as ManualScriptConfig).sequence
|
||||
).map((sAction, j) =>
|
||||
this._renderActionNode(
|
||||
sAction,
|
||||
`${path}/parallel/${i}/sequence/${j}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
: this._renderActionNode(
|
||||
action,
|
||||
`${path}/parallel/${i}/sequence/0`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
${model.branches.map(
|
||||
(branch) =>
|
||||
html`<div ?track=${branch.hasTrace}>
|
||||
${branch.children.map((sAction) =>
|
||||
this._renderActionNode(sAction)
|
||||
)}
|
||||
</div>`
|
||||
)}
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOtherNode(
|
||||
node: Action,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
private _renderOtherNode(model: TraceActionNode, graphStart = false) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${ACTION_ICONS[getActionType(node)] || mdiCodeBrackets}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${model.error}
|
||||
.notEnabled=${model.disabled}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const triggerKey = "triggers" in this.trace.config ? "triggers" : "trigger";
|
||||
const conditionKey =
|
||||
"conditions" in this.trace.config ? "conditions" : "condition";
|
||||
const actionKey = "actions" in this.trace.config ? "actions" : "action";
|
||||
|
||||
const paths = Object.keys(this.trackedNodes);
|
||||
const triggerNodes =
|
||||
triggerKey in this.trace.config
|
||||
? flattenTriggers(ensureArray(this.trace.config[triggerKey])).map(
|
||||
(trigger, i) => this._renderTrigger(trigger, i)
|
||||
)
|
||||
: undefined;
|
||||
try {
|
||||
const tree = this._buildTree(this.trace);
|
||||
const paths = tree.trackedPaths;
|
||||
const triggerNodes = tree.triggers?.map((node) =>
|
||||
this._renderTrigger(node)
|
||||
);
|
||||
return html`
|
||||
<div class="graph-scroll ha-scrollbar">
|
||||
<div class="parent graph-container">
|
||||
@@ -628,37 +469,26 @@ export class HatScriptGraph extends LitElement {
|
||||
</hat-graph-branch>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
conditionKey in this.trace.config
|
||||
? html`${ensureArray(this.trace.config[conditionKey])?.map(
|
||||
(condition, i) => this._renderCondition(condition, i)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
actionKey in this.trace.config
|
||||
? html`${ensureArray(this.trace.config[actionKey]).map(
|
||||
(action, i) => this._renderActionNode(action, `action/${i}`)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"sequence" in this.trace.config
|
||||
? html`${ensureArray<Action>(this.trace.config.sequence).map(
|
||||
(action, i) =>
|
||||
this._renderActionNode(action, `sequence/${i}`, i === 0)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${tree.conditions.map((node) => this._renderConditionNode(node))}
|
||||
${tree.actions.map((node) => this._renderActionNode(node))}
|
||||
${tree.sequence.map((node, i) =>
|
||||
this._renderActionNode(node, i === 0)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<ha-icon-button
|
||||
label=${this._localize(
|
||||
"ui.panel.config.automation.trace.previous_tracked_node"
|
||||
)}
|
||||
.disabled=${paths.length === 0 || paths[0] === this.selected}
|
||||
@click=${this._previousTrackedNode}
|
||||
.path=${mdiChevronUp}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
label=${this._localize(
|
||||
"ui.panel.config.automation.trace.next_tracked_node"
|
||||
)}
|
||||
.disabled=${
|
||||
paths.length === 0 || paths[paths.length - 1] === this.selected
|
||||
}
|
||||
@@ -681,14 +511,6 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("trace")) {
|
||||
this.renderedNodes = {};
|
||||
this.trackedNodes = {};
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
@@ -709,52 +531,26 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
|
||||
// If trace changed and we have no or an invalid selection, select first option.
|
||||
if (!this.selected || !(this.selected in this.trackedNodes)) {
|
||||
const firstNode = this.trackedNodes[Object.keys(this.trackedNodes)[0]];
|
||||
const tree = this._buildTree(this.trace);
|
||||
if (!this.selected || !tree.trackedNodes[this.selected]) {
|
||||
const firstNode = tree.firstTracked;
|
||||
if (firstNode) {
|
||||
fireEvent(this, "graph-node-selected", firstNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.trace) {
|
||||
const sortKeys = Object.keys(this.trace.trace);
|
||||
const keys = Object.keys(this.renderedNodes).sort(
|
||||
(a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b)
|
||||
);
|
||||
const sortedTrackedNodes = {};
|
||||
const sortedRenderedNodes = {};
|
||||
for (const key of keys) {
|
||||
sortedRenderedNodes[key] = this.renderedNodes[key];
|
||||
if (key in this.trackedNodes) {
|
||||
sortedTrackedNodes[key] = this.trackedNodes[key];
|
||||
}
|
||||
}
|
||||
this.renderedNodes = sortedRenderedNodes;
|
||||
this.trackedNodes = sortedTrackedNodes;
|
||||
}
|
||||
}
|
||||
|
||||
private _previousTrackedNode() {
|
||||
const nodes = Object.keys(this.trackedNodes);
|
||||
const prevIndex = nodes.indexOf(this.selected!) - 1;
|
||||
if (prevIndex >= 0) {
|
||||
fireEvent(
|
||||
this,
|
||||
"graph-node-selected",
|
||||
this.trackedNodes[nodes[prevIndex]]
|
||||
);
|
||||
const prev = this._buildTree(this.trace).previousTracked(this.selected!);
|
||||
if (prev) {
|
||||
fireEvent(this, "graph-node-selected", prev);
|
||||
}
|
||||
}
|
||||
|
||||
private _nextTrackedNode() {
|
||||
const nodes = Object.keys(this.trackedNodes);
|
||||
const nextIndex = nodes.indexOf(this.selected!) + 1;
|
||||
if (nextIndex < nodes.length) {
|
||||
fireEvent(
|
||||
this,
|
||||
"graph-node-selected",
|
||||
this.trackedNodes[nodes[nextIndex]]
|
||||
);
|
||||
const next = this._buildTree(this.trace).nextTracked(this.selected!);
|
||||
if (next) {
|
||||
fireEvent(this, "graph-node-selected", next);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
mdiTrafficLight,
|
||||
} from "@mdi/js";
|
||||
import type { AutomationElementGroupCollection } from "./automation";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "./condition";
|
||||
import type { Action } from "./script";
|
||||
import { getActionType } from "./script";
|
||||
|
||||
export const ACTION_ICONS = {
|
||||
condition: mdiAbTesting,
|
||||
@@ -42,6 +44,24 @@ export const ACTION_ICONS = {
|
||||
set_conversation_response: mdiBullhorn,
|
||||
} as const;
|
||||
|
||||
// Plain function on purpose: the trace tree calls this once per action node
|
||||
// with a distinct object each time, so a single-entry memoize-one cache
|
||||
// would never hit.
|
||||
export const getAutomationActionType = (action: Action | undefined) => {
|
||||
if (!action) {
|
||||
return undefined;
|
||||
}
|
||||
if ("action" in action) {
|
||||
return getActionType(action);
|
||||
}
|
||||
if (CONDITION_BUILDING_BLOCKS.some((key) => key in action)) {
|
||||
return "condition" as const;
|
||||
}
|
||||
return Object.keys(ACTION_ICONS).find(
|
||||
(option) => option in action
|
||||
) as keyof typeof ACTION_ICONS;
|
||||
};
|
||||
|
||||
export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
|
||||
"variables",
|
||||
]);
|
||||
|
||||
+2
-2
@@ -94,14 +94,14 @@ export interface HistoryStreamMessage {
|
||||
}
|
||||
|
||||
export const entityIdHistoryNeedsAttributes = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states">,
|
||||
entityId: string
|
||||
) =>
|
||||
!hass.states[entityId] ||
|
||||
NEED_ATTRIBUTE_DOMAINS.includes(computeDomain(entityId));
|
||||
|
||||
export const fetchDateWS = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states" | "callWS">,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
entityIds: string[]
|
||||
|
||||
@@ -368,6 +368,10 @@ export interface LocationSelector {
|
||||
radius?: boolean;
|
||||
radius_readonly?: boolean;
|
||||
icon?: string;
|
||||
/** Name whose initials the marker shows when there is no icon */
|
||||
name?: string;
|
||||
/** Marker and radius color; defaults to the theme's zone color */
|
||||
color?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { getAutomationActionType } from "./action";
|
||||
import { flattenTriggers } from "./automation";
|
||||
import type { Condition, Trigger } from "./automation";
|
||||
import type {
|
||||
Action,
|
||||
ChooseAction,
|
||||
IfAction,
|
||||
Option,
|
||||
ParallelAction,
|
||||
RepeatAction,
|
||||
SequenceAction,
|
||||
} from "./script";
|
||||
import type {
|
||||
ActionTraceStep,
|
||||
ChooseActionTraceStep,
|
||||
ConditionTraceStep,
|
||||
DelayActionTraceStep,
|
||||
IfActionTraceStep,
|
||||
TraceExtended,
|
||||
WaitActionTraceStep,
|
||||
} from "./trace";
|
||||
|
||||
export type TraceNodeType = "trigger" | "condition" | "action" | "chooseOption";
|
||||
|
||||
export type TraceActionNodeType =
|
||||
Exclude<ReturnType<typeof getAutomationActionType>, undefined> | "other";
|
||||
|
||||
export interface NodeInfo {
|
||||
path: string;
|
||||
config: unknown;
|
||||
type?: TraceNodeType;
|
||||
}
|
||||
|
||||
export interface TraceNode<T = unknown> {
|
||||
path: string;
|
||||
config: T;
|
||||
type: TraceNodeType;
|
||||
/** Data fact: the path (or branch) was tracked by Core. */
|
||||
hasTrace: boolean;
|
||||
/** Visual execution state (triggers mask not-triggered, conditions use outcome). */
|
||||
track: boolean;
|
||||
error: boolean;
|
||||
/** Own `enabled === false` or inherited from an ancestor action. */
|
||||
disabled: boolean;
|
||||
notTriggered?: boolean;
|
||||
condition?: { executed: boolean; passed: boolean; failed: boolean };
|
||||
}
|
||||
|
||||
export interface TraceActionNode<
|
||||
T extends Action = Action,
|
||||
> extends TraceNode<T> {
|
||||
actionType: TraceActionNodeType;
|
||||
branches: TraceBranch[];
|
||||
iterations?: number;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
export interface TraceBranch {
|
||||
path: string;
|
||||
children: TraceActionNode[];
|
||||
hasTrace: boolean;
|
||||
finished: boolean;
|
||||
unfinished: boolean;
|
||||
disabled: boolean;
|
||||
option?: Option;
|
||||
}
|
||||
|
||||
const isDisabled = (config: unknown, parentDisabled: boolean): boolean =>
|
||||
parentDisabled ||
|
||||
(typeof config === "object" &&
|
||||
config !== null &&
|
||||
"enabled" in config &&
|
||||
(config as { enabled?: boolean }).enabled === false);
|
||||
|
||||
/** Configuration-shaped tree annotated with the execution tracked by Core. */
|
||||
export class TraceTree {
|
||||
public readonly triggers?: TraceNode<Trigger>[];
|
||||
|
||||
public readonly conditions: TraceNode<Condition>[];
|
||||
|
||||
public readonly actions: TraceActionNode[];
|
||||
|
||||
public readonly sequence: TraceActionNode[];
|
||||
|
||||
/** All selectable nodes in render order, keyed by path. */
|
||||
public readonly renderedNodes: Record<string, NodeInfo> = {};
|
||||
|
||||
/**
|
||||
* Selectable nodes that were tracked, sorted in trace order. Includes
|
||||
* not-triggered triggers so they remain navigable, even though their
|
||||
* `track` is false and they do not render as part of the executed path.
|
||||
*/
|
||||
public readonly trackedNodes: Record<string, NodeInfo> = {};
|
||||
|
||||
public readonly trackedPaths: string[] = [];
|
||||
|
||||
constructor(public readonly trace: TraceExtended) {
|
||||
const config = trace.config;
|
||||
const triggerKey = "triggers" in config ? "triggers" : "trigger";
|
||||
const conditionKey = "conditions" in config ? "conditions" : "condition";
|
||||
const actionKey = "actions" in config ? "actions" : "action";
|
||||
this.triggers =
|
||||
triggerKey in config
|
||||
? flattenTriggers(ensureArray(config[triggerKey])).map((trigger, i) =>
|
||||
this._triggerNode(trigger, `trigger/${i}`)
|
||||
)
|
||||
: undefined;
|
||||
this.conditions =
|
||||
conditionKey in config
|
||||
? ensureArray<Condition>(config[conditionKey] ?? []).map(
|
||||
(condition, i) =>
|
||||
this._conditionNode(
|
||||
condition,
|
||||
`condition/${i}`,
|
||||
"condition",
|
||||
false
|
||||
)
|
||||
)
|
||||
: [];
|
||||
this.actions =
|
||||
actionKey in config
|
||||
? this._actions(
|
||||
ensureArray<Action>(config[actionKey]),
|
||||
"action/",
|
||||
false
|
||||
)
|
||||
: [];
|
||||
this.sequence =
|
||||
"sequence" in config
|
||||
? this._actions(
|
||||
ensureArray<Action>(config.sequence),
|
||||
"sequence/",
|
||||
false
|
||||
)
|
||||
: [];
|
||||
this._indexNodes();
|
||||
}
|
||||
|
||||
public get firstTracked(): NodeInfo | undefined {
|
||||
return this.trackedNodes[this.trackedPaths[0]];
|
||||
}
|
||||
|
||||
public getNode(path: string): NodeInfo | undefined {
|
||||
return this.renderedNodes[path];
|
||||
}
|
||||
|
||||
public previousTracked(path: string): NodeInfo | undefined {
|
||||
// An unknown path yields index -2 and returns undefined, matching the
|
||||
// previous graph behavior.
|
||||
const index = this.trackedPaths.indexOf(path) - 1;
|
||||
return index >= 0 ? this.trackedNodes[this.trackedPaths[index]] : undefined;
|
||||
}
|
||||
|
||||
public nextTracked(path: string): NodeInfo | undefined {
|
||||
// An unknown path yields index 0 and restarts from the first node,
|
||||
// matching the previous graph behavior.
|
||||
const index = this.trackedPaths.indexOf(path) + 1;
|
||||
return index < this.trackedPaths.length
|
||||
? this.trackedNodes[this.trackedPaths[index]]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _base<T>(
|
||||
config: T,
|
||||
path: string,
|
||||
type: TraceNodeType,
|
||||
parentDisabled: boolean
|
||||
): TraceNode<T> {
|
||||
const hasTrace = path in this.trace.trace;
|
||||
return {
|
||||
config,
|
||||
path,
|
||||
type,
|
||||
hasTrace,
|
||||
track: hasTrace,
|
||||
error: this.trace.trace[path]?.some((record) => record.error) ?? false,
|
||||
disabled: isDisabled(config, parentDisabled),
|
||||
};
|
||||
}
|
||||
|
||||
private _triggerNode(config: Trigger, path: string): TraceNode<Trigger> {
|
||||
const node = this._base(config, path, "trigger", false);
|
||||
// A not-triggered trace records the trigger that evaluated a change but
|
||||
// decided not to fire. It is still selectable (to view the reason), but
|
||||
// must not be shown as the path that ran.
|
||||
node.notTriggered = node.hasTrace && !!this.trace.not_triggered;
|
||||
node.track = node.hasTrace && !node.notTriggered;
|
||||
return node;
|
||||
}
|
||||
|
||||
private _conditionNode<T>(
|
||||
config: T,
|
||||
path: string,
|
||||
type: TraceNodeType,
|
||||
parentDisabled: boolean
|
||||
): TraceNode<T> {
|
||||
const node = this._base(config, path, type, parentDisabled);
|
||||
const records = this.trace.trace[path] as ConditionTraceStep[] | undefined;
|
||||
const executed = !!records?.some((record) => record.result || record.error);
|
||||
node.condition = {
|
||||
executed,
|
||||
passed: !!records?.some((record) => record.result?.result),
|
||||
failed: !!records?.some(
|
||||
(record) => record.result && !record.result.result
|
||||
),
|
||||
};
|
||||
node.track = executed;
|
||||
return node;
|
||||
}
|
||||
|
||||
private _actions(
|
||||
actions: Action[],
|
||||
prefix: string,
|
||||
parentDisabled: boolean
|
||||
): TraceActionNode[] {
|
||||
return actions.map((action, i) =>
|
||||
this._actionNode(action, `${prefix}${i}`, parentDisabled)
|
||||
);
|
||||
}
|
||||
|
||||
private _branch(
|
||||
path: string,
|
||||
prefix: string,
|
||||
steps: Action[],
|
||||
parentDisabled: boolean,
|
||||
hasTrace = this._hasTracedSteps(prefix)
|
||||
): TraceBranch {
|
||||
const children = this._actions(steps, prefix, parentDisabled);
|
||||
const finished = hasTrace ? this._branchFinished(prefix, steps) : false;
|
||||
return {
|
||||
path,
|
||||
children,
|
||||
hasTrace,
|
||||
finished,
|
||||
unfinished: hasTrace && !finished,
|
||||
disabled: parentDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an action subtree, retaining the original config for selection. */
|
||||
private _actionNode<T extends Action>(
|
||||
config: T,
|
||||
path: string,
|
||||
parentDisabled = false
|
||||
): TraceActionNode<T> {
|
||||
const actionType = getAutomationActionType(config) ?? "other";
|
||||
const node: TraceActionNode<T> = {
|
||||
...(actionType === "condition"
|
||||
? this._conditionNode(config, path, "action", parentDisabled)
|
||||
: this._base(config, path, "action", parentDisabled)),
|
||||
actionType,
|
||||
branches: [],
|
||||
};
|
||||
const disabled = node.disabled;
|
||||
switch (actionType) {
|
||||
case "choose": {
|
||||
const choose = config as ChooseAction;
|
||||
const records = this.trace.trace[path] as
|
||||
ChooseActionTraceStep[] | undefined;
|
||||
const choices =
|
||||
records?.map((record) =>
|
||||
record.result?.choice === "default" ||
|
||||
(!record.result && !record.error)
|
||||
? "default"
|
||||
: record.result?.choice
|
||||
) ?? [];
|
||||
node.branches = ensureArray<Option>(choose.choose ?? []).map(
|
||||
(option, i) => {
|
||||
const branchPath = `${path}/choose/${i}`;
|
||||
const prefix = `${branchPath}/sequence/`;
|
||||
return {
|
||||
...this._branch(
|
||||
branchPath,
|
||||
prefix,
|
||||
ensureArray<Action>(option.sequence ?? []),
|
||||
disabled,
|
||||
choices.includes(i) || this._hasTracedSteps(prefix)
|
||||
),
|
||||
option,
|
||||
};
|
||||
}
|
||||
);
|
||||
const prefix = `${path}/default/`;
|
||||
node.branches.push(
|
||||
this._branch(
|
||||
`${path}/default`,
|
||||
prefix,
|
||||
ensureArray<Action>(choose.default ?? []),
|
||||
disabled,
|
||||
choices.includes("default") || this._hasTracedSteps(prefix)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "if": {
|
||||
const ifAction = config as IfAction;
|
||||
const records = this.trace.trace[path] as
|
||||
IfActionTraceStep[] | undefined;
|
||||
node.branches = (["then", "else"] as const).map((choice) => {
|
||||
const prefix = `${path}/${choice}/`;
|
||||
// Core sets no result for the implicit else bypass. An error instead
|
||||
// means execution aborted before choosing a branch.
|
||||
const hasTrace =
|
||||
!!records?.some(
|
||||
(record) =>
|
||||
record.result?.choice === choice ||
|
||||
(choice === "else" && !record.result && !record.error)
|
||||
) || this._hasTracedSteps(prefix);
|
||||
return this._branch(
|
||||
`${path}/${choice}`,
|
||||
prefix,
|
||||
ensureArray<Action>(ifAction[choice] ?? []),
|
||||
disabled,
|
||||
hasTrace
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "repeat": {
|
||||
const repeat = config as RepeatAction;
|
||||
const prefix = `${path}/repeat/sequence/`;
|
||||
const iterations = this.trace.trace[`${prefix}0`];
|
||||
// Core's repeat.index is 1-based (1, 2, 3, …), so the last stored
|
||||
// index equals the completed count. Fall back to the stored record
|
||||
// count when the variable is absent, as iterationNumber does in
|
||||
// ha-trace-path-details.
|
||||
node.iterations =
|
||||
(
|
||||
iterations?.[iterations.length - 1]?.changed_variables?.repeat as
|
||||
{ index?: number } | undefined
|
||||
)?.index ?? iterations?.length;
|
||||
node.badge =
|
||||
node.iterations !== undefined && node.iterations > 1
|
||||
? node.iterations
|
||||
: undefined;
|
||||
node.branches = [
|
||||
this._branch(
|
||||
`${path}/repeat`,
|
||||
prefix,
|
||||
ensureArray<Action>(repeat.repeat.sequence),
|
||||
disabled
|
||||
),
|
||||
];
|
||||
break;
|
||||
}
|
||||
case "sequence":
|
||||
node.branches = [
|
||||
this._branch(
|
||||
`${path}/sequence`,
|
||||
`${path}/sequence/`,
|
||||
ensureArray<Action>((config as SequenceAction).sequence ?? []),
|
||||
disabled,
|
||||
node.hasTrace
|
||||
),
|
||||
];
|
||||
break;
|
||||
case "parallel":
|
||||
node.branches = ensureArray<Action>(
|
||||
(config as ParallelAction).parallel
|
||||
).map((branch, i) => {
|
||||
const branchPath = `${path}/parallel/${i}`;
|
||||
const steps = ensureArray<Action>(
|
||||
"sequence" in branch
|
||||
? ((branch as SequenceAction).sequence ?? [])
|
||||
: branch
|
||||
);
|
||||
const prefix = `${branchPath}/sequence/`;
|
||||
return this._branch(
|
||||
branchPath,
|
||||
prefix,
|
||||
steps,
|
||||
disabled,
|
||||
// An empty branch has no step path for Core to record. It ran
|
||||
// when the parent parallel action ran, matching the old graph
|
||||
// which tracked the branch wrapper from the parent path.
|
||||
steps.length === 0 ? node.hasTrace : this._hasTracedSteps(prefix)
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private _indexNodes() {
|
||||
const ordered: NodeInfo[] = [];
|
||||
|
||||
const visitAction = (node: TraceActionNode) => {
|
||||
ordered.push({
|
||||
path: node.path,
|
||||
config: node.config,
|
||||
type: "action",
|
||||
});
|
||||
if (node.actionType === "choose") {
|
||||
for (const branch of node.branches.slice(0, -1)) {
|
||||
ordered.push({
|
||||
path: branch.path,
|
||||
config: branch.option,
|
||||
type: "chooseOption",
|
||||
});
|
||||
branch.children.forEach(visitAction);
|
||||
}
|
||||
node.branches[node.branches.length - 1]?.children.forEach(visitAction);
|
||||
} else {
|
||||
node.branches.forEach((branch) => branch.children.forEach(visitAction));
|
||||
}
|
||||
};
|
||||
|
||||
this.triggers?.forEach((node) =>
|
||||
ordered.push({ path: node.path, config: node.config, type: "trigger" })
|
||||
);
|
||||
this.conditions.forEach((node) =>
|
||||
ordered.push({ path: node.path, config: node.config, type: "condition" })
|
||||
);
|
||||
this.actions.forEach(visitAction);
|
||||
this.sequence.forEach(visitAction);
|
||||
|
||||
// Preserve the previous render-then-sort order: untracked paths keep
|
||||
// relative render order ahead of tracked ones. Untracked paths get -1
|
||||
// so they sort before tracked paths, and Array.prototype.sort is stable
|
||||
// so their relative render order survives.
|
||||
const traceOrder = new Map<string, number>();
|
||||
let traceIndex = 0;
|
||||
for (const path of Object.keys(this.trace.trace)) {
|
||||
traceOrder.set(path, traceIndex++);
|
||||
}
|
||||
const trackedByPath = new Map<string, boolean>();
|
||||
const collectTracked = (nodes: TraceActionNode[]) => {
|
||||
for (const node of nodes) {
|
||||
trackedByPath.set(node.path, node.hasTrace);
|
||||
for (const branch of node.branches) {
|
||||
if (node.actionType === "choose" && branch.option !== undefined) {
|
||||
trackedByPath.set(branch.path, branch.hasTrace);
|
||||
}
|
||||
collectTracked(branch.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.triggers?.forEach((node) =>
|
||||
trackedByPath.set(node.path, node.hasTrace)
|
||||
);
|
||||
this.conditions.forEach((node) =>
|
||||
trackedByPath.set(node.path, node.hasTrace)
|
||||
);
|
||||
collectTracked(this.actions);
|
||||
collectTracked(this.sequence);
|
||||
|
||||
const sorted = ordered.sort(
|
||||
(a, b) => (traceOrder.get(a.path) ?? -1) - (traceOrder.get(b.path) ?? -1)
|
||||
);
|
||||
for (const info of sorted) {
|
||||
this.renderedNodes[info.path] = info;
|
||||
if (trackedByPath.get(info.path)) {
|
||||
this.trackedNodes[info.path] = info;
|
||||
this.trackedPaths.push(info.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A branch can be missing the result that names it (parallel runs can drop
|
||||
// it), so fall back to whether any of its steps were traced.
|
||||
private _hasTracedSteps(pathPrefix: string) {
|
||||
return Object.keys(this.trace.trace).some((path) =>
|
||||
path.startsWith(pathPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
// Reaching the last step does not mean it completed or allowed continuation.
|
||||
// The prefix includes the trailing slash, e.g. "sequence/0/then/".
|
||||
private _branchFinished(pathPrefix: string, steps: Action[]) {
|
||||
if (steps.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastPath = `${pathPrefix}${steps.length - 1}`;
|
||||
const lastTrace = this.trace.trace[lastPath];
|
||||
if (!lastTrace?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const finished = this._actionFinished(steps[steps.length - 1], lastTrace);
|
||||
if (finished !== undefined) {
|
||||
return finished;
|
||||
}
|
||||
|
||||
// A non-error stop propagates through building blocks without adding a
|
||||
// parent error. Inspect descendants, since last_step may name a sibling.
|
||||
const lastTimestamp = lastTrace[lastTrace.length - 1].timestamp;
|
||||
if (
|
||||
Object.entries(this.trace.trace).some(
|
||||
([path, records]) =>
|
||||
path.startsWith(`${lastPath}/`) &&
|
||||
records.some(
|
||||
(record) =>
|
||||
record.timestamp >= lastTimestamp &&
|
||||
"result" in record &&
|
||||
record.result &&
|
||||
"stop" in record.result &&
|
||||
!record.result.error
|
||||
)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Core awaits all parallel branches before propagating an error. Once
|
||||
// stopped normally, a branch without a local failure completed even if
|
||||
// a sibling failed. External cancellation may still interrupt it.
|
||||
return (
|
||||
(this.trace.state === "stopped" &&
|
||||
["finished", "aborted", "error"].includes(
|
||||
this.trace.script_execution
|
||||
)) ||
|
||||
this._hasContinuedAfter(lastPath, lastTimestamp)
|
||||
);
|
||||
}
|
||||
|
||||
// Undefined means the action has no explicit completion result; the caller
|
||||
// must check run state or subsequent execution instead.
|
||||
private _actionFinished(
|
||||
action: Action,
|
||||
trace: ActionTraceStep[]
|
||||
): boolean | undefined {
|
||||
if (
|
||||
trace.some((tr) => tr.error) &&
|
||||
!("continue_on_error" in action && action.continue_on_error)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastRecord = trace[trace.length - 1];
|
||||
// Disabled steps are skipped by Core and recorded generically with
|
||||
// `result.enabled === false`, regardless of action type.
|
||||
if (
|
||||
(lastRecord as { result?: { enabled?: boolean } }).result?.enabled ===
|
||||
false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("stop" in action) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
getAutomationActionType(action) === "condition" &&
|
||||
(trace as ConditionTraceStep[]).some((tr) => tr.result?.result === false)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ("wait_template" in action || "wait_for_trigger" in action) {
|
||||
if (
|
||||
(trace as WaitActionTraceStep[]).some(
|
||||
({ result }) =>
|
||||
(result?.timeout && action.continue_on_timeout === false) ||
|
||||
(result?.wait?.completed === false &&
|
||||
(action.continue_on_timeout === false ||
|
||||
result.wait.remaining !== 0))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = (lastRecord as WaitActionTraceStep).result;
|
||||
if (result?.wait || result?.enabled === false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ("delay" in action) {
|
||||
const result = (lastRecord as DelayActionTraceStep).result;
|
||||
if (result && "done" in result) {
|
||||
return result.done;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _hasContinuedAfter(path: string, timestamp: string) {
|
||||
// A newer parallel sibling is not evidence of completion. Look for a
|
||||
// subsequent action in this sequence or after an ancestor's rejoin.
|
||||
const parts = path.split("/");
|
||||
for (let index = parts.length - 1; index > 0; index--) {
|
||||
if (
|
||||
!["action", "sequence", "then", "else", "default"].includes(
|
||||
parts[index - 1]
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const nextPath = `${parts.slice(0, index).join("/")}/${Number(parts[index]) + 1}`;
|
||||
const nextTrace = this.trace.trace[nextPath];
|
||||
const nextRecord = nextTrace?.[nextTrace.length - 1];
|
||||
// Repeats may retain continuation records from an earlier iteration.
|
||||
if (nextRecord && nextRecord.timestamp > timestamp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,22 @@ export interface StopActionTraceStep extends BaseTraceStep {
|
||||
result?: { stop: string; error: boolean };
|
||||
}
|
||||
|
||||
export interface WaitActionTraceStep extends BaseTraceStep {
|
||||
result?: {
|
||||
enabled?: boolean;
|
||||
wait?: {
|
||||
completed: boolean;
|
||||
remaining: number | null;
|
||||
trigger?: Record<string, unknown> | null;
|
||||
};
|
||||
timeout?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DelayActionTraceStep extends BaseTraceStep {
|
||||
result?: { delay: number; done: boolean };
|
||||
}
|
||||
|
||||
export interface ChooseChoiceActionTraceStep extends BaseTraceStep {
|
||||
result?: { result: boolean };
|
||||
}
|
||||
@@ -70,6 +86,10 @@ export type ActionTraceStep =
|
||||
| ConditionTraceStep
|
||||
| CallServiceActionTraceStep
|
||||
| ChooseActionTraceStep
|
||||
| IfActionTraceStep
|
||||
| StopActionTraceStep
|
||||
| WaitActionTraceStep
|
||||
| DelayActionTraceStep
|
||||
| ChooseChoiceActionTraceStep;
|
||||
|
||||
interface BaseTrace {
|
||||
|
||||
@@ -567,6 +567,7 @@ class MoreInfoWeather extends LitElement {
|
||||
.attribution {
|
||||
text-align: center;
|
||||
margin-top: var(--ha-space-4);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.time-ago,
|
||||
@@ -642,6 +643,7 @@ class MoreInfoWeather extends LitElement {
|
||||
.attribute {
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: 1;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.name-state {
|
||||
|
||||
@@ -877,19 +877,32 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
.narrow-header-row {
|
||||
--header-row-inset-start: var(--safe-area-inset-left, 0px);
|
||||
--header-row-inset-end: var(--safe-area-inset-right, 0px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 100%;
|
||||
gap: var(--ha-space-4);
|
||||
padding: 0 16px;
|
||||
padding: 0;
|
||||
padding-inline-start: calc(16px + var(--header-row-inset-start));
|
||||
box-sizing: border-box;
|
||||
overflow-x: scroll;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.narrow-header-row:dir(rtl) {
|
||||
--header-row-inset-start: var(--safe-area-inset-right, 0px);
|
||||
--header-row-inset-end: var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.narrow-header-row::after {
|
||||
content: "";
|
||||
flex: 0 0 var(--header-row-inset-end);
|
||||
}
|
||||
|
||||
.narrow-header-row .flex {
|
||||
flex: 1;
|
||||
margin-left: -16px;
|
||||
margin-inline-start: -16px;
|
||||
}
|
||||
|
||||
.selection-bar {
|
||||
|
||||
@@ -352,7 +352,9 @@ export class HassTabsSubpage extends LitElement {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 0 16px;
|
||||
padding: 0 calc(16px + var(--safe-area-inset-right))
|
||||
var(--safe-area-inset-bottom)
|
||||
calc(16px + var(--safe-area-inset-left));
|
||||
box-sizing: border-box;
|
||||
background-color: var(--sidebar-background-color);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
@@ -360,7 +362,6 @@ export class HassTabsSubpage extends LitElement {
|
||||
z-index: 2;
|
||||
font-size: var(--ha-font-size-s);
|
||||
width: 100%;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
#tabbar:not(.bottom-bar) {
|
||||
@@ -396,14 +397,13 @@ export class HassTabsSubpage extends LitElement {
|
||||
.content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
margin-inline-end: var(--safe-area-inset-right);
|
||||
box-sizing: border-box;
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
:host([narrow]) .content {
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-inline-start: var(--safe-area-inset-left);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
:host([narrow][show-tabs]) .content {
|
||||
/* Bottom bar reuses header height */
|
||||
|
||||
@@ -300,7 +300,16 @@ class NotificationManager extends LitElement {
|
||||
border: none;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
transform: translateX(calc(-50% * var(--scale-direction)));
|
||||
transform: translateX(
|
||||
calc(
|
||||
-50% * var(--scale-direction) +
|
||||
var(--safe-area-offset-left, 0px) - var(
|
||||
--safe-area-offset-right,
|
||||
0px
|
||||
)
|
||||
)
|
||||
);
|
||||
max-width: var(--safe-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
} from "../../../../data/automation";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
|
||||
import {
|
||||
COLLAPSIBLE_ACTION_ELEMENTS,
|
||||
getAutomationActionType,
|
||||
} from "../../../../data/action";
|
||||
import { migrateAutomationAction, type Action } from "../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { editorStyles, indentStyle } from "../styles";
|
||||
import {
|
||||
getAutomationActionType,
|
||||
type ActionElement,
|
||||
} from "./ha-automation-action-row";
|
||||
import type { ActionElement } from "./ha-automation-action-row";
|
||||
|
||||
@customElement("ha-automation-action-editor")
|
||||
export default class HaAutomationActionEditor extends LitElement {
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
ACTION_COMBINED_BLOCKS,
|
||||
ACTION_ICONS,
|
||||
YAML_ONLY_ACTION_TYPES,
|
||||
getAutomationActionType,
|
||||
} from "../../../../data/action";
|
||||
import type {
|
||||
ActionSidebarConfig,
|
||||
@@ -81,7 +82,7 @@ import type {
|
||||
RepeatAction,
|
||||
ServiceAction,
|
||||
} from "../../../../data/script";
|
||||
import { getActionType, isAction } from "../../../../data/script";
|
||||
import { isAction } from "../../../../data/script";
|
||||
import { describeAction } from "../../../../data/script_i18n";
|
||||
import type { TargetSelector } from "../../../../data/selector";
|
||||
import { callExecuteScript } from "../../../../data/service";
|
||||
@@ -115,23 +116,6 @@ import "./types/ha-automation-action-stop";
|
||||
import "./types/ha-automation-action-wait_for_trigger";
|
||||
import "./types/ha-automation-action-wait_template";
|
||||
|
||||
export const getAutomationActionType = memoizeOne(
|
||||
(action: Action | undefined) => {
|
||||
if (!action) {
|
||||
return undefined;
|
||||
}
|
||||
if ("action" in action) {
|
||||
return getActionType(action) as "action";
|
||||
}
|
||||
if (CONDITION_BUILDING_BLOCKS.some((key) => key in action)) {
|
||||
return "condition" as const;
|
||||
}
|
||||
return Object.keys(ACTION_ICONS).find(
|
||||
(option) => option in action
|
||||
) as keyof typeof ACTION_ICONS;
|
||||
}
|
||||
);
|
||||
|
||||
export interface ActionElement extends LitElement {
|
||||
action: Action;
|
||||
expandAll?: () => void;
|
||||
|
||||
@@ -13,6 +13,7 @@ import "../../../../components/ha-svg-icon";
|
||||
import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
VIRTUAL_ACTIONS,
|
||||
getAutomationActionType,
|
||||
} from "../../../../data/action";
|
||||
import { getValueFromDynamic, isDynamic } from "../../../../data/automation";
|
||||
import type { Action } from "../../../../data/script";
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
|
||||
import { automationRowsStyles } from "../styles";
|
||||
import type HaAutomationActionRow from "./ha-automation-action-row";
|
||||
import { getAutomationActionType } from "./ha-automation-action-row";
|
||||
import "./ha-automation-action-row";
|
||||
|
||||
@customElement("ha-automation-action")
|
||||
export default class HaAutomationAction extends AutomationSortableListMixin<Action>(
|
||||
|
||||
@@ -25,7 +25,10 @@ import { handleStructError } from "../../../../common/structs/handle-errors";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
|
||||
import {
|
||||
ACTION_BUILDING_BLOCKS,
|
||||
getAutomationActionType,
|
||||
} from "../../../../data/action";
|
||||
import type { ActionSidebarConfig } from "../../../../data/automation";
|
||||
import type { DomainManifestLookup } from "../../../../data/integration";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
@@ -37,11 +40,11 @@ import type {
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
|
||||
import { getAutomationActionType } from "../action/ha-automation-action-row";
|
||||
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
|
||||
import "../ha-automation-note";
|
||||
import { overflowStyles, sidebarEditorStyles } from "../styles";
|
||||
import "./ha-automation-sidebar-card";
|
||||
import "../action/ha-automation-action-editor";
|
||||
|
||||
@customElement("ha-automation-sidebar-action")
|
||||
export default class HaAutomationSidebarAction extends LitElement {
|
||||
|
||||
@@ -464,6 +464,15 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
border-width: 1px 0;
|
||||
border-radius: var(--ha-border-radius-square);
|
||||
box-shadow: unset;
|
||||
box-sizing: border-box;
|
||||
width: calc(
|
||||
100% + var(--safe-area-inset-left, 0px) +
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
|
||||
margin-right: calc(-1 * var(--safe-area-inset-right, 0px));
|
||||
padding-left: var(--safe-area-inset-left, 0px);
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
}
|
||||
ha-config-section {
|
||||
margin-top: -42px;
|
||||
|
||||
@@ -11,7 +11,10 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import {
|
||||
stopKeydownEnterSpacePropagation,
|
||||
stopPropagation,
|
||||
} from "../../../common/dom/stop_propagation";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
@@ -19,6 +22,7 @@ import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-tree-indicator";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import {
|
||||
disableConfigEntry,
|
||||
type ConfigEntry,
|
||||
@@ -74,8 +78,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
area ? area.name : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
return html`<ha-md-list-item
|
||||
type="button"
|
||||
return html`<ha-list-item-button
|
||||
@click=${this._handleNavigateToDevice}
|
||||
class=${classMap({ disabled: Boolean(device.disabled_by) })}
|
||||
>
|
||||
@@ -125,15 +128,14 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
: nothing
|
||||
}</span
|
||||
>
|
||||
${
|
||||
!this.narrow ? html`<ha-icon-next slot="end"> </ha-icon-next>` : nothing
|
||||
}
|
||||
${!this.narrow ? html`<ha-icon-next slot="end"></ha-icon-next>` : nothing}
|
||||
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
|
||||
${
|
||||
!this.narrow
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
@click=${this._handleEditDeviceButton}
|
||||
@keydown=${stopKeydownEnterSpacePropagation}
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.device.edit"
|
||||
@@ -145,6 +147,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
<ha-dropdown
|
||||
slot="end"
|
||||
@click=${stopPropagation}
|
||||
@keydown=${stopKeydownEnterSpacePropagation}
|
||||
@wa-select=${this._handleMenuAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
@@ -221,7 +224,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</ha-dropdown>
|
||||
</ha-md-list-item> `;
|
||||
</ha-list-item-button>`;
|
||||
}
|
||||
|
||||
private _getEntities = (): EntityRegistryEntry[] =>
|
||||
@@ -376,25 +379,26 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
ha-list-item-button {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
--ha-row-item-padding-inline: 56px 16px;
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-list-item-leading-space: 56px;
|
||||
--md-ripple-hover-color: transparent;
|
||||
--md-ripple-pressed-color: transparent;
|
||||
ha-icon-button,
|
||||
ha-icon-next,
|
||||
ha-svg-icon {
|
||||
color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
:host([is-child]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 88px;
|
||||
:host([is-child]) ha-list-item-button {
|
||||
--ha-row-item-padding-inline: 88px 16px;
|
||||
}
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
:host([narrow]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 16px;
|
||||
:host([narrow]) ha-list-item-button {
|
||||
--ha-row-item-padding-inline: 16px;
|
||||
}
|
||||
:host([narrow][is-child]) ha-md-list-item {
|
||||
--md-list-item-leading-space: 48px;
|
||||
:host([narrow][is-child]) ha-list-item-button {
|
||||
--ha-row-item-padding-inline: 48px 16px;
|
||||
}
|
||||
ha-tree-indicator {
|
||||
width: 48px;
|
||||
@@ -405,8 +409,9 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
width: 1px;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
ha-list-item-button::part(end) {
|
||||
align-self: stretch;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -28,6 +28,9 @@ import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/item/ha-row-item";
|
||||
import "../../../components/list/ha-list-base";
|
||||
import {
|
||||
deleteApplicationCredential,
|
||||
fetchApplicationCredentialsConfigEntry,
|
||||
@@ -43,9 +46,9 @@ import {
|
||||
reloadConfigEntry,
|
||||
updateConfigEntry,
|
||||
} from "../../../data/config_entries";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import {
|
||||
@@ -164,8 +167,8 @@ export class HaConfigEntryRow extends LitElement {
|
||||
|
||||
const subEntries = this.data.subEntries;
|
||||
|
||||
return html`<ha-md-list>
|
||||
<ha-md-list-item
|
||||
return html` <div class="config-entry-wrapper">
|
||||
<ha-row-item
|
||||
class=${classMap({
|
||||
config_entry: true,
|
||||
"state-not-loaded": item!.state === "not_loaded",
|
||||
@@ -452,82 +455,85 @@ export class HaConfigEntryRow extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</ha-dropdown>
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this._expanded
|
||||
? subEntries.length
|
||||
? html`${
|
||||
ownDevices.length
|
||||
? html`<ha-md-list class="devices">
|
||||
<ha-md-list-item
|
||||
@click=${this._toggleOwnDevices}
|
||||
type="button"
|
||||
class="toggle-devices-row ${classMap({
|
||||
expanded: this._devicesExpanded,
|
||||
})}"
|
||||
>
|
||||
<ha-icon-button
|
||||
class="expand-button ${classMap({
|
||||
</ha-row-item>
|
||||
<ha-list-base>
|
||||
${
|
||||
this._expanded
|
||||
? subEntries.length
|
||||
? html`${
|
||||
ownDevices.length
|
||||
? html`<div class="devices">
|
||||
<ha-list-item-button
|
||||
@click=${this._toggleOwnDevices}
|
||||
class="toggle-devices-row ${classMap({
|
||||
expanded: this._devicesExpanded,
|
||||
})}"
|
||||
.path=${mdiChevronDown}
|
||||
slot="start"
|
||||
>
|
||||
</ha-icon-button>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.devices_without_subentry"
|
||||
)}
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this._devicesExpanded
|
||||
? groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list>`
|
||||
: nothing
|
||||
}
|
||||
${subEntries.map(
|
||||
(subEntryData) => html`
|
||||
<ha-config-sub-entry-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.manifest=${this.manifest}
|
||||
.diagnosticHandler=${this.diagnosticHandler}
|
||||
.entities=${this.entities}
|
||||
.entry=${item}
|
||||
.data=${subEntryData}
|
||||
data-entry-id=${item.entry_id}
|
||||
></ha-config-sub-entry-row>
|
||||
`
|
||||
)}`
|
||||
: html`
|
||||
${groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
<ha-icon-button
|
||||
class="expand-button ${classMap({
|
||||
expanded: this._devicesExpanded,
|
||||
})}"
|
||||
.path=${mdiChevronDown}
|
||||
slot="start"
|
||||
>
|
||||
</ha-icon-button>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.devices_without_subentry"
|
||||
)}</span
|
||||
>
|
||||
</ha-list-item-button>
|
||||
${
|
||||
this._devicesExpanded
|
||||
? groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
${subEntries.map(
|
||||
(subEntryData) => html`
|
||||
<ha-config-sub-entry-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.manifest=${this.manifest}
|
||||
.diagnosticHandler=${this.diagnosticHandler}
|
||||
.entities=${this.entities}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list>`;
|
||||
.data=${subEntryData}
|
||||
data-entry-id=${item.entry_id}
|
||||
></ha-config-sub-entry-row>
|
||||
`
|
||||
)}`
|
||||
: html`
|
||||
${groupDevicesByParent(ownDevices).map(
|
||||
({ device, isChild, isLastChild }) =>
|
||||
html`<ha-config-entry-device-row
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.entry=${item}
|
||||
.device=${device}
|
||||
.entities=${entities}
|
||||
.isChild=${isChild}
|
||||
.isLastChild=${isLastChild}
|
||||
></ha-config-entry-device-row>`
|
||||
)}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _configPanel = memoizeOne(getConfigPanelPath);
|
||||
@@ -849,18 +855,22 @@ export class HaConfigEntryRow extends LitElement {
|
||||
.expand-button.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
ha-md-list {
|
||||
.config-entry-wrapper {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
padding: 0;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
:host([narrow]) {
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
}
|
||||
ha-md-list.devices {
|
||||
.devices {
|
||||
margin: 16px;
|
||||
margin-top: 0;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
ha-icon-button {
|
||||
color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
ha-icon-button.link {
|
||||
color: var(
|
||||
@@ -879,6 +889,14 @@ export class HaConfigEntryRow extends LitElement {
|
||||
ha-dropdown a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.message div {
|
||||
white-space: normal;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
import "../../../components/list/ha-list-base";
|
||||
import { getSignedPath } from "../../../data/auth";
|
||||
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
|
||||
import {
|
||||
@@ -754,11 +754,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
"ui.panel.config.integrations.discovered"
|
||||
)}
|
||||
</h3>
|
||||
<ha-md-list class="discovered">
|
||||
<ha-list-base class="discovered">
|
||||
${filteredDiscoveryData.map(
|
||||
(flow) =>
|
||||
html`<ha-md-list-item class="discovered">
|
||||
${flow.localized_title}
|
||||
html`<ha-list-item-base class="discovered">
|
||||
<span slot="headline">${flow.localized_title}</span>
|
||||
<ha-button
|
||||
slot="end"
|
||||
variant="success"
|
||||
@@ -768,9 +768,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
>
|
||||
${this.hass.localize("ui.common.add")}
|
||||
</ha-button>
|
||||
</ha-md-list-item>`
|
||||
</ha-list-item-base>`
|
||||
)}
|
||||
</ha-md-list>
|
||||
</ha-list-base>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
@@ -786,15 +786,17 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</h3>
|
||||
${
|
||||
filteredAttentionFlows.length
|
||||
? html`<ha-md-list class="attention">
|
||||
? html`<ha-list-base class="attention">
|
||||
${filteredAttentionFlows.map((flow) => {
|
||||
const attention = ATTENTION_SOURCES.includes(
|
||||
flow.context.source
|
||||
);
|
||||
return html`<ha-md-list-item
|
||||
return html`<ha-list-item-base
|
||||
class="config_entry ${attention ? "attention" : ""}"
|
||||
>
|
||||
${flow.localized_title}
|
||||
<span slot="headline"
|
||||
>${flow.localized_title}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.integrations.${
|
||||
@@ -813,9 +815,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
}`
|
||||
)}</ha-button
|
||||
>
|
||||
</ha-md-list-item>`;
|
||||
</ha-list-item-base>`;
|
||||
})}
|
||||
</ha-md-list>`
|
||||
</ha-list-base>`
|
||||
: nothing
|
||||
}
|
||||
${filteredAttentionData.map(
|
||||
@@ -1515,26 +1517,24 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
color: var(--mdc-theme-text-icon-on-background, rgba(0, 0, 0, 0.38));
|
||||
animation: unset;
|
||||
}
|
||||
ha-md-list {
|
||||
ha-list-base {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.discovered {
|
||||
--md-list-container-color: rgba(var(--rgb-success-color), 0.2);
|
||||
ha-list-base.discovered {
|
||||
background-color: rgba(var(--rgb-success-color), 0.2);
|
||||
}
|
||||
.attention {
|
||||
--md-list-container-color: rgba(var(--rgb-warning-color), 0.2);
|
||||
ha-list-base.attention {
|
||||
background-color: rgba(var(--rgb-warning-color), 0.2);
|
||||
}
|
||||
ha-md-list-item {
|
||||
--md-list-item-top-space: 4px;
|
||||
--md-list-item-bottom-space: 4px;
|
||||
ha-list-item-base.discovered {
|
||||
--ha-row-item-min-height: 72px;
|
||||
}
|
||||
ha-list-item-base.config_entry {
|
||||
position: relative;
|
||||
}
|
||||
ha-md-list-item.discovered {
|
||||
height: 72px;
|
||||
}
|
||||
ha-md-list-item.config_entry::after {
|
||||
ha-list-item-base.config_entry::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
@@ -1596,7 +1596,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
.state-disabled [slot="supporting-text"] {
|
||||
opacity: var(--md-list-item-disabled-opacity, 0.3);
|
||||
}
|
||||
ha-md-list {
|
||||
ha-list-base {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
import "../../../components/list/ha-list-base";
|
||||
import type { ConfigEntry } from "../../../data/config_entries";
|
||||
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
|
||||
import { groupDevicesByParent } from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import type { SubEntryData } from "./ha-config-integration-page";
|
||||
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
showPromptDialog,
|
||||
} from "../../lovelace/custom-card-helpers";
|
||||
import "./ha-config-entry-device-row";
|
||||
import type { SubEntryData } from "./ha-config-integration-page";
|
||||
|
||||
@customElement("ha-config-sub-entry-row")
|
||||
class HaConfigSubEntryRow extends LitElement {
|
||||
@@ -54,8 +56,8 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
const services = this.data.services;
|
||||
const entities = this._getEntities();
|
||||
|
||||
return html`<ha-md-list>
|
||||
<ha-md-list-item
|
||||
return html`<div class="sub-entry-card">
|
||||
<ha-list-item-base
|
||||
class="sub-entry"
|
||||
data-entry-id=${configEntry.entry_id}
|
||||
.configEntry=${configEntry}
|
||||
@@ -188,7 +190,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-base>
|
||||
${
|
||||
this._expanded
|
||||
? html`
|
||||
@@ -217,7 +219,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-md-list>`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _toggleExpand() {
|
||||
@@ -305,14 +307,18 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
.expand-button.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
ha-md-list {
|
||||
.sub-entry-card {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
padding: 0;
|
||||
margin: 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
ha-md-list-item.has-subentries {
|
||||
ha-icon-button,
|
||||
ha-icon-next,
|
||||
ha-svg-icon {
|
||||
color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
ha-list-item-base.has-subentries {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
ha-dropdown a {
|
||||
|
||||
@@ -174,6 +174,11 @@ class ZHANetworkInfoPage extends LitElement {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
font-size: var(--ha-font-size-m);
|
||||
white-space: normal;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
+5
@@ -111,6 +111,11 @@ class ZWaveJSNetworkInfoPage extends LitElement {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
font-size: var(--ha-font-size-m);
|
||||
white-space: normal;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -849,14 +849,29 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
}
|
||||
|
||||
.narrow-header-row {
|
||||
--header-row-inset-start: var(--safe-area-inset-left, 0px);
|
||||
--header-row-inset-end: var(--safe-area-inset-right, 0px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
padding: 0 var(--ha-space-4);
|
||||
padding: 0;
|
||||
padding-inline-start: calc(
|
||||
var(--ha-space-4) + var(--header-row-inset-start)
|
||||
);
|
||||
overflow-x: scroll;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.narrow-header-row:dir(rtl) {
|
||||
--header-row-inset-start: var(--safe-area-inset-right, 0px);
|
||||
--header-row-inset-end: var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.narrow-header-row::after {
|
||||
content: "";
|
||||
flex: 0 0 var(--header-row-inset-end);
|
||||
}
|
||||
|
||||
.selection-bar {
|
||||
background: rgba(var(--rgb-primary-color), 0.1);
|
||||
width: 100%;
|
||||
|
||||
@@ -78,6 +78,7 @@ class DialogVoiceSettings extends LitElement {
|
||||
|
||||
private _entityEntryUpdated(ev: CustomEvent) {
|
||||
this._params!.extEntityReg = ev.detail;
|
||||
this._params!.entityEntryUpdated?.(ev.detail);
|
||||
}
|
||||
|
||||
private _exposedEntitiesChanged() {
|
||||
|
||||
@@ -711,6 +711,9 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
exposedEntitiesChanged: () => {
|
||||
fireEvent(this, "exposed-entities-changed");
|
||||
},
|
||||
entityEntryUpdated: (entry) => {
|
||||
this._extEntities = { ...this._extEntities, [entityId]: entry };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface VoiceSettingsDialogParams {
|
||||
exposed: ExposeEntitySettings;
|
||||
extEntityReg?: ExtEntityRegistryEntry;
|
||||
exposedEntitiesChanged?: () => void;
|
||||
entityEntryUpdated?: (entry: ExtEntityRegistryEntry) => void;
|
||||
}
|
||||
|
||||
export const loadVoiceSettingsDialog = () => import("./dialog-voice-settings");
|
||||
|
||||
@@ -12,14 +12,21 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true } },
|
||||
},
|
||||
];
|
||||
const SCHEMA = memoizeOne(
|
||||
(icon: string, color: string) =>
|
||||
[
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon, color } },
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
@customElement("dialog-home-zone-detail")
|
||||
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
|
||||
@@ -81,7 +88,11 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${SCHEMA}
|
||||
.schema=${SCHEMA(
|
||||
this.hass.states[HOME_ZONE_ENTITY_ID]?.attributes.icon ||
|
||||
"mdi:home",
|
||||
zoneColor(HOME_ZONE_ENTITY_ID, false, [], getComputedStyle(this))
|
||||
)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -9,12 +10,18 @@ import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import "../../../components/ha-button";
|
||||
import type { SchemaUnion } from "../../../components/ha-form/types";
|
||||
import type { ZoneMutableParams } from "../../../data/zone";
|
||||
import type { Zone, ZoneMutableParams } from "../../../data/zone";
|
||||
import { getZoneEditorInitData } from "../../../data/zone";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
|
||||
import {
|
||||
nextZoneColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import { fullEntitiesContext } from "../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
|
||||
@customElement("dialog-zone-detail")
|
||||
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
@@ -22,6 +29,11 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// Registry creation order decides the zone color
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
@state() private _error?: Record<string, string>;
|
||||
|
||||
@state() private _data?: ZoneMutableParams;
|
||||
@@ -89,6 +101,22 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
!lngInvalid &&
|
||||
!radiusInvalid;
|
||||
|
||||
// From the registry context, so a deep link opening before the registry
|
||||
// loads still resolves the color
|
||||
const entityId = this._zoneEntityId(this._params.entry, this._entityReg);
|
||||
const color = entityId
|
||||
? zoneColor(
|
||||
entityId,
|
||||
!!this._data.passive,
|
||||
this._entityReg,
|
||||
getComputedStyle(this)
|
||||
)
|
||||
: nextZoneColor(
|
||||
!!this._data.passive,
|
||||
this._entityReg,
|
||||
getComputedStyle(this)
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
@@ -105,7 +133,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${this._schema(this._data.icon)}
|
||||
.schema=${this._schema(this._data.icon, color, this._data.name)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@@ -152,8 +180,18 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
`;
|
||||
}
|
||||
|
||||
// Storage zones register their entity with the zone id as unique id
|
||||
private _zoneEntityId = memoizeOne(
|
||||
(entry: Zone | undefined, entityReg: EntityRegistryEntry[]) =>
|
||||
entry
|
||||
? entityReg.find(
|
||||
(ent) => ent.platform === "zone" && ent.unique_id === entry.id
|
||||
)?.entity_id
|
||||
: undefined
|
||||
);
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(icon?: string) =>
|
||||
(icon?: string, color?: string, name?: string) =>
|
||||
[
|
||||
{
|
||||
name: "name",
|
||||
@@ -172,7 +210,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon } },
|
||||
selector: { location: { radius: true, icon, color, name } },
|
||||
},
|
||||
{ name: "passive_note", type: "constant" },
|
||||
{ name: "passive", selector: { boolean: {} } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import {
|
||||
contrastingZoneContent,
|
||||
zoneInitials,
|
||||
} from "../../../common/map/zone-marker";
|
||||
import type {
|
||||
HomeZoneMutableParams,
|
||||
Zone,
|
||||
@@ -96,9 +101,157 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
// Storage zone id (its unique id) to entity id
|
||||
@state() private _zoneEntityIds: Record<string, string> = {};
|
||||
|
||||
// Bumped when entity map colors change to recompute the memoized locations
|
||||
// Bumped on a theme change to recompute the memoized locations
|
||||
@state() private _colorVersion = 0;
|
||||
|
||||
// Home first, then alphabetical, for UI and YAML zones alike
|
||||
private _sortedItems = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
language: string
|
||||
): ({ entry: Zone } | { stateObject: HassEntity })[] => {
|
||||
const items = [
|
||||
...storageItems.map((entry) => ({
|
||||
entry,
|
||||
name: entry.name,
|
||||
home: false,
|
||||
})),
|
||||
...stateItems.map((stateObject) => ({
|
||||
stateObject,
|
||||
name: stateObject.attributes.friendly_name || stateObject.entity_id,
|
||||
home: stateObject.entity_id === HOME_ZONE_ENTITY_ID,
|
||||
})),
|
||||
];
|
||||
return items.sort((a, b) =>
|
||||
a.home !== b.home
|
||||
? a.home
|
||||
? -1
|
||||
: 1
|
||||
: stringCompare(a.name, b.name, language)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _renderStorageItem(entry: Zone) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="avatar"
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
this._zoneEntityIds[entry.id] ?? `zone.${entry.id}`,
|
||||
!!entry.passive,
|
||||
entry.icon,
|
||||
entry.name
|
||||
)}
|
||||
${entry.name}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderStateItem(stateObject: HassEntity) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${!this.narrow || stateObject.entity_id !== "zone.home"}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
.noEdit=${stateObject.entity_id !== "zone.home" || !this._canEditCore}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
stateObject.entity_id,
|
||||
!!stateObject.attributes.passive,
|
||||
stateObject.attributes.icon,
|
||||
stateObject.attributes.friendly_name || stateObject.entity_id
|
||||
)}
|
||||
${stateObject.attributes.friendly_name || stateObject.entity_id}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" || !this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" && this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize("ui.panel.config.zone.configured_in_yaml")}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
// The zone as it looks on the map: its color, with its icon or initials
|
||||
private _renderZoneGraphic(
|
||||
entityId: string,
|
||||
passive: boolean,
|
||||
icon: string | undefined,
|
||||
name: string
|
||||
) {
|
||||
const color = zoneColor(
|
||||
entityId,
|
||||
passive,
|
||||
this._entityReg,
|
||||
getComputedStyle(this)
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
slot="graphic"
|
||||
class="zone-avatar"
|
||||
style=${styleMap({
|
||||
background: color,
|
||||
color: contrastingZoneContent(color),
|
||||
})}
|
||||
>
|
||||
${icon ? html`<ha-icon .icon=${icon}></ha-icon>` : zoneInitials(name)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getZones = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
@@ -189,100 +342,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
<ha-list>
|
||||
${this._storageItems.map(
|
||||
(entry) => html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="icon"
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
>
|
||||
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
|
||||
${entry.name}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
${this._stateItems.map(
|
||||
(stateObject) => html`
|
||||
<ha-list-item
|
||||
graphic="icon"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${
|
||||
!this.narrow || stateObject.entity_id !== "zone.home"
|
||||
}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
>
|
||||
<ha-icon
|
||||
.icon=${stateObject.attributes.icon}
|
||||
slot="graphic"
|
||||
>
|
||||
</ha-icon>
|
||||
|
||||
${
|
||||
stateObject.attributes.friendly_name ||
|
||||
stateObject.entity_id
|
||||
}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize(
|
||||
"ui.panel.config.zone.configured_in_yaml"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
${this._sortedItems(
|
||||
this._storageItems,
|
||||
this._stateItems,
|
||||
hass.locale.language
|
||||
).map((item) =>
|
||||
"entry" in item
|
||||
? this._renderStorageItem(item.entry)
|
||||
: this._renderStateItem(item.stateObject)
|
||||
)}
|
||||
</ha-list>
|
||||
`;
|
||||
@@ -673,6 +740,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
ha-icon-button:not([disabled]) {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.zone-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.zone-avatar ha-icon {
|
||||
color: inherit;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
ha-icon-button {
|
||||
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public graphColor = false;
|
||||
|
||||
/** Overrides the node color, e.g. the color of the zone a person entered */
|
||||
@property({ attribute: false }) public nodeColor?: string;
|
||||
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
|
||||
@@ -384,13 +387,14 @@ class HaLogbookEntry extends LitElement {
|
||||
(domain === "sensor" && stateObj!.attributes.device_class === "enum");
|
||||
const useGraphColor = this.graphColor || !isEnumDomain;
|
||||
const color =
|
||||
layout === "inline" && !isUnavailable && this.item.state && useGraphColor
|
||||
this.nodeColor ??
|
||||
(layout === "inline" && !isUnavailable && this.item.state && useGraphColor
|
||||
? computeTimelineColor(
|
||||
this.item.state,
|
||||
(this._computedStyle ??= getComputedStyle(this)),
|
||||
stateObj
|
||||
)
|
||||
: nodeColor(item.category, stateObj);
|
||||
: nodeColor(item.category, stateObj));
|
||||
const style = color ? styleMap({ "--node-color": color }) : nothing;
|
||||
if (layout !== "timeline") {
|
||||
return html`<span
|
||||
|
||||
@@ -3,19 +3,18 @@ import {
|
||||
mdiGoogleCirclesCommunities,
|
||||
mdiImageFilterCenterFocus,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
import { resolveThemeColor } from "../../../common/color/compute-color";
|
||||
import {
|
||||
entityMapColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
@@ -33,7 +32,12 @@ import type {
|
||||
HaMapPaths,
|
||||
MapCardMarkerLabelMode,
|
||||
} from "../../../components/map/ha-map";
|
||||
import type { MapLatLng } from "../../../common/map/map-engine";
|
||||
import type { MapFitPadding, MapLatLng } from "../../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../../common/map/map-engine";
|
||||
import {
|
||||
entityMapColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
|
||||
import type { Themes } from "../../../data/ws-themes";
|
||||
@@ -41,6 +45,8 @@ import { fullEntitiesContext, uiContext } from "../../../data/context";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { PANEL_VIEW_LAYOUT } from "../views/const";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import {
|
||||
hasConfigChanged,
|
||||
@@ -57,6 +63,15 @@ import {
|
||||
export const DEFAULT_HOURS_TO_SHOW = 0;
|
||||
export const DEFAULT_ZOOM = 14;
|
||||
|
||||
// GPS accuracy (meters) above which the selected person's circle is shown
|
||||
const IMPRECISE_GPS_ACCURACY = 100;
|
||||
|
||||
// Margin around the overview (--ha-space-3), in pixels
|
||||
const OVERVIEW_GAP = 12;
|
||||
|
||||
const FOCUS_PERSON_ZOOM = 19;
|
||||
const FOCUS_ZONE_MAX_ZOOM = 18;
|
||||
|
||||
interface GeoEntity {
|
||||
entity_id: string;
|
||||
label_mode?: MapCardMarkerLabelMode;
|
||||
@@ -83,6 +98,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _stateHistory?: HistoryStates;
|
||||
|
||||
@state()
|
||||
@@ -97,6 +114,11 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _filteredMapEntities: HaMapEntity[] = [];
|
||||
|
||||
// The overview lists people the map snapshot missed (e.g. no location yet),
|
||||
// so it holds the map entities plus those, who may have no marker of their
|
||||
// own until they are located.
|
||||
private _overviewEntities: HaMapEntity[] = [];
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
// Registry creation order decides the palette colors
|
||||
@@ -112,6 +134,13 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@state() private _clusterMarkers = true;
|
||||
|
||||
@state() private _overviewSelected?: string;
|
||||
|
||||
// Height of the overview drawer when it sits over the bottom of the map
|
||||
@state() private _overviewSize = { width: 0, height: 0 };
|
||||
|
||||
private _overviewLoaded = false;
|
||||
|
||||
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
|
||||
|
||||
private _getAllEntities(): string[] {
|
||||
@@ -237,8 +266,22 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<ha-card id="card" .header=${this._config.title}>
|
||||
<div id="root">
|
||||
<div
|
||||
id="root"
|
||||
class=${classMap({
|
||||
"panel-layout": this.layout === PANEL_VIEW_LAYOUT,
|
||||
rtl: computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
),
|
||||
})}
|
||||
@hass-more-info=${this._handleMapMoreInfo}
|
||||
>
|
||||
<ha-map
|
||||
style=${styleMap({
|
||||
"--overview-height": `${this._overviewSize.height}px`,
|
||||
"--overview-width": `${this._overviewSize.width}px`,
|
||||
})}
|
||||
.entities=${this._filteredMapEntities}
|
||||
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
|
||||
.paths=${this._getHistoryPaths(
|
||||
@@ -248,12 +291,23 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
this._themes
|
||||
)}
|
||||
.autoFit=${this._config.auto_fit || false}
|
||||
.fitPadding=${this._overviewPadding()}
|
||||
.fitZones=${this._config.fit_zones || false}
|
||||
.zoomPosition=${
|
||||
this.layout === PANEL_VIEW_LAYOUT &&
|
||||
!computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "topright"
|
||||
: "topleft"
|
||||
}
|
||||
.themeMode=${themeMode}
|
||||
.clusterMarkers=${this._clusterMarkers}
|
||||
.scaleRuler=${this._config.scale_ruler || false}
|
||||
@map-clicked=${this._handleMapClicked}
|
||||
interactive-zones
|
||||
render-passive
|
||||
.renderPassive=${this.layout !== PANEL_VIEW_LAYOUT}
|
||||
></ha-map>
|
||||
<div id="buttons">
|
||||
${
|
||||
@@ -285,6 +339,18 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
tabindex="0"
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
this.layout === PANEL_VIEW_LAYOUT
|
||||
? html`<hui-map-overview
|
||||
id="overview"
|
||||
.hass=${this.hass}
|
||||
.entities=${this._overviewEntities}
|
||||
.selected=${this._overviewSelected}
|
||||
@map-overview-select=${this._handleOverviewSelect}
|
||||
@map-overview-resize=${this._handleOverviewResize}
|
||||
></hui-map-overview>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -361,18 +427,110 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
// Filter entities by conditions
|
||||
if (this._config?.conditions && this._mapEntities) {
|
||||
const conditions = this._config.conditions;
|
||||
this._filteredMapEntities = this._mapEntities.filter((entity) => {
|
||||
const conditionWithEntity = conditions.map((condition) =>
|
||||
addEntityToCondition(condition, entity.entity_id)
|
||||
);
|
||||
return checkConditionsMet(conditionWithEntity, this.hass!, {});
|
||||
});
|
||||
this._filteredMapEntities = this._mapEntities.filter((entity) =>
|
||||
this._meetsConditions(entity.entity_id)
|
||||
);
|
||||
} else {
|
||||
this._filteredMapEntities = this._mapEntities;
|
||||
}
|
||||
|
||||
if (this.layout === PANEL_VIEW_LAYOUT) {
|
||||
if (!this._overviewLoaded) {
|
||||
this._overviewLoaded = true;
|
||||
void import("./map/hui-map-overview");
|
||||
}
|
||||
// Keep people and standalone trackers the snapshot missed so they appear
|
||||
// once they locate.
|
||||
const entities = this._config?.show_all
|
||||
? this._withMissingTracked(this._filteredMapEntities)
|
||||
: this._filteredMapEntities;
|
||||
this._filteredMapEntities = this._decorateOverviewEntities(
|
||||
entities,
|
||||
this._overviewSelected,
|
||||
this._overviewSelected
|
||||
? this.hass.states[this._overviewSelected]
|
||||
: undefined,
|
||||
this.preview
|
||||
);
|
||||
this._overviewEntities = this._filteredMapEntities;
|
||||
}
|
||||
}
|
||||
|
||||
private _meetsConditions(entityId: string): boolean {
|
||||
const conditions = this._config?.conditions;
|
||||
if (!conditions) {
|
||||
return true;
|
||||
}
|
||||
return checkConditionsMet(
|
||||
conditions.map((condition) => addEntityToCondition(condition, entityId)),
|
||||
this.hass!,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// show_all freezes located entities, so a person or standalone tracker that
|
||||
// locates later is missing. Add every eligible one and let the map and
|
||||
// overview skip it until it has coordinates. Trackers owned by a person are
|
||||
// shown through that person, so they are left out here.
|
||||
private _withMissingTracked(entities: HaMapEntity[]): HaMapEntity[] {
|
||||
const hass = this.hass;
|
||||
if (!hass) {
|
||||
return entities;
|
||||
}
|
||||
const present = new Set(entities.map((entity) => entity.entity_id));
|
||||
const personSources = new Set<string>();
|
||||
Object.values(hass.states).forEach((stateObj) => {
|
||||
if (
|
||||
computeStateDomain(stateObj) === "person" &&
|
||||
stateObj.attributes.source
|
||||
) {
|
||||
personSources.add(stateObj.attributes.source);
|
||||
}
|
||||
});
|
||||
const extra: HaMapEntity[] = [];
|
||||
Object.values(hass.states).forEach((stateObj) => {
|
||||
const entityId = stateObj.entity_id;
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const eligible =
|
||||
domain === "person" ||
|
||||
(domain === "device_tracker" && !personSources.has(entityId));
|
||||
if (
|
||||
eligible &&
|
||||
!present.has(entityId) &&
|
||||
!hass.entities?.[entityId]?.hidden &&
|
||||
this._meetsConditions(entityId)
|
||||
) {
|
||||
extra.push({ entity_id: entityId, color: this._getColor(entityId) });
|
||||
}
|
||||
});
|
||||
return extra.length ? [...entities, ...extra] : entities;
|
||||
}
|
||||
|
||||
// In panel layout, only the selected zone shows its radius (all of them
|
||||
// while editing) and only an imprecise selected person its accuracy circle
|
||||
private _decorateOverviewEntities = memoizeOne(
|
||||
(
|
||||
entities: HaMapEntity[],
|
||||
selectedId: string | undefined,
|
||||
selectedStateObj: HassEntity | undefined,
|
||||
preview: boolean
|
||||
): HaMapEntity[] => {
|
||||
const selectedLocation = selectedStateObj
|
||||
? getEntityLocation(selectedStateObj, this.hass.states)
|
||||
: undefined;
|
||||
const showSelectedAccuracy =
|
||||
(selectedLocation?.gpsAccuracy ?? 0) > IMPRECISE_GPS_ACCURACY;
|
||||
return entities.map((entity) => ({
|
||||
...entity,
|
||||
hide_accuracy: !(
|
||||
showSelectedAccuracy && entity.entity_id === selectedId
|
||||
),
|
||||
hide_radius: !preview && entity.entity_id !== selectedId,
|
||||
selected: entity.entity_id === selectedId,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated && this._configEntities?.length) {
|
||||
@@ -465,6 +623,130 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
this._map?.fitMap({ unpause_autofit: true });
|
||||
}
|
||||
|
||||
private _handleMapClicked() {
|
||||
// A click on the map itself (not on a marker) deselects
|
||||
if (this._overviewSelected) {
|
||||
this._overviewSelected = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMapMoreInfo(ev: HASSDomEvent<{ entityId: string | null }>) {
|
||||
if ((ev.target as HTMLElement)?.localName === "hui-map-overview") {
|
||||
// The overview asks for the dialog itself, so let it through
|
||||
return;
|
||||
}
|
||||
const entityId = ev.detail.entityId;
|
||||
if (
|
||||
this.layout !== PANEL_VIEW_LAYOUT ||
|
||||
!entityId ||
|
||||
!["person", "device_tracker", "zone"].includes(computeDomain(entityId)) ||
|
||||
(computeDomain(entityId) !== "zone" &&
|
||||
!this._filteredMapEntities.some(
|
||||
(entity) => entity.entity_id === entityId
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this._overviewSelected = entityId;
|
||||
this._focusEntity(entityId);
|
||||
}
|
||||
|
||||
private _handleOverviewResize(
|
||||
ev: HASSDomEvent<{ width: number; height: number }>
|
||||
) {
|
||||
const { width, height } = ev.detail;
|
||||
// A collapsed overview keeps its width; zero both so it reserves no space.
|
||||
this._overviewSize =
|
||||
width && height ? { width, height } : { width: 0, height: 0 };
|
||||
// A focused fit pauses auto-fit, so refit to apply the new padding.
|
||||
if (this._overviewSelected) {
|
||||
this._focusEntity(this._overviewSelected);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleOverviewSelect(ev: HASSDomEvent<{ entityId?: string }>) {
|
||||
this._overviewSelected = ev.detail.entityId;
|
||||
if (ev.detail.entityId) {
|
||||
this._focusEntity(ev.detail.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
private _focusEntity(entityId: string) {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
if (computeStateDomain(stateObj) === "zone") {
|
||||
const { latitude, longitude, radius } = stateObj.attributes;
|
||||
this._map?.fitBounds(
|
||||
circleBoundsPoints([latitude, longitude], radius ?? 100),
|
||||
{
|
||||
pad: 0.2,
|
||||
zoom: FOCUS_ZONE_MAX_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const location = getEntityLocation(stateObj, this.hass.states);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const center: MapLatLng = [location.latitude, location.longitude];
|
||||
const accuracy = location.gpsAccuracy ?? 0;
|
||||
// Fit the accuracy circle so it is not mostly off-screen, capping the zoom
|
||||
if (accuracy > IMPRECISE_GPS_ACCURACY) {
|
||||
this._map?.fitBounds(circleBoundsPoints(center, accuracy), {
|
||||
pad: 0.2,
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._map?.fitBounds([center], {
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
padding: this._overviewPadding(),
|
||||
});
|
||||
}
|
||||
|
||||
// The part of the map the overview covers, so fitted markers land next to
|
||||
// it rather than under it: the bottom sheet on phones, the start side
|
||||
// otherwise (see the #overview styles). Memoized so the map only refits
|
||||
// when the drawer actually changes size.
|
||||
private _overviewPadding(): MapFitPadding | undefined {
|
||||
// The overview, and its padding, only exist in panel layout.
|
||||
if (this.layout !== PANEL_VIEW_LAYOUT) {
|
||||
return undefined;
|
||||
}
|
||||
return this._paddingFor(
|
||||
this._overviewSize.width,
|
||||
this._overviewSize.height,
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
}
|
||||
|
||||
private _paddingFor = memoizeOne(
|
||||
(
|
||||
width: number,
|
||||
height: number,
|
||||
language: string,
|
||||
translations: HomeAssistant["translationMetadata"]["translations"]
|
||||
): MapFitPadding | undefined => {
|
||||
if (!width || !height) {
|
||||
return undefined;
|
||||
}
|
||||
if (window.matchMedia("(max-width: 600px)").matches) {
|
||||
return { bottom: height + OVERVIEW_GAP };
|
||||
}
|
||||
const side = width + 2 * OVERVIEW_GAP;
|
||||
return computeRTL(language, translations)
|
||||
? { right: side }
|
||||
: { left: side };
|
||||
}
|
||||
);
|
||||
|
||||
private _toggleClusterMarkers() {
|
||||
this._clusterMarkers = !this._clusterMarkers;
|
||||
}
|
||||
@@ -636,10 +918,81 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The overview panel covers the start side in panel layout */
|
||||
#root.panel-layout #buttons {
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 3px;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#overview {
|
||||
position: absolute;
|
||||
top: var(--ha-space-3);
|
||||
inset-inline-start: var(--ha-space-3);
|
||||
width: min(360px, calc(100% - 2 * var(--ha-space-3)));
|
||||
max-height: calc(100% - 2 * var(--ha-space-3));
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#root.panel-layout {
|
||||
--map-bleed-left: var(--view-container-inset-left, 0px);
|
||||
--map-bleed-right: var(--view-container-inset-right, 0px);
|
||||
--map-bleed-bottom: var(--view-container-inset-bottom, 0px);
|
||||
}
|
||||
#card:has(#root.panel-layout) {
|
||||
overflow: visible;
|
||||
}
|
||||
#root.panel-layout ha-map {
|
||||
left: calc(-1 * var(--map-bleed-left));
|
||||
right: calc(-1 * var(--map-bleed-right));
|
||||
bottom: calc(-1 * var(--map-bleed-bottom));
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Keep the attribution and scale ruler clear of the drawer: beside it on
|
||||
wide layouts, above it on phones. The controls sit at physical corners. */
|
||||
#root.panel-layout ha-map {
|
||||
--ha-map-left-inset: calc(
|
||||
var(--overview-width, 0px) + 2 * var(--ha-space-3) +
|
||||
var(--map-bleed-left)
|
||||
);
|
||||
--ha-map-right-inset: var(--map-bleed-right);
|
||||
--ha-map-bottom-inset: var(--map-bleed-bottom);
|
||||
}
|
||||
#root.panel-layout.rtl ha-map {
|
||||
--ha-map-left-inset: var(--map-bleed-left);
|
||||
--ha-map-right-inset: calc(
|
||||
var(--overview-width, 0px) + 2 * var(--ha-space-3) +
|
||||
var(--map-bleed-right)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#overview {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: auto;
|
||||
max-height: 70%;
|
||||
}
|
||||
|
||||
#root.panel-layout ha-map,
|
||||
#root.panel-layout.rtl ha-map {
|
||||
--ha-map-left-inset: var(--map-bleed-left);
|
||||
--ha-map-right-inset: var(--map-bleed-right);
|
||||
--ha-map-bottom-inset: calc(
|
||||
var(--overview-height, 0px) + var(--ha-space-2)
|
||||
);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
EntityHistoryState,
|
||||
HistoryStates,
|
||||
} from "../../../../data/history";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
|
||||
|
||||
// A person or tracker without a location; skipped so a dropout is not an event
|
||||
const NO_LOCATION_STATES: string[] = [UNAVAILABLE, UNKNOWN];
|
||||
|
||||
/** One line of the overview's activity timeline */
|
||||
export interface ActivityEntry {
|
||||
state: string;
|
||||
when: Date;
|
||||
/** Zone detail: the person that arrived at or left the zone */
|
||||
personId?: string;
|
||||
arrived?: boolean;
|
||||
}
|
||||
|
||||
export const ACTIVITY_MAX_ENTRIES = 20;
|
||||
|
||||
/**
|
||||
* A person's state changes inside the window, newest first. History starts
|
||||
* with the state at the window's start, which is not an event; no-location
|
||||
* samples are skipped so a dropout does not count as a change.
|
||||
*/
|
||||
export const personActivity = (
|
||||
history: EntityHistoryState[] | undefined,
|
||||
since: number
|
||||
): ActivityEntry[] => {
|
||||
const entries: ActivityEntry[] = [];
|
||||
let previous: string | undefined;
|
||||
for (const entry of history ?? []) {
|
||||
if (NO_LOCATION_STATES.includes(entry.s)) {
|
||||
continue;
|
||||
}
|
||||
const changed = entry.s !== previous;
|
||||
previous = entry.s;
|
||||
if (!changed || entry.lu * 1000 < since) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ state: entry.s, when: new Date(entry.lu * 1000) });
|
||||
}
|
||||
return entries.reverse().slice(0, ACTIVITY_MAX_ENTRIES);
|
||||
};
|
||||
|
||||
/**
|
||||
* Arrivals at and departures from a zone by the given persons, newest first.
|
||||
* A person is in the zone while their state equals zoneState.
|
||||
*/
|
||||
export const zoneActivity = (
|
||||
history: HistoryStates | undefined,
|
||||
personIds: string[],
|
||||
zoneState: string,
|
||||
since: number
|
||||
): ActivityEntry[] => {
|
||||
const entries: ActivityEntry[] = [];
|
||||
for (const personId of personIds) {
|
||||
let wasInZone: boolean | undefined;
|
||||
for (const entry of history?.[personId] ?? []) {
|
||||
if (NO_LOCATION_STATES.includes(entry.s)) {
|
||||
continue;
|
||||
}
|
||||
const inZone = entry.s === zoneState;
|
||||
// The first sample is an event only if it is an in-window arrival.
|
||||
const isEvent = wasInZone === undefined ? inZone : inZone !== wasInZone;
|
||||
if (isEvent && entry.lu * 1000 >= since) {
|
||||
entries.push({
|
||||
state: entry.s,
|
||||
personId,
|
||||
arrived: inZone,
|
||||
when: new Date(entry.lu * 1000),
|
||||
});
|
||||
}
|
||||
wasInZone = inZone;
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => b.when.getTime() - a.when.getTime());
|
||||
return entries.slice(0, ACTIVITY_MAX_ENTRIES);
|
||||
};
|
||||
@@ -1382,6 +1382,8 @@ class HUIRoot extends LitElement {
|
||||
align-items: center;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
padding: 0px 12px;
|
||||
padding-right: calc(12px + var(--safe-area-inset-right, 0px));
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -1389,7 +1391,13 @@ class HUIRoot extends LitElement {
|
||||
border-bottom: none;
|
||||
}
|
||||
.narrow .toolbar {
|
||||
padding: 0 4px;
|
||||
padding: 0 calc(4px + var(--safe-area-inset-right, 0px)) 0
|
||||
calc(4px + var(--safe-area-inset-left, 0px));
|
||||
width: calc(
|
||||
100% + var(--safe-area-inset-left, 0px) +
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
|
||||
}
|
||||
.main-title {
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
@@ -1534,20 +1542,22 @@ class HUIRoot extends LitElement {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
--view-container-inset-left: 0px;
|
||||
--view-container-inset-right: var(--safe-area-inset-right);
|
||||
--view-container-inset-bottom: var(--safe-area-inset-bottom);
|
||||
padding-top: calc(
|
||||
var(--header-height) + var(--safe-area-inset-top) +
|
||||
var(--view-container-padding-top, 0px)
|
||||
);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
padding-inline-end: var(--safe-area-inset-right);
|
||||
padding-right: var(--view-container-inset-right);
|
||||
padding-bottom: calc(
|
||||
var(--safe-area-inset-bottom) +
|
||||
var(--view-container-inset-bottom) +
|
||||
var(--view-container-padding-bottom, 0px)
|
||||
);
|
||||
}
|
||||
.narrow hui-view-container {
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-inline-start: var(--safe-area-inset-left);
|
||||
--view-container-inset-left: var(--safe-area-inset-left);
|
||||
padding-left: var(--view-container-inset-left);
|
||||
}
|
||||
hui-view-container > * {
|
||||
display: flex;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-md-list";
|
||||
import { isExternal } from "../../data/external";
|
||||
import "../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -46,47 +45,42 @@ class HaProfileSectionBrowser extends LitElement {
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.client_settings_detail")}
|
||||
</div>
|
||||
<ha-md-list>
|
||||
${
|
||||
this.hass.dockedSidebar !== "auto" || !this.narrow
|
||||
? html`
|
||||
<ha-force-narrow-row
|
||||
.hass=${this.hass}
|
||||
></ha-force-narrow-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"vibrate" in navigator
|
||||
? html`
|
||||
<ha-set-vibrate-row
|
||||
.hass=${this.hass}
|
||||
></ha-set-vibrate-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!isExternal &&
|
||||
isComponentLoaded(this.hass.config, "html5.notify")
|
||||
? html`
|
||||
<ha-push-notifications-row
|
||||
.hass=${this.hass}
|
||||
></ha-push-notifications-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
|
||||
${
|
||||
!isMobileClient
|
||||
? html`
|
||||
<ha-enable-shortcuts-row
|
||||
id="shortcuts"
|
||||
.hass=${this.hass}
|
||||
></ha-enable-shortcuts-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-md-list>
|
||||
${
|
||||
this.hass.dockedSidebar !== "auto" || !this.narrow
|
||||
? html`
|
||||
<ha-force-narrow-row
|
||||
.hass=${this.hass}
|
||||
></ha-force-narrow-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"vibrate" in navigator
|
||||
? html`
|
||||
<ha-set-vibrate-row .hass=${this.hass}></ha-set-vibrate-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!isExternal && isComponentLoaded(this.hass.config, "html5.notify")
|
||||
? html`
|
||||
<ha-push-notifications-row
|
||||
.hass=${this.hass}
|
||||
></ha-push-notifications-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
|
||||
${
|
||||
!isMobileClient
|
||||
? html`
|
||||
<ha-enable-shortcuts-row
|
||||
id="shortcuts"
|
||||
.hass=${this.hass}
|
||||
></ha-enable-shortcuts-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
@@ -110,12 +104,6 @@ class HaProfileSectionBrowser extends LitElement {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-md-list";
|
||||
import "../../components/ha-md-list-item";
|
||||
import "../../components/item/ha-row-item";
|
||||
import type { CoreFrontendUserData } from "../../data/frontend";
|
||||
import { subscribeFrontendUserData } from "../../data/frontend";
|
||||
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
|
||||
@@ -81,40 +80,38 @@ class HaProfileSectionPreferences extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-dashboard-row>
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.header"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._customizeSidebar}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.button"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this.hass.user!.is_admin
|
||||
? html`
|
||||
<ha-entity-id-picker-row
|
||||
.hass=${this.hass}
|
||||
.coreUserData=${this._coreUserData}
|
||||
></ha-entity-id-picker-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-md-list>
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.header"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._customizeSidebar}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.button"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-row-item>
|
||||
${
|
||||
this.hass.user!.is_admin
|
||||
? html`
|
||||
<ha-entity-id-picker-row
|
||||
.hass=${this.hass}
|
||||
.coreUserData=${this._coreUserData}
|
||||
></ha-entity-id-picker-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
@@ -142,12 +139,6 @@ class HaProfileSectionPreferences extends LitElement {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6185,6 +6185,8 @@
|
||||
"edit_automation": "Edit automation",
|
||||
"older_trace": "Older trace",
|
||||
"newer_trace": "Newer trace",
|
||||
"previous_tracked_node": "Previous tracked node",
|
||||
"next_tracked_node": "Next tracked node",
|
||||
"no_traces_found": "No traces found",
|
||||
"trace_no_longer_available": "Chosen trace is no longer available",
|
||||
"enter_downloaded_trace": "Enter downloaded trace",
|
||||
@@ -9397,7 +9399,19 @@
|
||||
},
|
||||
"map": {
|
||||
"reset_focus": "Reset focus",
|
||||
"toggle_grouping": "Toggle grouping"
|
||||
"toggle_grouping": "Toggle grouping",
|
||||
"overview": {
|
||||
"people": "People",
|
||||
"devices": "Devices",
|
||||
"zones": "Zones",
|
||||
"no_people": "No people",
|
||||
"no_devices": "No devices with a location",
|
||||
"no_zones": "No zones",
|
||||
"activity": "Activity",
|
||||
"no_activity": "No recent activity",
|
||||
"activity_unavailable": "Activity couldn't be loaded",
|
||||
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
|
||||
}
|
||||
},
|
||||
"energy": {
|
||||
"loading": "Loading…",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { EntityRegistryEntry } from "../../../src/data/entity/entity_regist
|
||||
import {
|
||||
entityMapColor,
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
nextZoneColor,
|
||||
zoneColor,
|
||||
} from "../../../src/common/map/entity-map-colors";
|
||||
|
||||
@@ -62,6 +63,23 @@ describe("entity map colors", () => {
|
||||
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
|
||||
});
|
||||
|
||||
it("previews the next slot for a new zone", () => {
|
||||
const entries = [
|
||||
entry(HOME_ZONE_ENTITY_ID, 10), // excluded from the palette
|
||||
entry("zone.work", 20),
|
||||
entry("person.anne", 30),
|
||||
];
|
||||
|
||||
// Two ordered entities take color-1 and color-2, so the next is color-3
|
||||
expect(nextZoneColor(false, entries, styles)).toBe("color-3");
|
||||
});
|
||||
|
||||
it("mutes a new passive zone", () => {
|
||||
expect(nextZoneColor(true, [entry("zone.work", 10)], styles)).toBe(
|
||||
"secondary-text-color"
|
||||
);
|
||||
});
|
||||
|
||||
it("gives entities outside the registry a stable color", () => {
|
||||
const entries = [entry("zone.work", 10)];
|
||||
|
||||
|
||||
@@ -604,6 +604,27 @@ describe("MapLibreMapEngine", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitting", () => {
|
||||
it("keeps overlays clear of the fitted bounds", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
engine.fitBounds([[52, 4]], { maxZoom: 15, padding: { bottom: 200 } });
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
const [bounds, options] = map.fitBounds.mock.calls[0];
|
||||
// A single point centers on itself at the requested zoom
|
||||
expect(bounds[0]).toEqual([4, 52]);
|
||||
expect(bounds[1]).toEqual([4, 52]);
|
||||
expect(options.maxZoom).toBe(14);
|
||||
expect(options.padding).toEqual({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 200,
|
||||
left: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("markers", () => {
|
||||
it("hands a removed element back without MapLibre's positioning", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Action } from "../../src/data/script";
|
||||
import type {
|
||||
ActionTraceStep,
|
||||
AutomationTraceExtended,
|
||||
ScriptTraceExtended,
|
||||
} from "../../src/data/trace";
|
||||
import { TraceTree, type TraceBranch } from "../../src/data/trace-tree";
|
||||
|
||||
const timestamp = "2026-09-17T00:00:00Z";
|
||||
const step = { path: "sequence/0/then/0", timestamp };
|
||||
const lastPath = "sequence/0/then/0";
|
||||
|
||||
const createTrace = (
|
||||
sequence: Action[],
|
||||
records: ActionTraceStep[] = [],
|
||||
overrides: Partial<ScriptTraceExtended> = {}
|
||||
): ScriptTraceExtended => ({
|
||||
domain: "script",
|
||||
item_id: "test",
|
||||
run_id: "test",
|
||||
state: "stopped",
|
||||
script_execution: "finished",
|
||||
last_step: records[records.length - 1]?.path ?? null,
|
||||
timestamp: { start: timestamp, finish: timestamp },
|
||||
context: { id: "test", user_id: null },
|
||||
config: { alias: "Test", sequence },
|
||||
trace: records.reduce<Record<string, ActionTraceStep[]>>((steps, record) => {
|
||||
(steps[record.path] ??= []).push(record);
|
||||
return steps;
|
||||
}, {}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAutomationTrace = (
|
||||
config: AutomationTraceExtended["config"],
|
||||
records: ActionTraceStep[] = []
|
||||
): AutomationTraceExtended => {
|
||||
const { blueprint_inputs: _dropped, ...scriptTrace } = createTrace(
|
||||
[],
|
||||
records
|
||||
);
|
||||
return { ...scriptTrace, domain: "automation", trigger: null, config };
|
||||
};
|
||||
|
||||
// Branch completion is observed through the public branch flags, exercising
|
||||
// the same code paths the graph renders from.
|
||||
const thenBranchOf = (
|
||||
steps: Action[],
|
||||
records: ActionTraceStep[],
|
||||
overrides: Partial<ScriptTraceExtended> = {}
|
||||
): TraceBranch =>
|
||||
new TraceTree(createTrace([{ if: [], then: steps }], records, overrides))
|
||||
.sequence[0].branches[0];
|
||||
|
||||
describe("TraceTree", () => {
|
||||
it("normalizes nested branches and retains unexecuted nodes and original configs", () => {
|
||||
const leaf = { delay: 1 };
|
||||
const choose = {
|
||||
choose: {
|
||||
conditions: [],
|
||||
sequence: { repeat: { count: 2, sequence: leaf } },
|
||||
},
|
||||
default: { stop: "Fallback" },
|
||||
};
|
||||
const leafPath =
|
||||
"sequence/0/parallel/0/sequence/0/sequence/0/choose/0/sequence/0/repeat/sequence/0";
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[
|
||||
{
|
||||
parallel: [
|
||||
{ sequence: [{ sequence: [choose] }] },
|
||||
{ action: "light.turn_on" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[{ path: leafPath, timestamp, result: { delay: 1, done: true } }]
|
||||
)
|
||||
);
|
||||
|
||||
const [parallel] = tree.sequence;
|
||||
const [executed, unexecuted] = parallel.branches;
|
||||
const chooseNode = executed.children[0].branches[0].children[0];
|
||||
const [choice, fallback] = chooseNode.branches;
|
||||
const repeat = choice.children[0];
|
||||
const result = repeat.branches[0].children[0];
|
||||
expect(chooseNode.config).toBe(choose);
|
||||
expect(choice.option).toBe(choose.choose);
|
||||
expect(result.config).toBe(leaf);
|
||||
expect(result.path).toBe(leafPath);
|
||||
expect(result.hasTrace).toBe(true);
|
||||
// Descendant records still identify the chosen branch when Core drops its result.
|
||||
expect(choice.hasTrace).toBe(true);
|
||||
expect(repeat.branches[0].finished).toBe(true);
|
||||
expect(fallback.hasTrace).toBe(false);
|
||||
expect(fallback.children[0].config).toBe(choose.default);
|
||||
expect(unexecuted.children[0].path).toBe(
|
||||
"sequence/0/parallel/1/sequence/0"
|
||||
);
|
||||
// The model classifies a modern `action:` key as a service (which drives
|
||||
// the generic node's icon), while the graph keeps rendering the generic
|
||||
// node for it, as the old `key in node` lookup did.
|
||||
expect(unexecuted.children[0].actionType).toBe("service");
|
||||
expect(unexecuted.hasTrace).toBe(false);
|
||||
expect(unexecuted.finished).toBe(false);
|
||||
});
|
||||
|
||||
it.each<Action>([{ choose: [] }, { if: [], then: [] }])(
|
||||
"distinguishes an implicit bypass from an error for %j",
|
||||
(action) => {
|
||||
const trace = createTrace([action], [{ path: "sequence/0", timestamp }]);
|
||||
const branches = new TraceTree(trace).sequence[0].branches;
|
||||
const bypass = branches[branches.length - 1]!;
|
||||
expect(bypass.hasTrace).toBe(true);
|
||||
expect(bypass.finished).toBe(true);
|
||||
expect(bypass.children).toEqual([]);
|
||||
|
||||
trace.trace["sequence/0"][0].error = "Failed to evaluate condition";
|
||||
const failed = new TraceTree(trace).sequence[0];
|
||||
expect(failed.error).toBe(true);
|
||||
expect(failed.branches.every((branch) => !branch.hasTrace)).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps both branch choices and condition outcomes across repeat iterations", () => {
|
||||
const condition = { condition: "template", value_template: "{{ ready }}" };
|
||||
const trace = createTrace(
|
||||
[{ if: [], then: condition, else: [] }],
|
||||
[
|
||||
{ path: "sequence/0", timestamp, result: { choice: "then" } },
|
||||
{ path: "sequence/0", timestamp, result: { choice: "else" } },
|
||||
{ path: "sequence/0/then/0", timestamp, result: { result: false } },
|
||||
{ path: "sequence/0/then/0", timestamp, result: { result: true } },
|
||||
]
|
||||
);
|
||||
const [thenBranch, elseBranch] = new TraceTree(trace).sequence[0].branches;
|
||||
expect(thenBranch.hasTrace).toBe(true);
|
||||
expect(elseBranch.hasTrace).toBe(true);
|
||||
expect(thenBranch.children[0].condition).toEqual({
|
||||
executed: true,
|
||||
passed: true,
|
||||
failed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes disabled inheritance, branch state, and presentation fields", () => {
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[
|
||||
{
|
||||
enabled: false,
|
||||
choose: [{ conditions: [], sequence: [{ delay: 1 }] }],
|
||||
default: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
{ path: "sequence/0", timestamp },
|
||||
{
|
||||
path: "sequence/0/choose/0/sequence/0",
|
||||
timestamp,
|
||||
result: { delay: 1, done: false },
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
const [choose] = tree.sequence;
|
||||
expect(choose.actionType).toBe("choose");
|
||||
expect(choose.disabled).toBe(true);
|
||||
const [choice, fallback] = choose.branches;
|
||||
expect(choice.disabled).toBe(true);
|
||||
expect(choice.children[0].disabled).toBe(true);
|
||||
expect(choice.unfinished).toBe(true);
|
||||
expect(fallback.unfinished).toBe(false);
|
||||
expect(choice.children[0].actionType).toBe("delay");
|
||||
expect(tree.getNode("sequence/0/choose/0")).toEqual({
|
||||
path: "sequence/0/choose/0",
|
||||
config: { conditions: [], sequence: [{ delay: 1 }] },
|
||||
type: "chooseOption",
|
||||
});
|
||||
});
|
||||
|
||||
it("masks not-triggered runs but keeps them selectable with navigation", () => {
|
||||
const trace = createAutomationTrace(
|
||||
{
|
||||
alias: "Test",
|
||||
triggers: [{ trigger: "state" }],
|
||||
actions: [],
|
||||
},
|
||||
[{ path: "trigger/0", timestamp }]
|
||||
);
|
||||
const unmasked = new TraceTree({ ...trace, not_triggered: false });
|
||||
expect(unmasked.triggers?.[0]).toMatchObject({
|
||||
hasTrace: true,
|
||||
track: true,
|
||||
});
|
||||
const masked = new TraceTree({ ...trace, not_triggered: true });
|
||||
expect(masked.triggers?.[0]).toMatchObject({
|
||||
hasTrace: true,
|
||||
track: false,
|
||||
notTriggered: true,
|
||||
});
|
||||
expect(masked.firstTracked).toMatchObject({ path: "trigger/0" });
|
||||
expect(masked.previousTracked("trigger/0")).toBeUndefined();
|
||||
expect(masked.nextTracked("trigger/0")).toBeUndefined();
|
||||
// Unknown paths restart from the first tracked node, as the graph did.
|
||||
expect(masked.nextTracked("missing")).toMatchObject({ path: "trigger/0" });
|
||||
});
|
||||
|
||||
it("maps condition outcomes and repeat badges for rendering", () => {
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[
|
||||
{ condition: "template", value_template: "{{ ready }}" },
|
||||
{ repeat: { count: 3, sequence: [{ delay: 1 }] } },
|
||||
],
|
||||
[
|
||||
{
|
||||
path: "sequence/0",
|
||||
timestamp,
|
||||
result: { result: true },
|
||||
},
|
||||
{ path: "sequence/1", timestamp },
|
||||
{
|
||||
path: "sequence/1/repeat/sequence/0",
|
||||
timestamp,
|
||||
changed_variables: { repeat: { index: 3 } },
|
||||
result: { delay: 1, done: false },
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
const [condition, repeat] = tree.sequence;
|
||||
expect(condition.track).toBe(true);
|
||||
expect(repeat.badge).toBe(3);
|
||||
expect(repeat.branches[0].unfinished).toBe(true);
|
||||
expect(tree.trackedPaths).toEqual([
|
||||
"sequence/0",
|
||||
"sequence/1",
|
||||
"sequence/1/repeat/sequence/0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses canonical trace paths for plural automation keys and nested triggers", () => {
|
||||
const tree = new TraceTree(
|
||||
createAutomationTrace({
|
||||
alias: "Test",
|
||||
triggers: [
|
||||
{ triggers: [{ trigger: "state", entity_id: "light.test" }] },
|
||||
],
|
||||
conditions: { condition: "template", value_template: "{{ ready }}" },
|
||||
actions: { action: "light.turn_on" },
|
||||
})
|
||||
);
|
||||
expect(tree.triggers?.map((node) => node.path)).toEqual(["trigger/0"]);
|
||||
expect(tree.conditions.map((node) => node.path)).toEqual(["condition/0"]);
|
||||
expect(tree.actions.map((node) => node.path)).toEqual(["action/0"]);
|
||||
expect(tree.sequence).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TraceTree branch completion", () => {
|
||||
it("marks only branches with traced steps as tracked", () => {
|
||||
const [thenBranch, elseBranch] = new TraceTree(
|
||||
createTrace(
|
||||
[{ if: [], then: [{ delay: 1 }], else: [{ delay: 2 }] }],
|
||||
[step]
|
||||
)
|
||||
).sequence[0].branches;
|
||||
expect(thenBranch.hasTrace).toBe(true);
|
||||
expect(elseBranch.hasTrace).toBe(false);
|
||||
expect(elseBranch.finished).toBe(false);
|
||||
expect(elseBranch.unfinished).toBe(false);
|
||||
});
|
||||
|
||||
it.each<Action>([
|
||||
{ wait_template: "{{ false }}", continue_on_timeout: false },
|
||||
{ wait_for_trigger: [], continue_on_timeout: false },
|
||||
])("does not complete an aborted final wait: %j", (action) => {
|
||||
const branch = thenBranchOf(
|
||||
[action],
|
||||
[
|
||||
{
|
||||
...step,
|
||||
result: { wait: { completed: false, remaining: 0 }, timeout: true },
|
||||
},
|
||||
],
|
||||
{ script_execution: "aborted" }
|
||||
);
|
||||
expect(branch.finished).toBe(false);
|
||||
expect(branch.unfinished).toBe(true);
|
||||
});
|
||||
|
||||
it.each<Action>([
|
||||
{ wait_template: "{{ false }}", continue_on_timeout: true },
|
||||
{ wait_for_trigger: [], continue_on_timeout: true },
|
||||
{ wait_template: "{{ false }}" },
|
||||
{ wait_for_trigger: [] },
|
||||
])(
|
||||
"completes a timed-out wait when continuation is enabled: %j",
|
||||
(action) => {
|
||||
// Core reports `timeout: true` together with `wait.completed: false`;
|
||||
// with `continue_on_timeout` true (or omitted, which defaults to
|
||||
// continuing) the branch rejoins instead of stalling.
|
||||
const branch = thenBranchOf(
|
||||
[action],
|
||||
[
|
||||
{
|
||||
...step,
|
||||
result: { wait: { completed: false, remaining: 0 }, timeout: true },
|
||||
},
|
||||
]
|
||||
);
|
||||
expect(branch.finished).toBe(true);
|
||||
expect(branch.unfinished).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 10, false, true],
|
||||
[false, 0, undefined, true],
|
||||
[false, 0, true, true],
|
||||
[false, 0, false, false],
|
||||
[false, 10, true, false],
|
||||
[false, null, true, false],
|
||||
] as const)(
|
||||
"handles completed=%s remaining=%s continue_on_timeout=%s",
|
||||
(completed, remaining, continueOnTimeout, expected) => {
|
||||
// A parallel sibling may be last_step, so wait data must stand on its own.
|
||||
const branch = thenBranchOf(
|
||||
[
|
||||
{
|
||||
wait_template: "{{ ready }}",
|
||||
continue_on_timeout: continueOnTimeout,
|
||||
},
|
||||
],
|
||||
[{ ...step, result: { wait: { completed, remaining } } }],
|
||||
{ last_step: "sequence/1" }
|
||||
);
|
||||
expect(branch.finished).toBe(expected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["aborted", "cancelled", "error"] as const)(
|
||||
"does not complete the terminal branch when execution is %s",
|
||||
(scriptExecution) => {
|
||||
const records: ActionTraceStep[] =
|
||||
scriptExecution === "cancelled"
|
||||
? [step]
|
||||
: [{ ...step, error: "Failed" }];
|
||||
const finishedIn = (steps: Action[], lastStep?: string) => {
|
||||
const trace = createTrace([{ if: [], then: steps }], records, {
|
||||
script_execution: scriptExecution,
|
||||
...(lastStep !== undefined ? { last_step: lastStep } : {}),
|
||||
});
|
||||
return new TraceTree(trace).sequence[0].branches[0].finished;
|
||||
};
|
||||
expect(finishedIn([{ action: "light.turn_on" }])).toBe(false);
|
||||
expect(finishedIn([{ sequence: [] }], `${lastPath}/sequence/0`)).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
finishedIn([{ action: "light.turn_on" }], "sequence/0/then/01")
|
||||
).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it("does not complete the currently running final action", () => {
|
||||
expect(
|
||||
thenBranchOf([{ delay: 10 }], [step], { state: "running" }).finished
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["sequence/0", "then/0", "repeat/sequence/0"])(
|
||||
"does not rejoin after a nested stop at %s, even when a sibling ran last",
|
||||
(descendant) => {
|
||||
const stopPath = `${lastPath}/${descendant}`;
|
||||
const baseTrace = createTrace(
|
||||
[{ if: [], then: [{ sequence: [] }] }],
|
||||
[],
|
||||
{
|
||||
last_step: "sequence/0/else/0",
|
||||
}
|
||||
);
|
||||
baseTrace.trace = {
|
||||
[lastPath]: [step],
|
||||
[stopPath]: [
|
||||
{ ...step, path: stopPath, result: { stop: "", error: false } },
|
||||
],
|
||||
};
|
||||
const finished = () =>
|
||||
new TraceTree(baseTrace).sequence[0].branches[0].finished;
|
||||
expect(finished()).toBe(false);
|
||||
|
||||
// A stop from an earlier invocation cannot stop this one.
|
||||
baseTrace.trace[lastPath] = [
|
||||
{ ...step, timestamp: "2026-09-17T00:00:01Z" },
|
||||
];
|
||||
expect(finished()).toBe(true);
|
||||
|
||||
// A false nested condition returns control to its enclosing sequence.
|
||||
baseTrace.trace[lastPath] = [step];
|
||||
baseTrace.trace[stopPath] = [
|
||||
{ ...step, path: stopPath, result: { result: false } },
|
||||
];
|
||||
expect(finished()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["running", "cancelled"] as const)(
|
||||
"does not infer parallel completion from a sibling when %s",
|
||||
(execution) => {
|
||||
const path = "sequence/0/parallel/0/sequence/0";
|
||||
const siblingPath = "sequence/0/parallel/1/sequence/0";
|
||||
const sibling: ActionTraceStep = {
|
||||
...step,
|
||||
path: siblingPath,
|
||||
timestamp: "2026-09-17T00:00:01Z",
|
||||
};
|
||||
const finishedIn = (
|
||||
steps: Action[],
|
||||
record: ActionTraceStep
|
||||
): boolean => {
|
||||
const trace = createTrace([{ parallel: [{ sequence: steps }] }], [], {
|
||||
state: execution === "running" ? "running" : "stopped",
|
||||
script_execution: execution === "running" ? "finished" : execution,
|
||||
last_step: siblingPath,
|
||||
});
|
||||
trace.trace = {
|
||||
[path]: [record],
|
||||
[siblingPath]: [sibling],
|
||||
};
|
||||
return new TraceTree(trace).sequence[0].branches[0].finished;
|
||||
};
|
||||
expect(finishedIn([{ action: "script.slow" }], { ...step, path })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
finishedIn([{ delay: 10 }], {
|
||||
...step,
|
||||
path,
|
||||
result: { delay: 10, done: false },
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
finishedIn([{ delay: 10 }], {
|
||||
...step,
|
||||
path,
|
||||
result: { delay: 10, done: true },
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["aborted", "error"] as const)(
|
||||
"keeps successful parallel branches green when a sibling leaves the run %s",
|
||||
(scriptExecution) => {
|
||||
const path = "sequence/0/parallel/0/sequence/0";
|
||||
const siblingPath = "sequence/0/parallel/1/sequence/0";
|
||||
const treeFor = (lastStep: string): TraceTree => {
|
||||
const trace = createTrace(
|
||||
[
|
||||
{
|
||||
parallel: [
|
||||
{ sequence: [{ action: "script.slow" }] },
|
||||
{ sequence: [{ action: "script.failing" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
{ script_execution: scriptExecution, last_step: lastStep }
|
||||
);
|
||||
trace.trace = {
|
||||
[path]: [{ ...step, path }],
|
||||
[siblingPath]: [{ ...step, path: siblingPath, error: "Failed" }],
|
||||
};
|
||||
return new TraceTree(trace);
|
||||
};
|
||||
const tree = treeFor(siblingPath);
|
||||
expect(tree.sequence[0].branches[0].finished).toBe(true);
|
||||
expect(tree.sequence[0].branches[1].finished).toBe(false);
|
||||
|
||||
// A successful sibling can also be the last one to start an action.
|
||||
expect(treeFor(path).sequence[0].branches[0].finished).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses continuation after a branch as rejoin evidence", () => {
|
||||
const checkRejoin = (
|
||||
trace: ScriptTraceExtended | AutomationTraceExtended,
|
||||
select: (tree: TraceTree) => TraceBranch,
|
||||
path: string,
|
||||
nextPath: string
|
||||
) => {
|
||||
trace.trace = {
|
||||
[path]: [{ ...step, path }],
|
||||
[nextPath]: [
|
||||
{ ...step, path: nextPath, timestamp: "2026-09-17T00:00:01Z" },
|
||||
],
|
||||
};
|
||||
expect(select(new TraceTree(trace)).finished).toBe(true);
|
||||
// A prior iteration's continuation cannot prove this invocation finished.
|
||||
trace.trace[path][0].timestamp = "2026-09-17T00:00:02Z";
|
||||
expect(select(new TraceTree(trace)).finished).toBe(false);
|
||||
};
|
||||
|
||||
// Continuation after a nested if rejoins its enclosing parallel branch.
|
||||
checkRejoin(
|
||||
createTrace(
|
||||
[
|
||||
{
|
||||
parallel: [
|
||||
{
|
||||
sequence: [
|
||||
{ if: [], then: [{ action: "script.slow" }] },
|
||||
{ action: "script.next" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
{ state: "running" }
|
||||
),
|
||||
(tree) => tree.sequence[0].branches[0].children[0].branches[0],
|
||||
"sequence/0/parallel/0/sequence/0/then/0",
|
||||
"sequence/0/parallel/0/sequence/1"
|
||||
);
|
||||
|
||||
// Continuation after a parallel branch rejoins the top-level sequence.
|
||||
checkRejoin(
|
||||
createTrace(
|
||||
[
|
||||
{ parallel: [{ sequence: [{ action: "script.slow" }] }] },
|
||||
{ action: "script.next" },
|
||||
],
|
||||
[],
|
||||
{ state: "running" }
|
||||
),
|
||||
(tree) => tree.sequence[0].branches[0],
|
||||
"sequence/0/parallel/0/sequence/0",
|
||||
"sequence/1"
|
||||
);
|
||||
|
||||
// Same rejoin under the plural automation action key.
|
||||
const automationTrace = createAutomationTrace(
|
||||
{
|
||||
alias: "Test",
|
||||
triggers: [],
|
||||
actions: [
|
||||
{ parallel: [{ sequence: [{ action: "script.slow" }] }] },
|
||||
{ action: "script.next" },
|
||||
],
|
||||
},
|
||||
[]
|
||||
);
|
||||
automationTrace.state = "running";
|
||||
checkRejoin(
|
||||
automationTrace,
|
||||
(tree) => tree.actions[0].branches[0],
|
||||
"action/0/parallel/0/sequence/0",
|
||||
"action/1"
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a disabled wait to be skipped", () => {
|
||||
expect(
|
||||
thenBranchOf(
|
||||
[{ wait_template: "{{ false }}", enabled: false }],
|
||||
[{ ...step, result: { enabled: false } }]
|
||||
).finished
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each<Action>([
|
||||
{ action: "light.turn_on", enabled: false },
|
||||
{ stop: "Done", enabled: false },
|
||||
{ condition: "template", value_template: "{{ false }}", enabled: false },
|
||||
{ delay: 10, enabled: false },
|
||||
])("completes a disabled terminal action: %j", (action) => {
|
||||
// A disabled final action is skipped by Core, so its branch is done even
|
||||
// while a parallel sibling is still running.
|
||||
const branch = thenBranchOf(
|
||||
[action],
|
||||
[{ ...step, result: { enabled: false } }],
|
||||
{ state: "running" }
|
||||
);
|
||||
expect(branch.finished).toBe(true);
|
||||
expect(branch.unfinished).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks an empty parallel branch when the parent ran", () => {
|
||||
const tree = new TraceTree(
|
||||
createTrace(
|
||||
[{ parallel: [{ sequence: [] }] }],
|
||||
[{ path: "sequence/0", timestamp }]
|
||||
)
|
||||
);
|
||||
const [branch] = tree.sequence[0].branches;
|
||||
expect(branch.hasTrace).toBe(true);
|
||||
expect(branch.finished).toBe(true);
|
||||
expect(branch.unfinished).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves empty, unreached, error, condition and stop handling", () => {
|
||||
const finishedIn = (steps: Action[], records: ActionTraceStep[]) => {
|
||||
const trace = createTrace([{ if: [], then: steps }], records, {
|
||||
last_step: "sequence/1",
|
||||
});
|
||||
return new TraceTree(trace).sequence[0].branches[0].finished;
|
||||
};
|
||||
expect(finishedIn([], [step])).toBe(true);
|
||||
expect(finishedIn([{ action: "light.turn_on" }], [step])).toBe(true);
|
||||
expect(finishedIn([{ stop: "Done" }], [step])).toBe(false);
|
||||
expect(
|
||||
finishedIn([{ action: "light.turn_on" }, { delay: 1 }], [step])
|
||||
).toBe(false);
|
||||
expect(
|
||||
finishedIn([{ action: "light.turn_on" }], [{ ...step, error: "Failed" }])
|
||||
).toBe(false);
|
||||
expect(
|
||||
finishedIn(
|
||||
[{ action: "light.turn_on", continue_on_error: true }],
|
||||
[{ ...step, error: "Failed" }]
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
finishedIn(
|
||||
[{ condition: "template", value_template: "{{ false }}" }],
|
||||
[{ ...step, result: { result: false } }]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EntityHistoryState } from "../../../../../src/data/history";
|
||||
import {
|
||||
ACTIVITY_MAX_ENTRIES,
|
||||
personActivity,
|
||||
zoneActivity,
|
||||
} from "../../../../../src/panels/lovelace/cards/map/map-activity";
|
||||
|
||||
// The window starts at t=1000s; samples are (state, seconds)
|
||||
const SINCE = 1000 * 1000;
|
||||
const sample = (s: string, seconds: number): EntityHistoryState =>
|
||||
({ s, lu: seconds, a: {} }) as EntityHistoryState;
|
||||
|
||||
describe("personActivity", () => {
|
||||
it("lists state changes inside the window, newest first", () => {
|
||||
const entries = personActivity(
|
||||
[sample("home", 900), sample("not_home", 1100), sample("work", 1200)],
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => e.state)).toEqual(["work", "not_home"]);
|
||||
expect(entries[0].when.getTime()).toBe(1200 * 1000);
|
||||
});
|
||||
|
||||
it("counts the first sample only when it lies inside the window", () => {
|
||||
// Before the window it is the state at the window's start, not an event
|
||||
expect(personActivity([sample("home", 900)], SINCE)).toEqual([]);
|
||||
expect(personActivity([sample("home", 1100)], SINCE)).toEqual([
|
||||
{ state: "home", when: new Date(1100 * 1000) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores repeated states and unavailable or unknown dropouts", () => {
|
||||
const entries = personActivity(
|
||||
[
|
||||
sample("home", 900),
|
||||
sample("home", 1100),
|
||||
sample("unavailable", 1150),
|
||||
sample("home", 1160),
|
||||
sample("unknown", 1170),
|
||||
sample("home", 1180),
|
||||
sample("not_home", 1200),
|
||||
],
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => e.state)).toEqual(["not_home"]);
|
||||
});
|
||||
|
||||
it("caps the list", () => {
|
||||
const history = Array.from({ length: 30 }, (_, i) =>
|
||||
sample(`zone_${i}`, 1001 + i)
|
||||
);
|
||||
expect(personActivity(history, SINCE)).toHaveLength(ACTIVITY_MAX_ENTRIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zoneActivity", () => {
|
||||
it("reports arrivals and departures per person, newest first", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("not_home", 900),
|
||||
sample("Work", 1100),
|
||||
sample("not_home", 1300),
|
||||
],
|
||||
"person.bob": [sample("home", 900), sample("Work", 1200)],
|
||||
},
|
||||
["person.anne", "person.bob"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(
|
||||
entries.map((e) => [e.personId, e.arrived, e.when.getTime() / 1000])
|
||||
).toEqual([
|
||||
["person.anne", false, 1300],
|
||||
["person.bob", true, 1200],
|
||||
["person.anne", true, 1100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not count moving between other zones or the window's first sample", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("Work", 900),
|
||||
sample("home", 950),
|
||||
sample("Gym", 1100),
|
||||
],
|
||||
},
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
// Leaving Work happened before the window; Gym is not Work
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts an in-window first sample as an arrival", () => {
|
||||
// A newly created person has no state at the window's start, so their
|
||||
// first sample inside the window is a real arrival
|
||||
const entries = zoneActivity(
|
||||
{ "person.anne": [sample("Work", 1100)] },
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(entries.map((e) => [e.arrived, e.when.getTime() / 1000])).toEqual([
|
||||
[true, 1100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges an unavailable or unknown dropout without an event", () => {
|
||||
const entries = zoneActivity(
|
||||
{
|
||||
"person.anne": [
|
||||
sample("Work", 900),
|
||||
sample("unavailable", 1100),
|
||||
sample("Work", 1150),
|
||||
sample("unknown", 1200),
|
||||
sample("Work", 1250),
|
||||
],
|
||||
},
|
||||
["person.anne"],
|
||||
"Work",
|
||||
SINCE
|
||||
);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user