Compare commits

..
44 changed files with 810 additions and 2280 deletions

No files matched your search

-1
View File
@@ -13,7 +13,6 @@ export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
if (
el.tagName === "TEXTAREA" ||
(el as HTMLElement).isContentEditable ||
el.parentElement?.tagName === "HA-SELECT" ||
el.parentElement?.tagName === "HA-DROPDOWN"
) {
-68
View File
@@ -1,68 +0,0 @@
/**
* Keeps a locally edited value from being overwritten by entity state updates
* while the change is waiting to be sent or the service call is in flight.
*
* Call `schedule()` when the user changes the value and a (debounced) service
* call is queued, wrap the service call in `run()`, and only copy entity state
* into the local value when `shouldSync()` returns true. After a successful
* call the local value is kept until the next state update syncs it. When the
* call fails, the `resync` callback restores the entity state.
*
* Only the latest call is tracked, as it supersedes any earlier ones.
*/
export class PendingValueSync {
private _scheduled = false;
private _latestCall?: symbol;
public get pending(): boolean {
return this._scheduled || this._latestCall !== undefined;
}
/** Mark that a service call has been scheduled for a local change. */
public schedule(): void {
this._scheduled = true;
}
/**
* Start over, e.g. when switching entities, so a pending change is not
* shown for (or sent to) a different entity. Calls already in flight are
* not aborted, but no longer block state updates or trigger `resync`.
*/
public reset(): void {
this._scheduled = false;
this._latestCall = undefined;
}
/** Whether an entity state update may be copied into the local value. */
public shouldSync(): boolean {
return !this.pending;
}
/**
* Run the service call for the scheduled change. If the latest call fails
* and no new change is scheduled, `resync` is called. Errors are not
* rethrown, as `callService` already notifies the user when the call fails.
*/
public async run(
call: () => Promise<unknown>,
resync: () => void
): Promise<void> {
const thisCall = Symbol("call");
this._scheduled = false;
this._latestCall = thisCall;
let failed = false;
try {
await call();
} catch (_err) {
failed = true;
}
if (this._latestCall !== thisCall) {
return;
}
this._latestCall = undefined;
if (failed && !this._scheduled) {
resync();
}
}
}
+27 -73
View File
@@ -4,14 +4,13 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { debounce } from "../common/util/debounce";
import type { ConfigEntry, SubEntry } from "../data/config_entries";
import { getConfigEntry, getSubEntries } from "../data/config_entries";
import type { Agent } from "../data/conversation";
import { listAgents } from "../data/conversation";
import { getExtendedEntityRegistryEntry } from "../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../data/integration";
import { showOptionsFlowDialog } from "../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../dialogs/config-flow/show-dialog-sub-config-flow";
import type { EntityConfigFlow } from "../dialogs/config-flow/entity-config-flow";
import {
fetchEntityConfigFlow,
showEntityConfigFlow,
} from "../dialogs/config-flow/entity-config-flow";
import type { HomeAssistant } from "../types";
import "./ha-select";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
@@ -34,9 +33,7 @@ export class HaConversationAgentPicker extends LitElement {
@state() _agents?: Agent[];
@state() private _configEntry?: ConfigEntry;
@state() private _subConfigEntry?: SubEntry;
@state() private _configFlow?: EntityConfigFlow;
protected render() {
if (!this._agents) {
@@ -79,16 +76,15 @@ export class HaConversationAgentPicker extends LitElement {
.options=${options}
></ha-select
>${
(this._subConfigEntry &&
this._configEntry?.supported_subentry_types[
this._subConfigEntry.subentry_type
]?.supports_reconfigure) ||
this._configEntry?.supports_options
this._configFlow
? html`<ha-icon-button
.label=${this.hass.localize(
"ui.components.conversation-agent-picker.settings"
)}
.path=${mdiCog}
@click=${this._openOptionsFlow}
@click=${this._openConfigFlow}
></ha-icon-button>`
: ""
: nothing
}
`;
}
@@ -102,40 +98,23 @@ export class HaConversationAgentPicker extends LitElement {
}
if (changedProperties.has("value")) {
this._maybeFetchConfigEntry();
this._fetchConfigFlow();
}
}
private async _maybeFetchConfigEntry() {
if (!this.value || !(this.value in this.hass.entities)) {
this._configEntry = undefined;
return;
}
private async _fetchConfigFlow() {
const entityId = this.value;
this._configFlow = undefined;
let configFlow: EntityConfigFlow | undefined;
try {
const regEntry = await getExtendedEntityRegistryEntry(
this.hass,
this.value
);
if (!regEntry.config_entry_id) {
this._configEntry = undefined;
return;
}
this._configEntry = (
await getConfigEntry(this.hass, regEntry.config_entry_id)
).config_entry;
if (!regEntry.config_subentry_id) {
this._subConfigEntry = undefined;
} else {
this._subConfigEntry = (
await getSubEntries(this.hass, regEntry.config_entry_id)
).find((entry) => entry.subentry_id === regEntry.config_subentry_id);
}
configFlow = entityId
? await fetchEntityConfigFlow(this.hass, entityId)
: undefined;
} catch (_err) {
this._configEntry = undefined;
this._subConfigEntry = undefined;
configFlow = undefined;
}
if (entityId === this.value) {
this._configFlow = configFlow;
}
}
@@ -203,35 +182,10 @@ export class HaConversationAgentPicker extends LitElement {
}
}
private async _openOptionsFlow() {
if (!this._configEntry) {
return;
private _openConfigFlow() {
if (this._configFlow) {
showEntityConfigFlow(this, this.hass, this._configFlow);
}
if (
this._subConfigEntry &&
this._configEntry.supported_subentry_types[
this._subConfigEntry.subentry_type
]?.supports_reconfigure
) {
showSubConfigFlowDialog(
this,
this._configEntry,
this._subConfigEntry.subentry_type,
{
startFlowHandler: this._configEntry.entry_id,
subEntryId: this._subConfigEntry.subentry_id,
}
);
return;
}
showOptionsFlowDialog(this, this._configEntry, {
manifest: await fetchIntegrationManifest(
this.hass,
this._configEntry.domain
),
});
}
static styles = css`
+393
View File
@@ -0,0 +1,393 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
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";
/**
* A bottom sheet component that slides up from the bottom of the screen.
*
* The bottom sheet provides a draggable interface that allows users to resize
* 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;
private _opened = false;
@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: ${maxHeight}vh;
--max-height: ${maxHeight}dvh;
--min-height: ${minHeight}vh;
--min-height: ${minHeight}dvh;
`}
>
<div class="handle-wrapper">
<div
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
class="handle"
></div>
</div>
<slot></slot>
</dialog>`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this._openSheet();
}
private _openSheet() {
requestAnimationFrame(() => {
// trigger opening animation
this._dialog.classList.add("show");
});
}
public closeSheet() {
requestAnimationFrame(() => {
this._dialog.classList.remove("show");
});
}
private _handleTransitionEnd() {
if (this._dialog.classList.contains("show")) {
// after show animation is done
// - set the height to the natural height, to prevent content shift when switch content
// - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
this._dialogViewportHeight =
(this._dialog.offsetHeight / window.innerHeight) * 100;
this._dialogMaxViewpointHeight = 90;
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();
fireEvent(this, "bottom-sheet-closed");
}
}
connectedCallback() {
super.connectedCallback();
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
window.addEventListener("resize", this._handleViewportResize);
}
disconnectedCallback() {
super.disconnectedCallback();
// unregister event listeners for drag handling
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
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);
};
private _handleTouchStart = (ev: TouchEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(ev.touches[0].clientY);
};
private _startDrag(clientY: number) {
this._dragging = true;
this._dragStartY = clientY;
this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
document.body.style.setProperty("cursor", "grabbing");
}
private _handleMouseMove = (ev: MouseEvent) => {
if (!this._dragging) {
return;
}
this._updateSize(ev.clientY);
};
private _handleTouchMove = (ev: TouchEvent) => {
if (!this._dragging) {
return;
}
ev.preventDefault(); // Prevent scrolling
this._updateSize(ev.touches[0].clientY);
};
private _updateSize(clientY: number) {
const deltaY = this._dragStartY - clientY;
const viewportHeight = window.innerHeight;
const deltaVh = (deltaY / viewportHeight) * 100;
// Calculate new size and clamp between 10vh and 90vh
let newSize = this._initialSize + deltaVh;
newSize = Math.max(10, Math.min(90, newSize));
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;
}
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();
};
private _handleTouchEnd = () => {
this._endDrag();
};
private _endDrag() {
if (!this._dragging) {
return;
}
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`
.handle-wrapper {
position: absolute;
top: 0;
width: 100%;
padding-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
touch-action: none;
}
.handle-wrapper .handle {
height: 20px;
width: 200px;
display: flex;
justify-content: center;
align-items: center;
z-index: 7;
/* Extends the grab area over the content below the handle */
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
}
.handle-wrapper .handle::after {
content: "";
border-radius: var(--ha-border-radius-md);
height: 4px;
background: var(--divider-color, #e0e0e0);
width: 80px;
}
.handle-wrapper .handle:active::after {
cursor: grabbing;
}
dialog {
height: var(--height, auto);
max-height: min(
var(--max-height, 70vh),
calc(100vh - var(--safe-area-inset-top))
);
max-height: min(
var(--max-height, 70dvh),
calc(100dvh - var(--safe-area-inset-top))
);
min-height: var(--min-height, 30vh);
min-height: var(--min-height, 30dvh);
background-color: var(
--ha-bottom-sheet-surface-background,
var(--ha-color-surface-default)
);
display: flex;
flex-direction: column;
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% - var(--sheet-side-borders) - var(--sheet-inset-left) - var(
--sheet-inset-right
)
);
max-width: 100%;
border: none;
box-shadow: var(--wa-shadow-l);
padding: 0;
margin: 0;
top: auto;
inset-inline-end: auto;
bottom: 0;
inset-inline-start: 0;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
border-top-left-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
border-top-right-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
transform: translateY(100%);
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
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(--sheet-inset-left);
margin-right: var(--sheet-inset-right);
}
dialog.show {
transform: translateY(0);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-resizable-bottom-sheet": HaResizableBottomSheet;
}
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
"bottom-sheet-resized": { height: number };
}
}
+8 -48
View File
@@ -16,9 +16,6 @@ const HALF_VIEWPORT_SHARE = 0.5;
// How far a touch moves before its direction decides what it drags
const TOUCH_SLOP_PX = 4;
// How much of the lowest stop a drag hides before it closes the sheet
const DISMISS_DRAG_SHARE = 0.75;
interface Stop {
position: BottomSheetPosition;
height: number;
@@ -34,11 +31,9 @@ interface Stop {
* them when a drag ends, or to the next one in the direction of a quick swipe.
* The handle drags with a mouse or a finger. On touch the whole sheet drags
* too, until it is at full height: then its content scrolls, and pulling down
* from the top brings the sheet down again. Dragging down collapses it to
* `minHeight` so a host keeps a strip of content reachable. Only `closeSheet`
* closes it, unless it is `dismissable`: then a drag that pulls it below its
* lowest stop closes it with a downward swipe, or once it is almost out of the
* screen. A quick swipe that ends above the lowest stop still stops there.
* from the top brings the sheet down again. Dragging down never closes it; it
* collapses to `minHeight` so a host keeps a strip of content reachable, and
* only `closeSheet` closes it.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels
@@ -66,17 +61,12 @@ export class HaSnapBottomSheet extends LitElement {
/** Names the sheet for assistive technologies */
@property() public label?: string;
@property({ type: Boolean }) public dismissable = false;
@state() private _shown = false;
@state() private _position: BottomSheetPosition = "half";
@state() private _dragHeight?: number;
// How far below its lowest stop a drag has pulled the sheet
@state() private _dismissOffset?: number;
private _gesture = new SwipeGestureRecognizer();
private _dragging = false;
@@ -128,21 +118,13 @@ export class HaSnapBottomSheet extends LitElement {
@click=${this._handleBackdropClick}
></div>
<div
class="sheet ${classMap({
show: this._shown,
full,
dragging,
dismissing: Boolean(this._dismissOffset),
})}"
class="sheet ${classMap({ show: this._shown, full, dragging })}"
popover=${popover}
role="region"
aria-label=${ifDefined(this.label)}
style=${styleMap({
height: this._height(),
"min-height": this._collapsedHeight(),
transform: this._dismissOffset
? `translateY(${this._dismissOffset}px)`
: undefined,
})}
@touchstart=${this._handleTouchStart}
>
@@ -201,7 +183,6 @@ export class HaSnapBottomSheet extends LitElement {
this._dragging = false;
this._pendingTouch = undefined;
this._dragHeight = undefined;
this._dismissOffset = undefined;
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("touchmove", this._handleTouchMove);
@@ -336,20 +317,15 @@ export class HaSnapBottomSheet extends LitElement {
private _startDrag(clientY: number) {
this._dragging = true;
this._dragStartHeight = this._sheet.offsetHeight;
if (this.dismissable) {
this._dismissOffset = 0;
}
this._gesture.start(clientY);
}
private _moveDrag(clientY: number) {
const delta = this._gesture.move(clientY);
const lowestHeight = this._stops()[0].height;
const height = this._dragStartHeight + delta;
this._dragHeight = Math.max(lowestHeight, height);
if (this._dismissOffset !== undefined) {
this._dismissOffset = Math.max(0, lowestHeight - height);
}
this._dragHeight = Math.max(
this._stops()[0].height,
this._dragStartHeight + delta
);
}
private _endDrag() {
@@ -359,18 +335,6 @@ export class HaSnapBottomSheet extends LitElement {
this._dragging = false;
document.body.style.removeProperty("cursor");
const { isSwipe, isDownwardSwipe } = this._gesture.end();
const dismissOffset = this._dismissOffset;
this._dismissOffset = undefined;
if (
dismissOffset &&
((isSwipe && isDownwardSwipe) ||
dismissOffset > this._stops()[0].height * DISMISS_DRAG_SHARE)
) {
this._position = this._stops()[0].position;
this._dragHeight = undefined;
this.closeSheet();
return;
}
this._position = this._stopAfterDrag(
this._sheet.offsetHeight,
isSwipe ? (isDownwardSwipe ? "down" : "up") : undefined
@@ -592,10 +556,6 @@ export class HaSnapBottomSheet extends LitElement {
.sheet.show {
transform: translateY(0);
}
.sheet.dismissing {
transition: none;
}
`;
}
+6 -6
View File
@@ -232,7 +232,7 @@ export const checkForEntityUpdates = async (
// When update is not available and there is no latest_version show "Unavailable"
export const computeUpdateStateDisplay = (
stateObj: UpdateEntity,
options: Pick<HomeAssistant, "localize" | "locale" | "formatEntityState">
hass: HomeAssistant
): string => {
const state = stateObj.state;
const attributes = stateObj.attributes;
@@ -245,14 +245,14 @@ export const computeUpdateStateDisplay = (
supportsFeature(stateObj, UpdateEntityFeature.PROGRESS) &&
attributes.update_percentage !== null;
if (supportsProgress) {
return options.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, options.locale, {
return hass.localize("ui.card.update.installing_with_progress", {
progress: formatNumber(attributes.update_percentage!, hass.locale, {
maximumFractionDigits: attributes.display_precision,
minimumFractionDigits: attributes.display_precision,
}),
});
}
return options.localize("ui.card.update.installing");
return hass.localize("ui.card.update.installing");
}
if (state === "off") {
@@ -262,10 +262,10 @@ export const computeUpdateStateDisplay = (
if (isSkipped) {
return attributes.latest_version!;
}
return options.formatEntityState(stateObj);
return hass.formatEntityState(stateObj);
}
return options.formatEntityState(stateObj);
return hass.formatEntityState(stateObj);
};
export type UpdateType =
@@ -0,0 +1,59 @@
import type { ConfigEntry, SubEntry } from "../../data/config_entries";
import { getConfigEntry, getSubEntries } from "../../data/config_entries";
import { getExtendedEntityRegistryEntry } from "../../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { showOptionsFlowDialog } from "./show-dialog-options-flow";
import { showSubConfigFlowDialog } from "./show-dialog-sub-config-flow";
export interface EntityConfigFlow {
entry: ConfigEntry;
subEntry?: SubEntry;
}
export const fetchEntityConfigFlow = async (
hass: HomeAssistant,
entityId: string
): Promise<EntityConfigFlow | undefined> => {
if (!(entityId in hass.entities)) {
return undefined;
}
const regEntry = await getExtendedEntityRegistryEntry(hass, entityId);
if (!regEntry.config_entry_id) {
return undefined;
}
const { config_entry: entry } = await getConfigEntry(
hass,
regEntry.config_entry_id
);
if (regEntry.config_subentry_id) {
const subEntry = (await getSubEntries(hass, entry.entry_id)).find(
(sub) => sub.subentry_id === regEntry.config_subentry_id
);
if (
subEntry &&
entry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
) {
return { entry, subEntry };
}
}
return entry.supports_options ? { entry } : undefined;
};
export const showEntityConfigFlow = async (
element: HTMLElement,
hass: HomeAssistant,
{ entry, subEntry }: EntityConfigFlow
) => {
if (subEntry) {
showSubConfigFlowDialog(element, entry, subEntry.subentry_type, {
startFlowHandler: entry.entry_id,
subEntryId: subEntry.subentry_id,
});
return;
}
showOptionsFlowDialog(element, entry, {
manifest: await fetchIntegrationManifest(hass, entry.domain),
});
};
@@ -116,7 +116,6 @@ import "./types/ha-automation-action-stop";
import "./types/ha-automation-action-wait_for_trigger";
import "./types/ha-automation-action-wait_template";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
export interface ActionElement extends LitElement {
action: Action;
@@ -1241,7 +1240,10 @@ export default class HaAutomationActionRow extends LitElement {
if (this.narrow) {
window.setTimeout(() => {
scrollRowIntoView(this);
this.scrollIntoView({
block: "start",
behavior: "smooth",
});
}, 180); // duration of transition of added padding for bottom sheet
}
}
@@ -189,6 +189,12 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
(!ACTION_BUILDING_BLOCKS.includes(type) || mode === "moved")
) {
row.openSidebar();
if (this.narrow) {
row.scrollIntoView({
block: "start",
behavior: "smooth",
});
}
}
if (mode === "new") {
@@ -87,7 +87,6 @@ import "./types/ha-automation-condition-time";
import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -953,7 +952,10 @@ export default class HaAutomationConditionRow extends LitElement {
if (this.narrow) {
window.setTimeout(() => {
scrollRowIntoView(this);
this.scrollIntoView({
block: "start",
behavior: "smooth",
});
}, 180); // duration of transition of added padding for bottom sheet
}
}
@@ -141,6 +141,12 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
mode === "moved")
) {
row.openSidebar();
if (this.narrow) {
row.scrollIntoView({
block: "start",
behavior: "smooth",
});
}
}
if (mode === "new") {
@@ -5,8 +5,8 @@ import { consume } from "../../../common/decorators/consume";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-snap-bottom-sheet";
import type { HaSnapBottomSheet } from "../../../components/ha-snap-bottom-sheet";
import "../../../components/ha-resizable-bottom-sheet";
import type { HaResizableBottomSheet } from "../../../components/ha-resizable-bottom-sheet";
import {
isCondition,
isScriptField,
@@ -28,9 +28,6 @@ import "./sidebar/ha-automation-sidebar-script-field";
import "./sidebar/ha-automation-sidebar-script-field-selector";
import "./sidebar/ha-automation-sidebar-trigger";
// The sidebar card header: the dialog header's 48px minimum plus its padding
const COLLAPSED_SHEET_HEIGHT = 68;
@customElement("ha-automation-sidebar")
export default class HaAutomationSidebar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -54,8 +51,8 @@ export default class HaAutomationSidebar extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@query("ha-snap-bottom-sheet")
private _bottomSheetElement?: HaSnapBottomSheet;
@query("ha-resizable-bottom-sheet")
private _bottomSheetElement?: HaResizableBottomSheet;
@query(".handle")
private _handleElement?: HTMLDivElement;
@@ -66,12 +63,6 @@ export default class HaAutomationSidebar extends LitElement {
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
changedProperties.has("sidebarKey") &&
this._bottomSheetElement?.position === "collapsed"
) {
this._bottomSheetElement.snapTo("half");
}
if (changedProperties.has("config") || changedProperties.has("narrow")) {
if (!this.config || this.narrow) {
this._tinykeysUnsub?.();
@@ -195,16 +186,9 @@ export default class HaAutomationSidebar extends LitElement {
if (this.narrow) {
return html`
<ha-snap-bottom-sheet
dismissable
.minHeight=${COLLAPSED_SHEET_HEIGHT}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.sidebar_label"
)}
@bottom-sheet-closed=${this._closeSidebar}
>
<ha-resizable-bottom-sheet @bottom-sheet-closed=${this._closeSidebar}>
${this._renderContent()}
</ha-snap-bottom-sheet>
</ha-resizable-bottom-sheet>
`;
}
@@ -408,6 +392,9 @@ export default class HaAutomationSidebar extends LitElement {
var(--ha-border-radius-2xl)
);
border-radius: var(--ha-card-border-radius);
--ha-bottom-sheet-border-width: 2px;
--ha-bottom-sheet-border-style: solid;
--ha-bottom-sheet-border-color: var(--primary-color);
margin-top: var(--safe-area-inset-top);
--ha-bottom-sheet-surface-background: var(--card-background-color);
@@ -8,10 +8,8 @@ import {
} from "lit";
import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
@@ -70,8 +68,6 @@ export const ManualEditorMixin = <TConfig>(
@state() protected sidebarKey = 0;
@state() private _bottomSheetHeight?: number;
@storage({
key: "automation-sidebar-width",
state: false,
@@ -120,18 +116,12 @@ export const ManualEditorMixin = <TConfig>(
"has-sidebar": this.sidebarConfig && !this.narrow,
})}
>
<div
class="content-wrapper ${
this.sidebarConfig && this.narrow ? "has-bottom-sheet" : ""
}"
style=${styleMap({
"--automation-bottom-sheet-settled-height":
this._bottomSheetHeight === undefined
? undefined
: `${this._bottomSheetHeight}px`,
})}
>
<div class="content">
<div class="content-wrapper">
<div
class="content ${
this.sidebarConfig && this.narrow ? "has-bottom-sheet" : ""
}"
>
<slot name="alerts"></slot>
${this.renderContent()}
</div>
@@ -162,7 +152,6 @@ export const ManualEditorMixin = <TConfig>(
@sidebar-resized=${this.resizeSidebar}
@sidebar-resizing-stopped=${this.stopResizeSidebar}
@sidebar-reset-size=${this.resetSidebarWidth}
@bottom-sheet-resized=${this._handleBottomSheetResized}
></ha-automation-sidebar>
</div>
</div>
@@ -192,8 +181,6 @@ export const ManualEditorMixin = <TConfig>(
// deselect previous selected row
this.sidebarConfig?.close?.();
this.sidebarConfig = ev.detail;
// The sheet opens items at half height, so make room before rows scroll
this._bottomSheetHeight = undefined;
this.dispatchEvent(
new CustomEvent("sidebar-config-changed", {
detail: { value: this.sidebarConfig },
@@ -239,15 +226,8 @@ export const ManualEditorMixin = <TConfig>(
}
}
private _handleBottomSheetResized(
ev: HASSDomEvent<HASSDomEvents["bottom-sheet-resized"]>
) {
this._bottomSheetHeight = ev.detail.height;
}
protected handleCloseSidebar() {
this.sidebarConfig = undefined;
this._bottomSheetHeight = undefined;
this.dispatchEvent(
new CustomEvent("sidebar-config-changed", {
detail: { value: undefined },
@@ -53,7 +53,6 @@ import {
rowStyles,
} from "../styles";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -591,7 +590,10 @@ export default class HaAutomationOptionRow extends LitElement {
if (this.narrow) {
window.setTimeout(() => {
scrollRowIntoView(this);
this.scrollIntoView({
block: "start",
behavior: "smooth",
});
}, 180); // duration of transition of added padding for bottom sheet
}
}
@@ -13,7 +13,6 @@ import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixi
import { automationRowsStyles } from "../styles";
import "./ha-automation-option-row";
import type HaAutomationOptionRow from "./ha-automation-option-row";
import { scrollRowIntoView } from "../scroll-row-into-view";
@customElement("ha-automation-option")
export default class HaAutomationOption extends AutomationSortableListMixin<Option>(
@@ -142,6 +141,13 @@ export default class HaAutomationOption extends AutomationSortableListMixin<Opti
this.focusItemIndexOnChange = undefined;
row.updateComplete.then(() => {
if (this.narrow) {
row.scrollIntoView({
block: "start",
behavior: "smooth",
});
}
if (mode === "new") {
row.expand();
}
@@ -149,9 +155,6 @@ export default class HaAutomationOption extends AutomationSortableListMixin<Opti
if (this.optionsInSidebar) {
row.openSidebar();
} else {
if (this.narrow) {
scrollRowIntoView(row);
}
row.focus();
}
});
@@ -1,48 +0,0 @@
const parentInFlatTree = (element: Element): Element | null => {
if (element.assignedSlot) {
return element.assignedSlot;
}
if (element.parentElement) {
return element.parentElement;
}
const root = element.getRootNode();
return root instanceof ShadowRoot ? root.host : null;
};
const scrollContainer = (element: Element): Element | null => {
for (
let parent = parentInFlatTree(element);
parent;
parent = parentInFlatTree(parent)
) {
const { overflowY } = getComputedStyle(parent);
if (overflowY === "auto" || overflowY === "scroll") {
return parent;
}
}
return null;
};
/**
* Brings a row into the part of the editor that is visible above the bottom
* sheet, which covers as much as the row's scroll-margin-bottom. A row already
* there stays put; otherwise it is centered there, or aligned to the top when
* it is taller than that space.
*/
export const scrollRowIntoView = (row: HTMLElement) => {
const container = scrollContainer(row)?.getBoundingClientRect() ?? {
top: 0,
bottom: window.innerHeight,
};
const style = getComputedStyle(row);
const visibleTop = container.top + parseFloat(style.scrollMarginTop);
const visibleBottom = container.bottom - parseFloat(style.scrollMarginBottom);
const { top, bottom } = row.getBoundingClientRect();
if (top >= visibleTop && bottom <= visibleBottom) {
return;
}
row.scrollIntoView({
block: bottom - top > visibleBottom - visibleTop ? "start" : "center",
behavior: "smooth",
});
};
+2 -30
View File
@@ -231,29 +231,8 @@ export const manualEditorStyles = css`
transition: padding-bottom 180ms ease-in-out;
}
.has-bottom-sheet {
--automation-bottom-sheet-height: 50vh;
/* The backdrop covers the editor when the sheet is at full height */
--automation-bottom-sheet-covered-height: min(
var(--automation-bottom-sheet-settled-height, 100vh),
var(--automation-bottom-sheet-height)
);
}
/* A custom property can't fall back from dvh to vh by being declared twice */
@supports (height: 1dvh) {
.has-bottom-sheet {
--automation-bottom-sheet-height: 50dvh;
}
}
.has-bottom-sheet .content {
padding-bottom: calc(
var(--automation-bottom-sheet-covered-height) + var(--ha-space-4)
);
}
.has-bottom-sheet .fab-positioner ha-button[slot="fab"].dirty {
bottom: calc(
var(--automation-bottom-sheet-covered-height) + var(--ha-space-4)
);
.content.has-bottom-sheet {
padding-bottom: calc(90vh - max(var(--safe-area-inset-bottom), 32px));
}
ha-automation-sidebar {
@@ -302,13 +281,6 @@ export const automationRowsStyles = css`
display: block;
scroll-margin-top: 48px;
}
/* The part the open bottom sheet covers, see scrollRowIntoView */
ha-automation-trigger-row,
ha-automation-condition-row,
ha-automation-action-row,
ha-automation-option-row {
scroll-margin-bottom: var(--automation-bottom-sheet-height, 0px);
}
.handle {
padding: 4px;
cursor: move; /* fallback if grab cursor is unsupported */
@@ -104,7 +104,6 @@ import "./types/ha-automation-trigger-time_pattern";
import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
export interface TriggerElement extends LitElement {
trigger: Trigger;
@@ -834,7 +833,10 @@ export default class HaAutomationTriggerRow extends LitElement {
if (this.narrow) {
window.setTimeout(() => {
scrollRowIntoView(this);
this.scrollIntoView({
block: "start",
behavior: "smooth",
});
}, 180);
}
}
@@ -237,6 +237,12 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
row.updateComplete.then(() => {
if (this.optionsInSidebar) {
row.openSidebar();
if (this.narrow) {
row.scrollIntoView({
block: "start",
behavior: "smooth",
});
}
} else {
row.expand();
row.focus();
+101 -30
View File
@@ -1,7 +1,7 @@
import { mdiHelpCircleOutline, mdiStarFourPoints } from "@mdi/js";
import { mdiCog, mdiHelpCircleOutline, mdiStarFourPoints } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -10,6 +10,7 @@ import type { HaProgressButton } from "../../../components/buttons/ha-progress-b
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import {
AITaskEntityFeature,
@@ -17,6 +18,11 @@ import {
saveAITaskPreferences,
type AITaskPreferences,
} from "../../../data/ai_task";
import type { EntityConfigFlow } from "../../../dialogs/config-flow/entity-config-flow";
import {
fetchEntityConfigFlow,
showEntityConfigFlow,
} from "../../../dialogs/config-flow/entity-config-flow";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { documentationUrl } from "../../../util/documentation-url";
@@ -36,6 +42,10 @@ export class AITaskPref extends LitElement {
@state() private _prefs?: AITaskPreferences;
@state() private _configFlows: Partial<
Record<keyof AITaskPreferences, EntityConfigFlow>
> = {};
private _gen_data_entity_id?: string | null;
private _gen_image_entity_id?: string | null;
@@ -47,6 +57,8 @@ export class AITaskPref extends LitElement {
}
fetchAITaskPreferences(this.hass).then((prefs) => {
this._prefs = prefs;
this._updateConfigFlow("gen_data_entity_id");
this._updateConfigFlow("gen_image_entity_id");
});
}
@@ -97,18 +109,11 @@ export class AITaskPref extends LitElement {
"ui.panel.config.ai_task.gen_data_description"
)}
</span>
<ha-entity-picker
data-name="gen_data_entity_id"
.disabled=${
this._prefs === undefined &&
isComponentLoaded(this.hass.config, "ai_task")
}
.value=${
this._gen_data_entity_id || this._prefs?.gen_data_entity_id
}
.entityFilter=${filterGenData}
@value-changed=${this._handlePrefChange}
></ha-entity-picker>
${this._renderPicker(
"gen_data_entity_id",
filterGenData,
this.hass.localize("ui.panel.config.ai_task.gen_data_settings")
)}
</ha-settings-row>
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
@@ -119,18 +124,11 @@ export class AITaskPref extends LitElement {
"ui.panel.config.ai_task.gen_image_description"
)}
</span>
<ha-entity-picker
data-name="gen_image_entity_id"
.disabled=${
this._prefs === undefined &&
isComponentLoaded(this.hass.config, "ai_task")
}
.value=${
this._gen_image_entity_id || this._prefs?.gen_image_entity_id
}
.entityFilter=${filterGenImage}
@value-changed=${this._handlePrefChange}
></ha-entity-picker>
${this._renderPicker(
"gen_image_entity_id",
filterGenImage,
this.hass.localize("ui.panel.config.ai_task.gen_image_settings")
)}
</ha-settings-row>
</div>
<div class="card-actions">
@@ -142,11 +140,74 @@ export class AITaskPref extends LitElement {
`;
}
private _renderPicker(
key: keyof AITaskPreferences,
entityFilter: (entity: HassEntity) => boolean,
settingsLabel: string
) {
const configFlow = this._configFlows[key];
return html`
<div class="picker">
<ha-entity-picker
data-name=${key}
.disabled=${
this._prefs === undefined &&
isComponentLoaded(this.hass.config, "ai_task")
}
.value=${this._entityId(key) ?? undefined}
.entityFilter=${entityFilter}
@value-changed=${this._handlePrefChange}
></ha-entity-picker>
${
configFlow
? html`<ha-icon-button
data-name=${key}
.label=${settingsLabel}
.path=${mdiCog}
@click=${this._openConfigFlow}
></ha-icon-button>`
: nothing
}
</div>
`;
}
private _entityId(key: keyof AITaskPreferences) {
const value = this[`_${key}`];
return value === undefined ? this._prefs?.[key] : value;
}
private async _updateConfigFlow(key: keyof AITaskPreferences) {
const entityId = this._entityId(key);
this._configFlows = { ...this._configFlows, [key]: undefined };
let configFlow: EntityConfigFlow | undefined;
try {
configFlow = entityId
? await fetchEntityConfigFlow(this.hass, entityId)
: undefined;
} catch (_err) {
configFlow = undefined;
}
if (entityId === this._entityId(key)) {
this._configFlows = { ...this._configFlows, [key]: configFlow };
}
}
private _handlePrefChange(ev: ValueChangedEvent<string | undefined>) {
const input = ev.target as HaEntityPicker;
const key = input.dataset.name as keyof AITaskPreferences;
const value = ev.detail.value || null;
this[`_${key}`] = value;
this._updateConfigFlow(key);
}
private _openConfigFlow(ev: Event) {
const key = (ev.currentTarget as HTMLElement).dataset
.name as keyof AITaskPreferences;
const configFlow = this._configFlows[key];
if (configFlow) {
showEntityConfigFlow(this, this.hass, configFlow);
}
}
private async _update(ev) {
@@ -207,12 +268,22 @@ export class AITaskPref extends LitElement {
.card-actions {
text-align: right;
}
ha-entity-picker {
.picker {
display: flex;
align-items: center;
flex: 1;
margin-left: 16px;
min-width: 0;
margin-inline-start: var(--ha-space-4);
}
:host([narrow]) ha-entity-picker {
margin-left: 0;
:host([narrow]) .picker {
margin-inline-start: 0;
}
.picker ha-entity-picker {
flex: 1;
min-width: 0;
}
.picker ha-icon-button {
color: var(--secondary-text-color);
}
`;
}
@@ -27,7 +27,6 @@ import { showPromptDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { showEditorToast } from "../automation/editor-toast";
import { scrollRowIntoView } from "../automation/scroll-row-into-view";
import { indentStyle, overflowStyles, rowStyles } from "../automation/styles";
import "./ha-script-field-selector-editor";
import type HaScriptFieldSelectorEditor from "./ha-script-field-selector-editor";
@@ -439,7 +438,10 @@ export default class HaScriptFieldRow extends LitElement {
if (this.narrow) {
window.setTimeout(() => {
scrollRowIntoView(this);
this.scrollIntoView({
block: "start",
behavior: "smooth",
});
}, 180); // duration of transition of added padding for bottom sheet
}
}
+9 -1
View File
@@ -75,6 +75,15 @@ export default class HaScriptFields extends LitElement {
row.updateComplete.then(() => {
row.openSidebar();
row.focus();
if (this.narrow) {
window.setTimeout(() => {
row.scrollIntoView({
block: "start",
behavior: "smooth",
});
}, 180); // duration of transition of added padding for bottom sheet
}
});
}
@@ -153,7 +162,6 @@ export default class HaScriptFields extends LitElement {
display: block;
margin-bottom: 16px;
scroll-margin-top: 48px;
scroll-margin-bottom: var(--automation-bottom-sheet-height, 0px);
}
ha-svg-icon {
height: 20px;
@@ -182,6 +182,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
const stateDisplay = html`
<state-display
.stateObj=${stateObj}
.hass=${this.hass}
.content=${this._config.state_content}
.timeFormat=${this._config.time_format}
.name=${name}
@@ -8,7 +8,6 @@ import { transform } from "../../../common/decorators/transform";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { PendingValueSync } from "../../../common/util/pending-value-sync";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-control-slider";
import type { ClimateEntity } from "../../../data/climate";
@@ -86,8 +85,6 @@ class HuiTargetHumidityCardFeature
@state() private _targetHumidity?: number;
private _pendingSync = new PendingValueSync();
static getStubConfig(): TargetHumidityCardFeatureConfig {
return {
type: "target-humidity",
@@ -109,23 +106,9 @@ class HuiTargetHumidityCardFeature
protected willUpdate(changedProp: PropertyValues): void {
super.willUpdate(changedProp);
if (!changedProp.has("_stateObj")) {
return;
if (changedProp.has("_stateObj") && this._stateObj) {
this._targetHumidity = this._stateObj.attributes.humidity;
}
const oldStateObj = changedProp.get("_stateObj") as HassEntity | undefined;
if (oldStateObj?.entity_id !== this._stateObj?.entity_id) {
this._pendingSync.reset();
this._syncTargetHumidity();
} else if (this._pendingSync.shouldSync()) {
this._syncTargetHumidity();
}
}
private _syncTargetHumidity() {
if (!this._stateObj) {
return;
}
this._targetHumidity = this._stateObj.attributes.humidity;
}
private get _min() {
@@ -145,14 +128,10 @@ class HuiTargetHumidityCardFeature
private _callService() {
const domain = computeStateDomain(this._stateObj!);
const data = {
this._api.callService(domain, "set_humidity", {
entity_id: this._stateObj!.entity_id,
humidity: this._targetHumidity,
};
return this._pendingSync.run(
() => this._api.callService(domain, "set_humidity", data),
() => this._syncTargetHumidity()
);
});
}
protected render() {
@@ -169,7 +148,7 @@ class HuiTargetHumidityCardFeature
return html`
<ha-control-button-group>
<ha-control-number-buttons
.value=${this._targetHumidity}
.value=${this._stateObj.attributes.humidity}
.min=${this._min}
.max=${this._max}
.disabled=${this._stateObj.state === UNAVAILABLE}
@@ -188,7 +167,7 @@ class HuiTargetHumidityCardFeature
return html`
<ha-control-slider
.value=${this._targetHumidity}
.value=${this._stateObj.attributes.humidity}
.min=${this._min}
.max=${this._max}
.disabled=${this._stateObj!.state === UNAVAILABLE}
@@ -13,7 +13,6 @@ import { computeStateDomain } from "../../../common/entity/compute_state_domain"
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { debounce } from "../../../common/util/debounce";
import { PendingValueSync } from "../../../common/util/pending-value-sync";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-number-buttons";
import type { ClimateEntity } from "../../../data/climate";
@@ -108,8 +107,6 @@ class HuiTargetTemperatureCardFeature
@state() private _targetTemperature: Partial<Record<Target, number>> = {};
private _pendingSync = new PendingValueSync();
static getStubConfig(): TargetTemperatureCardFeatureConfig {
return {
type: "target-temperature",
@@ -125,34 +122,19 @@ class HuiTargetTemperatureCardFeature
protected willUpdate(changedProp: PropertyValues): void {
super.willUpdate(changedProp);
if (!changedProp.has("_stateObj")) {
return;
if (changedProp.has("_stateObj") && this._stateObj) {
this._targetTemperature = {
value: this._stateObj.attributes.temperature,
low:
"target_temp_low" in this._stateObj.attributes
? this._stateObj.attributes.target_temp_low
: undefined,
high:
"target_temp_high" in this._stateObj.attributes
? this._stateObj.attributes.target_temp_high
: undefined,
};
}
const oldStateObj = changedProp.get("_stateObj") as HassEntity | undefined;
if (oldStateObj?.entity_id !== this._stateObj?.entity_id) {
this._debouncedCallService.cancel();
this._pendingSync.reset();
this._syncTargetTemperature();
} else if (this._pendingSync.shouldSync()) {
this._syncTargetTemperature();
}
}
private _syncTargetTemperature() {
if (!this._stateObj) {
return;
}
this._targetTemperature = {
value: this._stateObj.attributes.temperature,
low:
"target_temp_low" in this._stateObj.attributes
? this._stateObj.attributes.target_temp_low
: undefined,
high:
"target_temp_high" in this._stateObj.attributes
? this._stateObj.attributes.target_temp_high
: undefined,
};
}
private get _step() {
@@ -182,7 +164,6 @@ class HuiTargetTemperatureCardFeature
...this._targetTemperature,
[target]: value,
};
this._pendingSync.schedule();
this._debouncedCallService(target);
}
@@ -193,21 +174,18 @@ class HuiTargetTemperatureCardFeature
private _callService(type: string) {
const domain = computeStateDomain(this._stateObj!);
const data =
type === "high" || type === "low"
? {
entity_id: this._stateObj!.entity_id,
target_temp_low: this._targetTemperature.low,
target_temp_high: this._targetTemperature.high,
}
: {
entity_id: this._stateObj!.entity_id,
temperature: this._targetTemperature.value,
};
return this._pendingSync.run(
() => this._api.callService(domain, "set_temperature", data),
() => this._syncTargetTemperature()
);
if (type === "high" || type === "low") {
this._api.callService(domain, "set_temperature", {
entity_id: this._stateObj!.entity_id,
target_temp_low: this._targetTemperature.low,
target_temp_high: this._targetTemperature.high,
});
return;
}
this._api.callService(domain, "set_temperature", {
entity_id: this._stateObj!.entity_id,
temperature: this._targetTemperature.value,
});
}
private _supportsTarget() {
@@ -265,7 +243,7 @@ class HuiTargetTemperatureCardFeature
<ha-control-number-buttons
.formatOptions=${options}
.target=${"value"}
.value=${this._targetTemperature.value}
.value=${this._stateObj.attributes.temperature}
.unit=${this._hassConfig?.unit_system.temperature}
.min=${this._min}
.max=${this._max}
@@ -292,6 +292,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
: html`
<state-display
.stateObj=${stateObj}
.hass=${this.hass}
.content=${this._config.state_content}
.timeFormat=${this._config.time_format}
>
@@ -1,7 +1,3 @@
import type {
HassEntity,
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../../../common/array/ensure-array";
import {
checkTimeInRange,
@@ -11,7 +7,6 @@ import {
WEEKDAYS_SHORT,
type WeekdayShort,
} from "../../../common/datetime/weekday";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import type {
NumericStateCondition as CoreNumericStateCondition,
@@ -24,7 +19,6 @@ import type {
import type { DeviceCondition } from "../../../data/device/device_automation";
import { UNKNOWN } from "../../../data/entity/entity";
import { getUserPerson } from "../../../data/person";
import { resolveEntityIDs } from "../../../data/selector";
import type { HomeAssistant } from "../../../types";
export type Condition =
@@ -61,25 +55,9 @@ export interface ViewColumnsCondition extends BaseCondition {
max?: number;
}
export const LOCATION_MATCH_MODES = ["any_zone", "active_zone"] as const;
export type LocationMatchMode = (typeof LOCATION_MATCH_MODES)[number];
export interface LocationCondition extends BaseCondition {
condition: "location";
/** Legacy: matches the person state (active zone name). */
locations?: string[];
/** Zones selected directly or through a label, area or floor. */
target?: HassServiceTarget;
/**
* `any_zone` (default) matches the zones in the person `in_zones`.
* `active_zone` matches only the zone shown as the person state, like
* `locations`, so passive zones never match. Both match the state when
* `in_zones` is empty, as it is for legacy device trackers.
*/
match_mode?: LocationMatchMode;
/** Matches the person state `not_home`, which passive zones don't change. */
away?: boolean;
}
export interface NumericStateCondition extends BaseCondition {
@@ -306,148 +284,7 @@ function checkLocationCondition(
if (!stateObj) {
return false;
}
if (condition.locations?.includes(stateObj.state)) {
return true;
}
// Passive zones don't change the state, so this can match while `in_zones`
// lists one.
if (condition.away === true && stateObj.state === "not_home") {
return true;
}
if (!isValidTarget(condition.target)) {
return false;
}
const zones = resolveLocationZones(hass, condition.target);
const inZones = stateObj.attributes.in_zones;
// Legacy device trackers don't report `in_zones` (person only adds
// zone.home for them), so their state, the active zone name, is all there
// is. Trackers that do report it have an empty list only when `not_home`.
if (!Array.isArray(inZones) || inZones.length === 0) {
// A zone can be named "not_home", but that state means away.
return (
stateObj.state !== "not_home" &&
zones.some((zoneId) => activeZoneState(hass, zoneId) === stateObj.state)
);
}
if (condition.match_mode === "active_zone") {
// Zone names aren't unique, so find the zone the person is in that set
// their state, rather than comparing the selected zones' names.
const activeZone = inZones.find(
(zoneId) => activeZoneState(hass, zoneId) === stateObj.state
);
return activeZone !== undefined && zones.includes(activeZone);
}
return inZones.some((zone) => zones.includes(zone));
}
/**
* The person state while a zone is their active zone: `home` for zone.home,
* otherwise the zone name. Passive zones never become the state.
*/
function activeZoneState(
hass: HomeAssistant,
zoneId: string
): string | undefined {
const stateObj = hass.states[zoneId];
if (!stateObj || stateObj.attributes.passive) {
return undefined;
}
return zoneId === "zone.home" ? "home" : stateObj.attributes.friendly_name;
}
const TARGET_KEYS = [
"entity_id",
"device_id",
"area_id",
"floor_id",
"label_id",
] as const;
const isIdList = (value: unknown): boolean =>
typeof value === "string" ||
(Array.isArray(value) && value.every((id) => typeof id === "string"));
/**
* YAML can hold anything here. Like `targetStruct`, only a mapping of target
* keys to IDs is usable; anything else would throw while resolving zones.
*/
const isValidTarget = (target: unknown): target is HassServiceTarget =>
typeof target === "object" &&
target !== null &&
!Array.isArray(target) &&
Object.entries(target).every(
([key, value]) =>
(TARGET_KEYS as readonly string[]).includes(key) && isIdList(value)
);
const ZONE_TARGET_SELECTOR = { target: { entity: { domain: "zone" } } };
interface ResolvedZones {
entities: HomeAssistant["entities"];
devices: HomeAssistant["devices"];
areas: HomeAssistant["areas"];
zones: string[];
}
// Keyed by target so each card keeps its own entry. Resolving scans the
// registries, so only redo it when one of them changes.
const resolvedZonesCache = new WeakMap<HassServiceTarget, ResolvedZones>();
/**
* Zone entity IDs selected directly or through a label, area or floor, that
* currently have a state.
*/
export function resolveLocationZones(
hass: HomeAssistant,
target: HassServiceTarget
): string[] {
// Checked on every call rather than cached, so a zone that loses its state
// is dropped and a directly selected zone that appears later is picked up.
return resolveTargetZones(hass, target).filter(
(zoneId) => zoneId in hass.states
);
}
function resolveTargetZones(
hass: HomeAssistant,
target: HassServiceTarget
): string[] {
const cached = resolvedZonesCache.get(target);
if (
cached &&
cached.entities === hass.entities &&
cached.devices === hass.devices &&
cached.areas === hass.areas
) {
return cached.zones;
}
// Resolving skips entities without a state, such as zones while they
// reload. Give registered zones a placeholder state so the result only
// depends on the registries; zones without a state are left out per check.
let states = hass.states;
for (const entityId of Object.keys(hass.entities)) {
if (computeDomain(entityId) === "zone" && !(entityId in states)) {
if (states === hass.states) {
states = { ...hass.states };
}
states[entityId] = { entity_id: entityId, attributes: {} } as HassEntity;
}
}
const zones = resolveEntityIDs(
states === hass.states ? hass : { ...hass, states },
target,
hass.entities,
hass.devices,
hass.areas,
ZONE_TARGET_SELECTOR
).filter((entityId) => computeDomain(entityId) === "zone");
resolvedZonesCache.set(target, {
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
zones,
});
return zones;
return condition.locations?.includes(stateObj.state);
}
function checkUserCondition(condition: UserCondition, hass: HomeAssistant) {
@@ -611,31 +448,7 @@ function validateUserCondition(condition: UserCondition) {
}
function validateLocationCondition(condition: LocationCondition) {
if (condition.target != null && !isValidTarget(condition.target)) {
return false;
}
if (condition.away != null && typeof condition.away !== "boolean") {
return false;
}
if (
condition.match_mode != null &&
!LOCATION_MATCH_MODES.includes(condition.match_mode)
) {
return false;
}
// `locations` matches the person state while `target` defaults to
// `in_zones`, so mixing them can't keep both when the editor converts it.
if (
condition.locations != null &&
(condition.target != null || condition.match_mode != null)
) {
return false;
}
return (
condition.locations != null ||
condition.target != null ||
condition.away === true
);
return condition.locations != null;
}
function validateAndCondition(condition: AndCondition) {
@@ -97,6 +97,7 @@ export class HuiGenericEntityRow extends LitElement {
this.secondaryText ||
html`<state-display
.stateObj=${stateObj}
.hass=${this.hass}
.content=${this.config.secondary_info}
.timeFormat=${this.config.time_format}
.name=${name}
@@ -1,136 +1,30 @@
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
boolean,
enums,
literal,
object,
optional,
string,
} from "superstruct";
import type { HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { array, assert, literal, object, string } from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeStateName } from "../../../../../common/entity/compute_state_name";
import { formatListWithAnds } from "../../../../../common/string/format-list";
import { slugify } from "../../../../../common/string/slugify";
import { deepEqual } from "../../../../../common/util/deep-equal";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../../components/ha-form/types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { targetStruct } from "../../../../../data/script";
import "../../../../../components/ha-switch";
import "../../../../../components/ha-list";
import type { HomeAssistant } from "../../../../../types";
import {
LOCATION_MATCH_MODES,
resolveLocationZones,
type LocationCondition,
} from "../../../common/validate-condition";
import type { LocationCondition } from "../../../common/validate-condition";
import "../../../../../components/ha-form/ha-form";
const locationConditionStruct = object({
condition: literal("location"),
locations: optional(array(string())),
target: optional(targetStruct),
match_mode: optional(enums(LOCATION_MATCH_MODES)),
away: optional(boolean()),
locations: array(string()),
});
const LOCALIZE_PREFIX =
"ui.panel.lovelace.editor.condition-editor.condition.location";
/**
* Pick an unused zone ID for a name that matches no zone, adding `_2`, `_3`
* and so on like Home Assistant does for new entities. A renamed zone keeps
* its old ID, so the plain slug could select a zone the name never matched.
* Registered zones count as taken even without a state, such as while they
* reload. `reserved` holds IDs already picked for other names that slugify
* the same.
*/
function missingZoneId(
name: string,
hass: HomeAssistant,
reserved: Set<string>
): string {
const baseId = `zone.${slugify(name)}`;
let entityId = baseId;
let i = 2;
while (
entityId in hass.states ||
entityId in hass.entities ||
reserved.has(entityId)
) {
entityId = `${baseId}_${i}`;
i++;
}
return entityId;
}
/**
* Convert `locations` (zone names) to a zone target. `active_zone` matches
* the person state like `locations` did, so the condition behaves the same.
*/
export function migrateLocationCondition(
condition: LocationCondition,
hass: HomeAssistant
): LocationCondition {
// The person state is "home" for zone.home, otherwise the zone name.
// Zone names aren't unique, and the old condition matched every zone with
// the name, so keep all of them.
const zoneIdsByName = new Map<string, string[]>();
for (const stateObj of Object.values(hass.states)) {
const name = stateObj.attributes.friendly_name;
if (
stateObj.entity_id.startsWith("zone.") &&
stateObj.entity_id !== "zone.home" &&
name
) {
zoneIdsByName.set(name, [
...(zoneIdsByName.get(name) ?? []),
stateObj.entity_id,
]);
}
}
// Validation rejects `locations` with `target`, but `away` can be set too.
const entityIds = new Set<string>();
let away = condition.away === true;
const missingIds = new Set<string>();
for (const name of new Set(condition.locations ?? [])) {
// A zone can also be named "home" or "not_home", and the old condition
// matched it too, so these still collect zones by name.
if (name === "not_home") {
away = true;
} else if (name === "home") {
entityIds.add("zone.home");
}
const zoneIds = zoneIdsByName.get(name);
if (zoneIds) {
for (const entityId of zoneIds) {
entityIds.add(entityId);
}
} else if (name !== "home" && name !== "not_home") {
// Names that match no zone are kept, so the picker shows them as not
// found instead of silently removing them.
const entityId = missingZoneId(name, hass, missingIds);
missingIds.add(entityId);
entityIds.add(entityId);
}
}
const migrated: LocationCondition = {
condition: "location",
target: entityIds.size ? { entity_id: [...entityIds] } : {},
match_mode: "active_zone",
};
if (away) {
migrated.away = true;
}
return migrated;
}
const SCHEMA = [
{
name: "locations",
selector: {
state: {
entity_id: "person.whomever",
hide_states: ["unavailable", "unknown"],
multiple: true,
},
},
},
];
@customElement("ha-card-condition-location")
export class HaCardConditionLocation extends LitElement {
@@ -140,217 +34,66 @@ export class HaCardConditionLocation extends LitElement {
@property({ type: Boolean }) public disabled = false;
// `locations` is shown migrated, and written only once the condition is
// edited, so dashboards nobody edits keep their config.
@state() private _data?: LocationCondition;
// Stays set after the first edit, so the alert keeps explaining the change.
@state() private _migrated = false;
// The zone states `_data` was built from, including registered zones and
// zones it names that have no state, so hass updates that change no zone
// skip the migration.
private _zoneStates?: Map<string, HassEntity | undefined>;
public static get defaultConfig(): LocationCondition {
return { condition: "location", target: {} };
return { condition: "location", locations: [] };
}
protected static validateUIConfig(
condition: LocationCondition,
hass: HomeAssistant
) {
assert(condition, locationConditionStruct);
// Converting would drop the target, so leave this invalid mix to YAML.
if (
condition.locations !== undefined &&
(condition.target !== undefined || condition.match_mode !== undefined)
) {
throw new Error(
hass.localize("ui.errors.config.location_locations_combined")
);
}
}
protected willUpdate(changedProps: PropertyValues<this>): void {
if (!changedProps.has("condition") && !changedProps.has("hass")) {
return;
}
if (this.condition.locations === undefined) {
this._data = this.condition;
this._zoneStates = undefined;
return;
}
this._migrated = true;
// Redo only when a zone state or the entity registry changes, so a
// renamed, added or removed zone is picked up.
if (
!changedProps.has("condition") &&
changedProps.get("hass")?.entities === this.hass.entities &&
!this._zonesChanged()
) {
return;
}
const data = migrateLocationCondition(this.condition, this.hass);
this._zoneStates = new Map();
for (const entityId of [
...Object.keys(this.hass.states),
...Object.keys(this.hass.entities),
...ensureArray(data.target?.entity_id ?? []),
]) {
if (computeDomain(entityId) !== "zone") {
continue;
}
this._zoneStates.set(entityId, this.hass.states[entityId]);
}
// Keep the same object when nothing changed so the form doesn't re-render.
if (!deepEqual(data, this._data)) {
this._data = data;
}
}
private _zonesChanged(): boolean {
if (!this._zoneStates) {
return true;
}
for (const [entityId, stateObj] of this._zoneStates) {
if (this.hass.states[entityId] !== stateObj) {
return true;
}
}
return false;
}
private _schema = memoizeOne((localize: LocalizeFunc): HaFormSchema[] => [
{
name: "target",
selector: { target: { entity: { domain: "zone" } } },
},
{
name: "match_mode",
required: true,
selector: {
select: {
mode: "box",
box_max_columns: 1,
options: LOCATION_MATCH_MODES.map((mode) => ({
value: mode,
label: localize(
`${LOCALIZE_PREFIX}.match_mode_options.${mode}.label`
),
description: localize(
`${LOCALIZE_PREFIX}.match_mode_options.${mode}.description`
),
})),
},
},
},
{
name: "away",
selector: { boolean: {} },
},
]);
// Shows the `any_zone` default as selected.
private _formData = memoizeOne(
(data: LocationCondition): LocationCondition => ({
match_mode: "any_zone",
...data,
})
);
/** Selected passive zones, which never match `active_zone`. */
private _passiveZones(): string[] {
if (this._data?.match_mode !== "active_zone" || !this._data.target) {
return [];
}
return resolveLocationZones(this.hass, this._data.target)
.map((zoneId) => this.hass.states[zoneId])
.filter((stateObj) => stateObj?.attributes.passive)
.map((stateObj) => computeStateName(stateObj));
protected static validateUIConfig(condition: LocationCondition) {
return assert(condition, locationConditionStruct);
}
protected render() {
if (!this._data) {
return nothing;
}
const passiveZones = this._passiveZones();
return html`
${
this._migrated
? html`
<ha-alert alert-type="info">
${this.hass.localize(`${LOCALIZE_PREFIX}.migrated`)}
</ha-alert>
`
: nothing
}
<ha-form
.hass=${this.hass}
.data=${this._formData(this._data)}
.schema=${this._schema(this.hass.localize)}
.data=${this.condition}
.schema=${SCHEMA}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
></ha-form>
${
passiveZones.length
? html`
<ha-alert alert-type="warning">
${this.hass.localize(`${LOCALIZE_PREFIX}.passive_zones`, {
count: passiveZones.length,
zones: formatListWithAnds(this.hass.locale, passiveZones),
})}
</ha-alert>
`
: nothing
}
`;
}
private _valueChanged(ev: CustomEvent): void {
private _valueChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value as LocationCondition;
const locations = ev.detail.value.locations;
const condition: LocationCondition = {
...this.condition,
target: value.target ?? {},
locations,
};
delete condition.locations;
// `any_zone` is the default, so it's left out.
if (value.match_mode === "active_zone") {
condition.match_mode = "active_zone";
} else {
delete condition.match_mode;
}
if (value.away) {
condition.away = true;
} else {
delete condition.away;
}
fireEvent(this, "value-changed", { value: condition });
}
private _computeLabelCallback = (schema: HaFormSchema): string =>
this.hass.localize(`${LOCALIZE_PREFIX}.${schema.name}`);
private _computeLabelCallback = (schema): string => {
switch (schema.name) {
case "locations":
return this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.condition.location.locations"
);
default:
return "";
}
};
private _computeHelperCallback = (schema: HaFormSchema): string =>
this.hass.localize(`${LOCALIZE_PREFIX}.${schema.name}_helper`);
private _computeHelperCallback = (schema): string => {
switch (schema.name) {
case "locations":
return this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.condition.location.locations_helper"
);
default:
return "";
}
};
static styles = css`
:host {
display: block;
}
ha-alert {
display: block;
margin-bottom: var(--ha-space-2, 8px);
}
ha-form + ha-alert {
margin-top: var(--ha-space-2, 8px);
margin-bottom: 0;
}
`;
}
@@ -40,6 +40,7 @@ class HuiTimerEntityRow extends LitElement {
<hui-generic-entity-row .hass=${this.hass} .config=${this._config}>
<div class="text-content">
<ha-timer-remaining-time
.hass=${this.hass}
.stateObj=${stateObj}
></ha-timer-remaining-time>
</div>
@@ -165,6 +165,7 @@ class HuiWeatherEntityRow extends LitElement implements LovelaceRow {
<div class="secondary">
<state-display
.stateObj=${stateObj}
.hass=${this.hass}
.content=${this._config.secondary_info}
.timeFormat=${this._config.time_format}
.name=${name}
@@ -177,6 +177,7 @@ export class HuiEntityHeadingBadge
config.show_state
? html`
<state-display
.hass=${this.hass}
.stateObj=${stateObj}
.content=${config.state_content}
.name=${name}
@@ -13,7 +13,6 @@ import { clamp } from "../../common/number/clamp";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { PendingValueSync } from "../../common/util/pending-value-sync";
import "../../components/ha-big-number";
import "../../components/ha-control-circular-slider";
import "../../components/ha-outlined-icon-button";
@@ -54,24 +53,11 @@ export class HaStateControlClimateHumidity extends LitElement {
private _sizeController = createStateControlCircularSliderController(this);
private _pendingSync = new PendingValueSync();
protected willUpdate(changedProp: PropertyValues<this>): void {
super.willUpdate(changedProp);
if (!changedProp.has("stateObj")) {
return;
if (changedProp.has("stateObj")) {
this._targetHumidity = this.stateObj.attributes.humidity;
}
if (changedProp.get("stateObj")?.entity_id !== this.stateObj.entity_id) {
this._debouncedCallService.cancel();
this._pendingSync.reset();
this._syncTargetHumidity();
} else if (this._pendingSync.shouldSync()) {
this._syncTargetHumidity();
}
}
private _syncTargetHumidity() {
this._targetHumidity = this.stateObj.attributes.humidity;
}
private get _step() {
@@ -90,7 +76,6 @@ export class HaStateControlClimateHumidity extends LitElement {
const { value } = ev.detail;
if (typeof value !== "number" || isNaN(value)) return;
this._targetHumidity = value;
this._debouncedCallService.cancel();
this._callService();
}
@@ -103,14 +88,10 @@ export class HaStateControlClimateHumidity extends LitElement {
private _debouncedCallService = debounce(() => this._callService(), 1000);
private _callService() {
const data = {
this._api.callService("climate", "set_humidity", {
entity_id: this.stateObj!.entity_id,
humidity: this._targetHumidity,
};
return this._pendingSync.run(
() => this._api.callService("climate", "set_humidity", data),
() => this._syncTargetHumidity()
);
});
}
private _handleButton(ev) {
@@ -121,7 +102,6 @@ export class HaStateControlClimateHumidity extends LitElement {
humidity = clamp(humidity, this._min, this._max);
this._targetHumidity = humidity;
this._pendingSync.schedule();
this._debouncedCallService();
}
@@ -15,7 +15,6 @@ import { clamp } from "../../common/number/clamp";
import { formatNumber } from "../../common/number/format_number";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { debounce } from "../../common/util/debounce";
import { PendingValueSync } from "../../common/util/pending-value-sync";
import "../../components/ha-big-number";
import "../../components/ha-control-circular-slider";
import type { ControlCircularSliderMode } from "../../components/ha-control-circular-slider";
@@ -85,28 +84,15 @@ export class HaStateControlClimateTemperature extends LitElement {
private _sizeController = createStateControlCircularSliderController(this);
private _pendingSync = new PendingValueSync();
protected willUpdate(changedProp: PropertyValues<this>): void {
super.willUpdate(changedProp);
if (!changedProp.has("stateObj")) {
return;
if (changedProp.has("stateObj")) {
this._targetTemperature = {
value: this.stateObj.attributes.temperature,
low: this.stateObj.attributes.target_temp_low,
high: this.stateObj.attributes.target_temp_high,
};
}
if (changedProp.get("stateObj")?.entity_id !== this.stateObj.entity_id) {
this._debouncedCallService.cancel();
this._pendingSync.reset();
this._syncTargetTemperature();
} else if (this._pendingSync.shouldSync()) {
this._syncTargetTemperature();
}
}
private _syncTargetTemperature() {
this._targetTemperature = {
value: this.stateObj.attributes.temperature,
low: this.stateObj.attributes.target_temp_low,
high: this.stateObj.attributes.target_temp_high,
};
}
private get _step() {
@@ -133,7 +119,6 @@ export class HaStateControlClimateTemperature extends LitElement {
[target]: value,
};
this._selectTargetTemperature = target as Target;
this._debouncedCallService.cancel();
this._callService(target);
}
@@ -154,21 +139,18 @@ export class HaStateControlClimateTemperature extends LitElement {
);
private _callService(type: string) {
const data =
type === "high" || type === "low"
? {
entity_id: this.stateObj!.entity_id,
target_temp_low: this._targetTemperature.low,
target_temp_high: this._targetTemperature.high,
}
: {
entity_id: this.stateObj!.entity_id,
temperature: this._targetTemperature.value,
};
return this._pendingSync.run(
() => this._api.callService("climate", "set_temperature", data),
() => this._syncTargetTemperature()
);
if (type === "high" || type === "low") {
this._api.callService("climate", "set_temperature", {
entity_id: this.stateObj!.entity_id,
target_temp_low: this._targetTemperature.low,
target_temp_high: this._targetTemperature.high,
});
return;
}
this._api.callService("climate", "set_temperature", {
entity_id: this.stateObj!.entity_id,
temperature: this._targetTemperature.value,
});
}
private _handleButton(ev) {
@@ -191,7 +173,6 @@ export class HaStateControlClimateTemperature extends LitElement {
...this._targetTemperature,
[target]: temp,
};
this._pendingSync.schedule();
this._debouncedCallService(target);
}
@@ -10,7 +10,6 @@ import { stateActive } from "../../common/entity/state_active";
import { stateColorCss } from "../../common/entity/state_color";
import { clamp } from "../../common/number/clamp";
import { debounce } from "../../common/util/debounce";
import { PendingValueSync } from "../../common/util/pending-value-sync";
import "../../components/ha-big-number";
import "../../components/ha-control-circular-slider";
import "../../components/ha-outlined-icon-button";
@@ -53,24 +52,11 @@ export class HaStateControlHumidifierHumidity extends LitElement {
private _sizeController = createStateControlCircularSliderController(this);
private _pendingSync = new PendingValueSync();
protected willUpdate(changedProp: PropertyValues<this>): void {
super.willUpdate(changedProp);
if (!changedProp.has("stateObj")) {
return;
if (changedProp.has("stateObj")) {
this._targetHumidity = this.stateObj.attributes.humidity;
}
if (changedProp.get("stateObj")?.entity_id !== this.stateObj.entity_id) {
this._debouncedCallService.cancel();
this._pendingSync.reset();
this._syncTargetHumidity();
} else if (this._pendingSync.shouldSync()) {
this._syncTargetHumidity();
}
}
private _syncTargetHumidity() {
this._targetHumidity = this.stateObj.attributes.humidity;
}
private get _step() {
@@ -89,7 +75,6 @@ export class HaStateControlHumidifierHumidity extends LitElement {
const { value } = ev.detail;
if (typeof value !== "number" || isNaN(value)) return;
this._targetHumidity = value;
this._debouncedCallService.cancel();
this._callService();
}
@@ -102,14 +87,10 @@ export class HaStateControlHumidifierHumidity extends LitElement {
private _debouncedCallService = debounce(() => this._callService(), 1000);
private _callService() {
const data = {
this._api.callService("humidifier", "set_humidity", {
entity_id: this.stateObj!.entity_id,
humidity: this._targetHumidity,
};
return this._pendingSync.run(
() => this._api.callService("humidifier", "set_humidity", data),
() => this._syncTargetHumidity()
);
});
}
private _handleButton(ev) {
@@ -120,7 +101,6 @@ export class HaStateControlHumidifierHumidity extends LitElement {
humidity = clamp(humidity, this._min, this._max);
this._targetHumidity = humidity;
this._pendingSync.schedule();
this._debouncedCallService();
}
@@ -14,7 +14,6 @@ import { supportsFeature } from "../../common/entity/supports-feature";
import { clamp } from "../../common/number/clamp";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { PendingValueSync } from "../../common/util/pending-value-sync";
import "../../components/ha-big-number";
import "../../components/ha-control-circular-slider";
import "../../components/ha-outlined-icon-button";
@@ -62,24 +61,11 @@ export class HaStateControlWaterHeaterTemperature extends LitElement {
private _sizeController = createStateControlCircularSliderController(this);
private _pendingSync = new PendingValueSync();
protected willUpdate(changedProp: PropertyValues<this>): void {
super.willUpdate(changedProp);
if (!changedProp.has("stateObj")) {
return;
if (changedProp.has("stateObj")) {
this._targetTemperature = this.stateObj.attributes.temperature;
}
if (changedProp.get("stateObj")?.entity_id !== this.stateObj.entity_id) {
this._debouncedCallService.cancel();
this._pendingSync.reset();
this._syncTargetTemperature();
} else if (this._pendingSync.shouldSync()) {
this._syncTargetTemperature();
}
}
private _syncTargetTemperature() {
this._targetTemperature = this.stateObj.attributes.temperature;
}
private get _step() {
@@ -101,7 +87,6 @@ export class HaStateControlWaterHeaterTemperature extends LitElement {
const { value } = ev.detail;
if (typeof value !== "number" || isNaN(value)) return;
this._targetTemperature = value;
this._debouncedCallService.cancel();
this._callService();
}
@@ -114,14 +99,10 @@ export class HaStateControlWaterHeaterTemperature extends LitElement {
private _debouncedCallService = debounce(() => this._callService(), 1000);
private _callService() {
const data = {
this._api.callService("water_heater", "set_temperature", {
entity_id: this.stateObj!.entity_id,
temperature: this._targetTemperature,
};
return this._pendingSync.run(
() => this._api.callService("water_heater", "set_temperature", data),
() => this._syncTargetTemperature()
);
});
}
private _handleButton(ev) {
@@ -132,7 +113,6 @@ export class HaStateControlWaterHeaterTemperature extends LitElement {
temp = clamp(temp, this._min, this._max);
this._targetTemperature = temp;
this._pendingSync.schedule();
this._debouncedCallService();
}
+5 -14
View File
@@ -1,20 +1,16 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { TimerRemainingTimeController } from "../common/controllers/timer-remaining-time-controller";
import { consume } from "../common/decorators/consume";
import { formattersContext } from "../data/context";
import { computeDisplayTimer } from "../data/timer";
import type { HomeAssistant } from "../types";
@customElement("ha-timer-remaining-time")
class HaTimerRemainingTime extends ReactiveElement {
@property({ attribute: false }) public stateObj!: HassEntity;
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@property({ attribute: false }) public stateObj!: HassEntity;
private _remainingTime = new TimerRemainingTimeController(this);
@@ -31,14 +27,9 @@ class HaTimerRemainingTime extends ReactiveElement {
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (!this._formatters) {
return;
}
this.innerHTML =
computeDisplayTimer(
this._formatters.formatEntityState,
this.hass.formatEntityState,
this.stateObj,
this._remainingTime.timeRemaining
) ?? "-";
+13 -41
View File
@@ -1,12 +1,10 @@
import memoizeOne from "memoize-one";
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { join } from "lit/directives/join";
import { ensureArray } from "../common/array/ensure-array";
import { consume } from "../common/decorators/consume";
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
@@ -14,10 +12,6 @@ import {
TIMESTAMP_STATE_DOMAINS,
} from "../common/const";
import { UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
import {
formattersContext,
internationalizationContext,
} from "../data/context";
import {
SENSOR_TIMESTAMP_DEVICE_CLASSES,
SENSOR_DEVICE_CLASS_UPTIME,
@@ -25,10 +19,7 @@ import {
import type { UpdateEntity } from "../data/update";
import { computeUpdateStateDisplay } from "../data/update";
import "../panels/lovelace/components/hui-timestamp-display";
import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../types";
import type { HomeAssistant } from "../types";
import { computeDomain } from "../common/entity/compute_domain";
export const STATE_DISPLAY_SPECIAL_CONTENT = [
@@ -125,6 +116,8 @@ export const stateContentHasTimestamp = (
@customElement("state-display")
class StateDisplay extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity;
@property({ attribute: false }) public content?: StateContent;
@@ -139,14 +132,6 @@ class StateDisplay extends LitElement {
@property({ type: Boolean, attribute: "dash-unavailable" })
public dashUnavailable?: boolean;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
protected createRenderRoot() {
return this;
}
@@ -172,9 +157,7 @@ class StateDisplay extends LitElement {
}
private _computeContent(
content: string,
formatters: HomeAssistantFormatters,
i18n: HomeAssistantInternationalization
content: string
): TemplateResult<1> | string | undefined {
const stateObj = this.stateObj;
const domain = computeStateDomain(stateObj);
@@ -207,7 +190,7 @@ class StateDisplay extends LitElement {
`;
}
return formatters.formatEntityState(stateObj);
return this.hass!.formatEntityState(stateObj);
}
if (content === "name" && this.name) {
return html`${this.name}`;
@@ -218,7 +201,7 @@ class StateDisplay extends LitElement {
const nameType = NAME_CONTENT_TYPES.get(content);
if (nameType) {
return (
formatters.formatEntityName(stateObj, { type: nameType }) || undefined
this.hass.formatEntityName(stateObj, { type: nameType }) || undefined
);
}
@@ -250,17 +233,14 @@ class StateDisplay extends LitElement {
if (specialContent.includes(content)) {
if (content === "install_status") {
return html`
${computeUpdateStateDisplay(stateObj as UpdateEntity, {
localize: i18n.localize,
locale: i18n.locale,
formatEntityState: formatters.formatEntityState,
})}
${computeUpdateStateDisplay(stateObj as UpdateEntity, this.hass!)}
`;
}
if (content === "remaining_time") {
import("./ha-timer-remaining-time");
return html`
<ha-timer-remaining-time
.hass=${this.hass}
.stateObj=${stateObj}
></ha-timer-remaining-time>
`;
@@ -275,27 +255,19 @@ class StateDisplay extends LitElement {
) {
return undefined;
}
return formatters.formatEntityAttributeValue(stateObj, content);
return this.hass!.formatEntityAttributeValue(stateObj, content);
}
protected render() {
const formatters = this._formatters;
const i18n = this._i18n;
if (!formatters || !i18n) {
return nothing;
}
const stateObj = this.stateObj;
const contents = ensureArray(this._content);
const values = contents
.map((content) => this._computeContent(content, formatters, i18n))
.map((content) => this._computeContent(content))
.filter(Boolean);
if (!values.length) {
return html`${formatters.formatEntityState(stateObj)}`;
return html`${this.hass!.formatEntityState(stateObj)}`;
}
return join(values, STRINGS_SEPARATOR_DOT);
+1
View File
@@ -25,6 +25,7 @@ class StateCardTimer extends LitElement {
></state-info>
<div class="state">
<ha-timer-remaining-time
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-timer-remaining-time>
</div>
+8 -22
View File
@@ -928,7 +928,8 @@
},
"conversation-agent-picker": {
"conversation_agent": "Conversation agent",
"none": "None"
"none": "None",
"settings": "Agent settings"
},
"country-picker": {
"country": "Country",
@@ -2672,8 +2673,7 @@
"key_not_expected": "Key ''{key}'' is not expected or not supported by the visual editor.",
"key_wrong_type": "The provided value for ''{key}'' is not supported by the visual editor. We support ({type_correct}) but received ({type_wrong}).",
"no_template_editor_support": "Templates not supported in visual editor",
"media_query_not_supported": "This media query is not supported by the visual editor.",
"location_locations_combined": "Zone names (locations) can't be combined with target or match_mode."
"media_query_not_supported": "This media query is not supported by the visual editor."
},
"supervisor": {
"title": "Could not load the Supervisor panel!",
@@ -3049,7 +3049,9 @@
"gen_data_header": "Data generation tasks",
"gen_data_description": "Suggest automation names.",
"gen_image_header": "Image generation tasks",
"gen_image_description": "Generate images."
"gen_image_description": "Generate images.",
"gen_data_settings": "Data generation settings",
"gen_image_settings": "Image generation settings"
},
"mcp": {
"header": "Model Context Protocol",
@@ -5443,7 +5445,6 @@
},
"edit_yaml": "Edit in YAML",
"edit_ui": "Edit in visual editor",
"sidebar_label": "Item editor",
"copy_to_clipboard": "Copy to clipboard",
"unknown_entity": "unknown entity",
"edit_unknown_device": "Editor not available for unknown device",
@@ -10260,23 +10261,8 @@
},
"location": {
"label": "Location",
"migrated": "This condition used zone names, and is now shown with zones instead. It matches the same way as before. Zones that could not be found are kept and marked as not found. The new format is saved when you change this condition.",
"target": "Zones",
"target_helper": "This condition will be true if the person entity of the user viewing this dashboard is in one of the selected zones, or in a zone with the selected labels, areas, or floors. Match sets which zones count.",
"match_mode": "Match",
"match_mode_options": {
"any_zone": {
"label": "Any zone they are in",
"description": "True when the person is in any of these zones, even if another zone is shown as their state. Includes passive zones."
},
"active_zone": {
"label": "Zone shown as their state",
"description": "True only when one of these zones is shown as the person's state. Passive zones never match."
}
},
"passive_zones": "{zones} {count, plural,\n one {is a passive zone, so it never matches}\n other {are passive zones, so they never match}\n}. Passive zones never become a person's state.",
"away": "Away",
"away_helper": "This condition will also be true if the person entity of the user viewing this dashboard is away. Passive zones don't change this, so a person who is only in passive zones is away."
"locations": "Locations",
"locations_helper": "This condition will be true if the person entity of the user viewing this dashboard has a state matching any of the selected zones."
},
"user": {
"label": "User"
@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import { canOverrideAlphanumericInput } from "../../../src/common/dom/can-override-input";
// jsdom does not implement isContentEditable — it is undefined on every element,
// including one carrying contenteditable="true". These stubs therefore describe
// the shape the function actually reads rather than using real elements.
const stub = (props: Record<string, unknown>) =>
props as unknown as EventTarget;
describe("canOverrideAlphanumericInput", () => {
it("allows shortcuts on a plain element", () => {
expect(canOverrideAlphanumericInput([stub({ tagName: "DIV" })])).toBe(true);
});
it("blocks shortcuts in a textarea", () => {
expect(canOverrideAlphanumericInput([stub({ tagName: "TEXTAREA" })])).toBe(
false
);
});
it("blocks shortcuts in a text input", () => {
expect(
canOverrideAlphanumericInput([stub({ tagName: "INPUT", type: "text" })])
).toBe(false);
});
it("allows shortcuts on inputs that are not typed into", () => {
for (const type of ["button", "checkbox", "hidden", "radio", "range"]) {
expect(
canOverrideAlphanumericInput([stub({ tagName: "INPUT", type })])
).toBe(true);
}
});
it("blocks shortcuts in a contenteditable element", () => {
expect(
canOverrideAlphanumericInput([
stub({ tagName: "DIV", isContentEditable: true }),
])
).toBe(false);
});
it("blocks shortcuts when a contenteditable element is the event target inside a shadow root", () => {
expect(
canOverrideAlphanumericInput([
stub({ tagName: "SPAN", isContentEditable: true }),
stub({ tagName: "DIV" }),
stub({ tagName: "MY-CARD" }),
])
).toBe(false);
});
it("blocks shortcuts inside a code editor", () => {
expect(
canOverrideAlphanumericInput([
stub({ tagName: "DIV" }),
stub({ tagName: "HA-CODE-EDITOR" }),
])
).toBe(false);
});
it("blocks shortcuts inside a menu", () => {
expect(
canOverrideAlphanumericInput([
stub({ tagName: "DIV" }),
stub({ tagName: "HA-MENU" }),
])
).toBe(false);
});
it("blocks shortcuts on a select or dropdown child", () => {
expect(
canOverrideAlphanumericInput([
stub({ tagName: "DIV", parentElement: { tagName: "HA-SELECT" } }),
])
).toBe(false);
expect(
canOverrideAlphanumericInput([
stub({ tagName: "DIV", parentElement: { tagName: "HA-DROPDOWN" } }),
])
).toBe(false);
});
});
-171
View File
@@ -1,171 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { PendingValueSync } from "../../../src/common/util/pending-value-sync";
const deferred = () => {
let resolve!: () => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("PendingValueSync", () => {
it("syncs state when nothing is pending", () => {
const sync = new PendingValueSync();
expect(sync.pending).toBe(false);
expect(sync.shouldSync()).toBe(true);
});
it("skips state updates while a change is scheduled", () => {
const sync = new PendingValueSync();
sync.schedule();
expect(sync.pending).toBe(true);
expect(sync.shouldSync()).toBe(false);
});
it("skips state updates while the service call is in flight and keeps the local value afterwards", async () => {
const sync = new PendingValueSync();
const call = deferred();
const resync = vi.fn();
sync.schedule();
const run = sync.run(() => call.promise, resync);
expect(sync.shouldSync()).toBe(false);
call.resolve();
await run;
expect(resync).not.toHaveBeenCalled();
expect(sync.pending).toBe(false);
expect(sync.shouldSync()).toBe(true);
});
it("does not restore the confirmation of a previous call after the next call succeeds", async () => {
const sync = new PendingValueSync();
const second = deferred();
const resync = vi.fn();
// First tap is sent and succeeds
sync.schedule();
await sync.run(() => Promise.resolve(), resync);
// Second tap: the confirmation of the first call arrives during the
// debounce and while the second call is in flight
sync.schedule();
expect(sync.shouldSync()).toBe(false);
const run = sync.run(() => second.promise, resync);
expect(sync.shouldSync()).toBe(false);
// The second call resolves before the integration writes its new state
second.resolve();
await run;
expect(resync).not.toHaveBeenCalled();
});
it("keeps a new change when the previous call finishes", async () => {
const sync = new PendingValueSync();
const call = deferred();
const resync = vi.fn();
sync.schedule();
const run = sync.run(() => call.promise, resync);
// The user changes the value again while the first call is in flight
sync.schedule();
call.resolve();
await run;
expect(sync.shouldSync()).toBe(false);
await sync.run(() => Promise.resolve(), resync);
expect(resync).not.toHaveBeenCalled();
expect(sync.shouldSync()).toBe(true);
});
it("detaches calls in flight on reset", async () => {
const sync = new PendingValueSync();
const call = deferred();
const resync = vi.fn();
sync.schedule();
sync.reset();
expect(sync.pending).toBe(false);
// Switching entities while a call for the previous one is in flight
sync.schedule();
const run = sync.run(() => call.promise, resync);
sync.reset();
expect(sync.pending).toBe(false);
expect(sync.shouldSync()).toBe(true);
// A change for the new entity is not overwritten by the old call failing
sync.schedule();
call.reject(new Error("failed"));
await run;
expect(resync).not.toHaveBeenCalled();
expect(sync.pending).toBe(true);
});
it("resyncs when the service call fails", async () => {
const sync = new PendingValueSync();
const call = deferred();
const resync = vi.fn();
sync.schedule();
const run = sync.run(() => call.promise, resync);
call.reject(new Error("failed"));
await run;
expect(resync).toHaveBeenCalledTimes(1);
expect(sync.pending).toBe(false);
});
it("does not resync a failed call when a new change is scheduled", async () => {
const sync = new PendingValueSync();
const call = deferred();
const resync = vi.fn();
sync.schedule();
const run = sync.run(() => call.promise, resync);
sync.schedule();
call.reject(new Error("failed"));
await run;
expect(resync).not.toHaveBeenCalled();
expect(sync.pending).toBe(true);
});
it("does not let a superseded call failure resync the newer change", async () => {
const sync = new PendingValueSync();
const first = deferred();
const resync = vi.fn();
sync.schedule();
const firstRun = sync.run(() => first.promise, resync);
sync.schedule();
await sync.run(() => Promise.resolve(), resync);
// The older call fails after the newer one already succeeded
first.reject(new Error("failed"));
await firstRun;
expect(resync).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import * as selector from "../../../../src/data/selector";
import { describe, it, expect } from "vitest";
import type { VisibilityCondition } from "../../../../src/panels/lovelace/common/validate-condition";
import {
addEntityToCondition,
@@ -38,65 +37,6 @@ describe("validateConditionalConfig", () => {
});
});
describe("location condition validation", () => {
it.each([
["locations", { condition: "location", locations: ["home"] }],
["target", { condition: "location", target: { label_id: "store" } }],
["empty target", { condition: "location", target: {} }],
["away", { condition: "location", away: true }],
[
"a match mode",
{ condition: "location", target: {}, match_mode: "active_zone" },
],
[
"locations and away",
{ condition: "location", locations: ["home"], away: true },
],
])("should return true with %s", (_name, condition) => {
expect(validateConditionalConfig([condition] as any)).toBe(true);
});
it.each([
["a string target", { condition: "location", target: "zone.home" }],
["a list target", { condition: "location", target: ["zone.home"] }],
[
"a target with a non-string ID",
{ condition: "location", target: { entity_id: 5 } },
],
[
"a target with an unknown key",
{ condition: "location", target: { zone_id: "zone.home" } },
],
["a non-boolean away", { condition: "location", away: "yes" }],
[
"an unknown match mode",
{ condition: "location", target: {}, match_mode: "nearest" },
],
[
"locations with a target",
{ condition: "location", locations: ["home"], target: {} },
],
[
"locations with a match mode",
{ condition: "location", locations: ["home"], match_mode: "any_zone" },
],
[
"a non-boolean away with a target",
{ condition: "location", target: {}, away: "yes" },
],
])("should return false with %s", (_name, condition) => {
expect(validateConditionalConfig([condition] as any)).toBe(false);
});
it("should return false without locations, target or away", () => {
expect(
validateConditionalConfig([
{ condition: "location", away: false },
] as any)
).toBe(false);
});
});
describe("server-evaluated condition validation", () => {
it("should accept server-evaluated conditions, leaving them to core", () => {
const conditions = [
@@ -191,388 +131,6 @@ describe("checkConditionsMet", () => {
expect(checkConditionsMet(conditions, hass, {})).toBe(false);
});
});
describe("location condition evaluation", () => {
const createLocationHass = (state: string, inZones: string[]) =>
({
states: {
"person.me": {
entity_id: "person.me",
state,
attributes: { user_id: "user1", in_zones: inZones },
},
"zone.store_1": {
entity_id: "zone.store_1",
state: "1",
attributes: {},
},
"zone.store_2": {
entity_id: "zone.store_2",
state: "0",
attributes: {},
},
"zone.work": {
entity_id: "zone.work",
state: "0",
attributes: {},
},
},
entities: {
"zone.store_1": { entity_id: "zone.store_1", labels: ["store"] },
"zone.store_2": { entity_id: "zone.store_2", labels: ["store"] },
"zone.work": {
entity_id: "zone.work",
labels: [],
area_id: "downtown",
},
},
devices: {},
areas: { downtown: { area_id: "downtown", labels: [] } },
user: { id: "user1" },
}) as unknown as HomeAssistant;
it("matches locations against the person state", () => {
const hass = createLocationHass("Store", ["zone.store_1"]);
expect(
checkConditionsMet(
[{ condition: "location", locations: ["Store"] }],
hass,
{}
)
).toBe(true);
});
it("matches a zone label against in_zones", () => {
const hass = createLocationHass("Store", ["zone.store_1"]);
expect(
checkConditionsMet(
[{ condition: "location", target: { label_id: "store" } }],
hass,
{}
)
).toBe(true);
});
it("matches a zone that is not the active zone", () => {
// Active zone is the smaller "Work" zone inside the store zone.
const hass = createLocationHass("Work", ["zone.work", "zone.store_2"]);
expect(
checkConditionsMet(
[{ condition: "location", target: { entity_id: "zone.store_2" } }],
hass,
{}
)
).toBe(true);
});
it("does not match when the person is in no selected zone", () => {
const hass = createLocationHass("Work", ["zone.work"]);
expect(
checkConditionsMet(
[{ condition: "location", target: { label_id: "store" } }],
hass,
{}
)
).toBe(false);
});
it("matches zones in a selected area", () => {
const hass = createLocationHass("Work", ["zone.work"]);
expect(
checkConditionsMet(
[{ condition: "location", target: { area_id: "downtown" } }],
hass,
{}
)
).toBe(true);
});
it("does not match when the person is in no zone", () => {
const hass = createLocationHass("not_home", []);
expect(
checkConditionsMet(
[{ condition: "location", target: { entity_id: "zone.store_1" } }],
hass,
{}
)
).toBe(false);
});
it.each([
["not a mapping", "zone.store_1"],
["holds a non-string ID", { entity_id: ["zone.store_1", 5] }],
])("does not match a target that is %s", (_name, target) => {
const hass = createLocationHass("Store", ["zone.store_1"]);
const conditions = [{ condition: "location", target }] as any;
expect(() => checkConditionsMet(conditions, hass, {})).not.toThrow();
expect(checkConditionsMet(conditions, hass, {})).toBe(false);
});
it("only treats away: true as away", () => {
const hass = createLocationHass("not_home", []);
expect(
checkConditionsMet(
[{ condition: "location", away: "yes" }] as any,
hass,
{}
)
).toBe(false);
});
it("picks up label changes for the same condition", () => {
const conditions = [
{ condition: "location", target: { label_id: "work" } },
] as any;
const hass = createLocationHass("Work", ["zone.work"]);
expect(checkConditionsMet(conditions, hass, {})).toBe(false);
// Registry updates replace hass.entities, which must refresh the cache.
const relabeled = {
...hass,
entities: {
...hass.entities,
"zone.work": { ...hass.entities["zone.work"], labels: ["work"] },
},
} as unknown as HomeAssistant;
expect(checkConditionsMet(conditions, relabeled, {})).toBe(true);
});
it("picks up a zone whose state returns without a registry change", () => {
const conditions = [
{ condition: "location", target: { label_id: "store" } },
] as any;
const hass = createLocationHass("Store", ["zone.store_1"]);
// zone.store_1 has no state while zones reload.
const { "zone.store_1": _store1, ...states } = hass.states;
const reloading = { ...hass, states } as unknown as HomeAssistant;
expect(checkConditionsMet(conditions, reloading, {})).toBe(false);
// Same registries, state is back.
expect(checkConditionsMet(conditions, hass, {})).toBe(true);
});
it("keeps the cache while an unrelated zone has no state", () => {
const conditions = [
{ condition: "location", target: { label_id: "store" } },
] as any;
const base = createLocationHass("Store", ["zone.store_1"]);
// zone.work is registered but has no state.
const { "zone.work": _work, ...states } = base.states;
const hass = { ...base, states } as unknown as HomeAssistant;
const resolve = vi.spyOn(selector, "resolveEntityIDs");
expect(checkConditionsMet(conditions, hass, {})).toBe(true);
const moved = {
...hass,
states: { ...hass.states },
} as unknown as HomeAssistant;
expect(checkConditionsMet(conditions, moved, {})).toBe(true);
expect(resolve).toHaveBeenCalledTimes(1);
resolve.mockRestore();
});
it("drops a cached zone that loses its state", () => {
const conditions = [
{ condition: "location", target: { label_id: "store" } },
] as any;
const hass = createLocationHass("Store", ["zone.store_1"]);
expect(checkConditionsMet(conditions, hass, {})).toBe(true);
// Same registries, but zone.store_1 has no state any more.
const { "zone.store_1": _store1, ...states } = hass.states;
const removed = { ...hass, states } as unknown as HomeAssistant;
expect(checkConditionsMet(conditions, removed, {})).toBe(false);
});
it("does not match a directly selected zone without a state", () => {
const conditions = [
{ condition: "location", target: { entity_id: "zone.store_1" } },
] as any;
const hass = createLocationHass("Store", ["zone.store_1"]);
const { "zone.store_1": _store1, ...states } = hass.states;
const removed = { ...hass, states } as unknown as HomeAssistant;
expect(checkConditionsMet(conditions, removed, {})).toBe(false);
// Picked up when the state is back.
expect(checkConditionsMet(conditions, hass, {})).toBe(true);
});
it("re-checks in_zones when the person moves", () => {
const conditions = [
{ condition: "location", target: { label_id: "store" } },
] as any;
const hass = createLocationHass("Store", ["zone.store_1"]);
expect(checkConditionsMet(conditions, hass, {})).toBe(true);
// Same registries (cached zones), new person state.
const moved = {
...hass,
states: {
...hass.states,
"person.me": {
...hass.states["person.me"],
state: "Work",
attributes: { user_id: "user1", in_zones: ["zone.work"] },
},
},
} as unknown as HomeAssistant;
expect(checkConditionsMet(conditions, moved, {})).toBe(false);
});
it("matches away when the person is not in any zone", () => {
const hass = createLocationHass("not_home", []);
const conditions = [
{ condition: "location", target: { label_id: "store" }, away: true },
] as any;
expect(checkConditionsMet(conditions, hass, {})).toBe(true);
expect(
checkConditionsMet(
[{ condition: "location", target: { label_id: "store" } }],
hass,
{}
)
).toBe(false);
});
describe("active_zone", () => {
const createActiveZoneHass = (state: string, inZones: string[]) =>
({
states: {
"person.me": {
entity_id: "person.me",
state,
attributes: { user_id: "user1", in_zones: inZones },
},
"zone.home": {
entity_id: "zone.home",
state: "0",
attributes: { friendly_name: "Home" },
},
"zone.store_1": {
entity_id: "zone.store_1",
state: "0",
attributes: { friendly_name: "Store" },
},
"zone.work": {
entity_id: "zone.work",
state: "0",
attributes: { friendly_name: "Work" },
},
// Named like zone.store_1.
"zone.store_2": {
entity_id: "zone.store_2",
state: "0",
attributes: { friendly_name: "Store" },
},
// Passive, and named like zone.work.
"zone.work_campus": {
entity_id: "zone.work_campus",
state: "0",
attributes: { friendly_name: "Work", passive: true },
},
},
entities: {
"zone.store_1": { entity_id: "zone.store_1", labels: ["store"] },
},
devices: {},
areas: {},
user: { id: "user1" },
}) as unknown as HomeAssistant;
const check = (hass: HomeAssistant, target: Record<string, unknown>) =>
checkConditionsMet(
[{ condition: "location", target, match_mode: "active_zone" }],
hass,
{}
);
it("matches the zone shown as the person state", () => {
const hass = createActiveZoneHass("Store", ["zone.store_1"]);
expect(check(hass, { entity_id: "zone.store_1" })).toBe(true);
expect(check(hass, { label_id: "store" })).toBe(true);
});
it("matches home", () => {
const hass = createActiveZoneHass("home", ["zone.home"]);
expect(check(hass, { entity_id: "zone.home" })).toBe(true);
});
it("does not match a zone that is not the active zone", () => {
const hass = createActiveZoneHass("Work", [
"zone.work",
"zone.store_1",
]);
expect(check(hass, { entity_id: "zone.store_1" })).toBe(false);
expect(
checkConditionsMet(
[{ condition: "location", target: { entity_id: "zone.store_1" } }],
hass,
{}
)
).toBe(true);
});
it("does not match another zone with the same name", () => {
const hass = createActiveZoneHass("Store", ["zone.store_2"]);
expect(check(hass, { entity_id: "zone.store_1" })).toBe(false);
expect(check(hass, { entity_id: "zone.store_2" })).toBe(true);
});
it.each([["active_zone"], ["any_zone"]])(
"matches the state with %s when in_zones is empty",
(matchMode) => {
// Legacy device trackers don't report in_zones.
const hass = createActiveZoneHass("Work", []);
const conditions = (entityId: string) =>
[
{
condition: "location",
target: { entity_id: entityId },
match_mode: matchMode,
},
] as any;
expect(checkConditionsMet(conditions("zone.work"), hass, {})).toBe(
true
);
expect(checkConditionsMet(conditions("zone.store_1"), hass, {})).toBe(
false
);
}
);
it("does not match a zone named not_home while away", () => {
const hass = createActiveZoneHass("not_home", []);
hass.states["zone.not_home"] = {
entity_id: "zone.not_home",
state: "0",
attributes: { friendly_name: "not_home" },
} as any;
for (const matchMode of ["active_zone", "any_zone"]) {
expect(
checkConditionsMet(
[
{
condition: "location",
target: { entity_id: "zone.not_home" },
match_mode: matchMode,
},
] as any,
hass,
{}
)
).toBe(false);
}
});
it("never matches a passive zone", () => {
const hass = createActiveZoneHass("Work", [
"zone.work",
"zone.work_campus",
]);
expect(check(hass, { entity_id: "zone.work_campus" })).toBe(false);
expect(check(hass, { entity_id: "zone.work" })).toBe(true);
});
});
});
});
describe("addEntityToCondition", () => {
@@ -1,462 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
HaCardConditionLocation,
migrateLocationCondition,
} from "../../../../../../src/panels/lovelace/editor/conditions/types/ha-card-condition-location";
import type { LocationCondition } from "../../../../../../src/panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../../../../../src/types";
const HASS = {
states: {
"zone.home": {
entity_id: "zone.home",
state: "0",
attributes: { friendly_name: "Home" },
},
"zone.store_1": {
entity_id: "zone.store_1",
state: "0",
attributes: { friendly_name: "Store 1" },
},
"zone.store_2": {
entity_id: "zone.store_2",
state: "0",
attributes: { friendly_name: "Store 2" },
},
// Shares Store 2's name, so both are matched.
"zone.store_2_south": {
entity_id: "zone.store_2_south",
state: "0",
attributes: { friendly_name: "Store 2" },
},
// Renamed from "Store", so its ID no longer matches its name.
"zone.store": {
entity_id: "zone.store",
state: "0",
attributes: { friendly_name: "Shop" },
},
// Shares a zone's name but isn't a zone, so migration must ignore it.
"person.me": {
entity_id: "person.me",
state: "home",
attributes: { friendly_name: "Store 1" },
},
},
localize: (key: string) => key,
// Registered, but without a state (for example while zones reload).
entities: {
"zone.reloading": { entity_id: "zone.reloading" },
},
} as unknown as HomeAssistant;
const createEditor = (condition: LocationCondition) => {
const editor = document.createElement(
"ha-card-condition-location"
) as HaCardConditionLocation;
editor.hass = HASS;
editor.condition = condition;
return editor;
};
const nextValue = (editor: HaCardConditionLocation) =>
new Promise<LocationCondition>((resolve) => {
editor.addEventListener(
"value-changed",
(ev) => resolve((ev as CustomEvent).detail.value),
{ once: true }
);
});
// Runs the update hook without rendering ha-form, whose selectors load lazily.
const open = (editor: HaCardConditionLocation) =>
(editor as any).willUpdate(new Map([["condition", undefined]]));
// The condition shown in the form.
const shown = (editor: HaCardConditionLocation): LocationCondition => {
open(editor);
return (editor as any)._data;
};
const migrate = (
condition: Omit<LocationCondition, "condition">,
hass = HASS
) => migrateLocationCondition({ condition: "location", ...condition }, hass);
const formChange = (value: Partial<LocationCondition>) => ({
detail: { value: { condition: "location", ...value } },
stopPropagation: () => undefined,
});
const validate = (condition: unknown) =>
(HaCardConditionLocation as any).validateUIConfig(condition, HASS);
describe("ha-card-condition-location", () => {
describe("value changes", () => {
it("omits away when it is turned off", async () => {
const editor = createEditor({ condition: "location", target: {} });
const value = nextValue(editor);
(editor as any)._valueChanged(
formChange({ target: { label_id: "store" }, away: false })
);
expect(await value).toEqual({
condition: "location",
target: { label_id: "store" },
});
});
it("keeps other keys and removes away when it is turned off", async () => {
const editor = createEditor({
condition: "location",
target: {},
away: true,
alias: "At a store",
} as LocationCondition);
const value = nextValue(editor);
(editor as any)._valueChanged(
formChange({ target: { label_id: "store" }, away: false })
);
expect(await value).toEqual({
condition: "location",
target: { label_id: "store" },
alias: "At a store",
});
});
it("keeps away when it is turned on", async () => {
const editor = createEditor({ condition: "location", target: {} });
const value = nextValue(editor);
(editor as any)._valueChanged(formChange({ away: true }));
expect(await value).toEqual({
condition: "location",
target: {},
away: true,
});
});
it("keeps active_zone and leaves out the any_zone default", async () => {
const editor = createEditor({ condition: "location", target: {} });
let value = nextValue(editor);
(editor as any)._valueChanged(
formChange({ target: {}, match_mode: "active_zone" })
);
expect(await value).toEqual({
condition: "location",
target: {},
match_mode: "active_zone",
});
editor.condition = { ...editor.condition, match_mode: "active_zone" };
value = nextValue(editor);
(editor as any)._valueChanged(
formChange({ target: {}, match_mode: "any_zone" })
);
expect(await value).toEqual({ condition: "location", target: {} });
});
});
describe("passive zone warning", () => {
const hass = {
...HASS,
states: {
...HASS.states,
"zone.gym": {
entity_id: "zone.gym",
state: "0",
attributes: { friendly_name: "Gym", passive: true },
},
},
entities: {},
devices: {},
areas: {},
} as unknown as HomeAssistant;
const passiveZones = (condition: Partial<LocationCondition>) => {
const editor = createEditor({ condition: "location", ...condition });
editor.hass = hass;
open(editor);
return (editor as any)._passiveZones();
};
it("lists selected passive zones for active_zone", () => {
expect(
passiveZones({
target: { entity_id: ["zone.gym", "zone.store_1"] },
match_mode: "active_zone",
})
).toEqual(["Gym"]);
});
it("lists nothing for any_zone", () => {
expect(passiveZones({ target: { entity_id: "zone.gym" } })).toEqual([]);
});
});
describe("migration", () => {
it("maps zone names, home and not_home", () => {
expect(migrate({ locations: ["home", "Store 2", "not_home"] })).toEqual({
condition: "location",
match_mode: "active_zone",
target: {
entity_id: ["zone.home", "zone.store_2", "zone.store_2_south"],
},
away: true,
});
});
it("keeps zones named home or not_home", () => {
const hass = {
...HASS,
states: {
...HASS.states,
"zone.lowercase_home": {
entity_id: "zone.lowercase_home",
state: "0",
attributes: { friendly_name: "home" },
},
"zone.not_home": {
entity_id: "zone.not_home",
state: "0",
attributes: { friendly_name: "not_home" },
},
},
} as unknown as HomeAssistant;
expect(migrate({ locations: ["home", "not_home"] }, hass)).toEqual({
condition: "location",
match_mode: "active_zone",
target: {
entity_id: ["zone.home", "zone.lowercase_home", "zone.not_home"],
},
away: true,
});
});
it("keeps away", () => {
expect(migrate({ locations: ["Store 1"], away: true })).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.store_1"] },
away: true,
});
});
it("leaves the target empty when only not_home is set", () => {
expect(migrate({ locations: ["not_home"] })).toEqual({
condition: "location",
match_mode: "active_zone",
target: {},
away: true,
});
});
it("keeps names that match no zone", () => {
expect(migrate({ locations: ["Store 1", "Old store"] })).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.store_1", "zone.old_store"] },
});
});
it("does not reuse the ID of a zone with another name", () => {
// zone.store is "Shop" and zone.store_2 is "Store 2".
expect(migrate({ locations: ["Store"] })).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.store_3"] },
});
});
it("does not reuse the ID of a registered zone without a state", () => {
expect(migrate({ locations: ["Reloading"] })).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.reloading_2"] },
});
});
it("keeps missing names that slugify the same apart", () => {
expect(
migrate({ locations: ["Old store", "Old-store", "Old store"] })
).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.old_store", "zone.old_store_2"] },
});
});
it("picks up renamed zones without replacing unchanged data", () => {
const editor = createEditor({
condition: "location",
locations: ["Store 1"],
});
const first = shown(editor);
open(editor);
expect((editor as any)._data).toBe(first);
editor.hass = {
...HASS,
states: {
...HASS.states,
"zone.store_1": {
...HASS.states["zone.store_1"],
attributes: { friendly_name: "Shop 1" },
},
},
} as unknown as HomeAssistant;
(editor as any).willUpdate(new Map([["hass", HASS]]));
expect((editor as any)._data).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.store_1_2"] },
});
});
it("skips hass updates that change no zone", () => {
const store1 = { ...HASS.states["zone.store_1"] };
const hass = {
...HASS,
states: { ...HASS.states, "zone.store_1": store1 },
} as unknown as HomeAssistant;
const editor = createEditor({
condition: "location",
locations: ["Store 1"],
});
editor.hass = hass;
open(editor);
// Renamed in place, so only a new migration would see it.
store1.attributes = { friendly_name: "Shop 1" };
editor.hass = {
...hass,
states: {
...hass.states,
"person.me": { ...HASS.states["person.me"], state: "not_home" },
},
} as unknown as HomeAssistant;
(editor as any).willUpdate(new Map([["hass", hass]]));
expect((editor as any)._data).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.store_1"] },
});
// A registry update redoes it.
editor.hass = { ...editor.hass, entities: {} } as HomeAssistant;
(editor as any).willUpdate(new Map([["hass", hass]]));
expect((editor as any)._data).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.store_1_2"] },
});
});
it("picks up a registered zone when its state returns", () => {
const editor = createEditor({
condition: "location",
locations: ["Reloading"],
});
open(editor);
editor.hass = {
...HASS,
states: {
...HASS.states,
"zone.reloading": {
entity_id: "zone.reloading",
state: "0",
attributes: { friendly_name: "Reloading" },
},
},
} as unknown as HomeAssistant;
(editor as any).willUpdate(new Map([["hass", HASS]]));
expect((editor as any)._data).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.reloading"] },
});
});
it("shows the alert without writing the config when opened", () => {
const editor = createEditor({
condition: "location",
locations: ["home"],
});
const listener = vi.fn();
editor.addEventListener("value-changed", listener);
open(editor);
expect(listener).not.toHaveBeenCalled();
expect((editor as any)._migrated).toBe(true);
});
it("writes the new format once edited", async () => {
const editor = createEditor({
condition: "location",
locations: ["home"],
});
const value = nextValue(editor);
(editor as any)._valueChanged(
formChange({ ...shown(editor), away: true })
);
expect(await value).toEqual({
condition: "location",
match_mode: "active_zone",
target: { entity_id: ["zone.home"] },
away: true,
});
});
it("leaves a condition without locations unchanged", () => {
const condition: LocationCondition = {
condition: "location",
target: { label_id: "store" },
};
const editor = createEditor(condition);
open(editor);
expect(shown(editor)).toBe(condition);
expect((editor as any)._migrated).toBe(false);
});
});
describe("validateUIConfig", () => {
it.each([
["locations", { condition: "location", locations: ["home"] }],
[
"target and away",
{
condition: "location",
target: { entity_id: "zone.home", label_id: ["store"] },
away: true,
},
],
])("accepts %s", (_name, condition) => {
expect(() => validate(condition)).not.toThrow();
});
it("accepts a match mode", () => {
expect(() =>
validate({
condition: "location",
target: {},
match_mode: "active_zone",
})
).not.toThrow();
});
it.each([
["an unknown match mode", { target: {}, match_mode: "nearest" }],
["a target that is not an object", { target: "zone.home" }],
["an unknown target key", { target: { zone_id: "zone.home" } }],
["a non-boolean away", { target: {}, away: "yes" }],
])("rejects %s", (_name, condition) => {
expect(() => validate({ condition: "location", ...condition })).toThrow();
});
it.each([
["a target", { locations: ["home"], target: {} }],
["a match mode", { locations: ["home"], match_mode: "active_zone" }],
])("rejects locations with %s", (_name, condition) => {
expect(() => validate({ condition: "location", ...condition })).toThrow(
"ui.errors.config.location_locations_combined"
);
});
});
});