Compare commits

..
28 changed files with 781 additions and 2124 deletions
+1 -2
View File
@@ -472,10 +472,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (disabled) {
return;
}
const newValue = value?.trim();
const newTab = ev.ctrlKey || ev.metaKey;
this._fireSelectedEvents(newValue, index, newTab);
this._fireSelectedEvents(value, index, newTab);
};
private _fireSelectedEvents(value: string, index: number, newTab = false) {
-62
View File
@@ -1,62 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { HaListItemBase } from "./ha-list-item-base";
/**
* @element ha-list-item-value
* @extends {HaListItemBase}
*
* @summary
* Non-interactive label/value row for grouped lists: label on the start
* side, value content end-aligned. The value is the default slot so callers
* can render rich content (links, secondary lines).
*
* @slot - The value content.
*
* @csspart label - The label column.
* @csspart value - The value column.
*
* @cssprop --ha-list-item-value-max-width - Maximum width of the value column. Defaults to 60%.
*
* @attr {string} label - The row label.
*/
@customElement("ha-list-item-value")
export class HaListItemValue extends HaListItemBase {
@property({ type: String }) public label?: string;
protected override _renderInner(): TemplateResult {
return html`
<div part="label" class="label">${this.label}</div>
<div part="value" class="value"><slot></slot></div>
`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
}
.label {
flex: 1;
color: var(--secondary-text-color);
}
.value {
max-width: var(--ha-list-item-value-max-width, 60%);
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-value": HaListItemValue;
}
}
-88
View File
@@ -1,88 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { HaListBase } from "./ha-list-base";
/**
* @element ha-grouped-list
* @extends {HaListBase}
*
* @summary
* Grouped list: an optional header above a framed box of rows separated by
* hairlines — the "grouped list" idiom of settings and detail views. Items
* are `<ha-list-item-*>` rows; use `ha-list-item-value` for label/value
* facts and `ha-list-item-button` for navigable rows.
*
* @slot - List items (`<ha-list-item-*>`).
*
* @csspart header - The header above the frame.
* @csspart base - The framed `<div role="list">`.
*
* @cssprop --ha-row-item-padding-inline - Horizontal padding of the rows, which the header aligns to. Defaults to `--ha-space-3`.
*
* @attr {string} header - Header text rendered above the frame.
*/
@customElement("ha-grouped-list")
export class HaGroupedList extends HaListBase {
// The frame carries the list role so the header stays out of the list
// semantics.
protected override readonly hostRole = "";
@property({ type: String }) public header?: string;
protected override render(): TemplateResult {
return html`
${
this.header
? html`<div part="header" class="header" id="header">
${this.header}
</div>`
: nothing
}
<div
part="base"
class="base"
role="list"
aria-labelledby=${ifDefined(this.header ? "header" : undefined)}
>
<slot></slot>
</div>
`;
}
static styles = [
...HaListBase.styles,
css`
:host {
--ha-row-item-padding-inline: var(--ha-space-3);
}
.header {
margin: 0 0 var(--ha-space-1);
margin-inline-start: calc(
var(--ha-row-item-padding-inline) + var(--ha-border-width-sm)
);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.base {
border: var(--ha-border-width-sm) solid var(--divider-color);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
overflow: hidden;
}
::slotted(:not(:first-child)) {
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-grouped-list": HaGroupedList;
}
}
-1
View File
@@ -29,7 +29,6 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,7 +437,6 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+33 -6
View File
@@ -94,6 +94,29 @@ const localizeTimeString = (
}
};
// Seconds since midnight for a literal `HH:MM(:SS)` time, undefined for
// anything else (entity ids contain a dot, and malformed input is ignored).
const literalTimeToSeconds = (value: unknown): number | undefined => {
if (typeof value !== "string" || value.includes(".")) {
return undefined;
}
const chunks = value.split(":");
if (chunks.length < 2 || chunks.length > 3) {
return undefined;
}
const hours = Number(chunks[0]);
const minutes = Number(chunks[1]);
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
if (
!Number.isFinite(hours) ||
!Number.isFinite(minutes) ||
!Number.isFinite(seconds)
) {
return undefined;
}
return hours * 3600 + minutes * 60 + seconds;
};
const formatNumericLimitValue = (
hass: HomeAssistant,
value?: number | string
@@ -1232,12 +1255,16 @@ const describeLegacyCondition = (
let hasTime = "";
if (after !== undefined && before !== undefined) {
if (
typeof condition.after === "string" &&
!condition.after.includes(".") &&
typeof condition.before === "string" &&
!condition.before.includes(".") &&
condition.after > condition.before
const afterSeconds = literalTimeToSeconds(condition.after);
const beforeSeconds = literalTimeToSeconds(condition.before);
if (beforeSeconds === 0) {
// A window ending at midnight runs to the end of the day, so the
// "before" boundary adds nothing to the summary.
hasTime = "after";
} else if (
afterSeconds !== undefined &&
beforeSeconds !== undefined &&
afterSeconds > beforeSeconds
) {
hasTime = "after_before_or";
} else {
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
export const getLogbookDataFromServer = (
const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
+19 -1
View File
@@ -1,3 +1,4 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -7,7 +8,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -15,6 +16,23 @@ export const resolveMediaSource = (
media_content_id,
});
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
// core). Resolving again returns a different signature, which would defeat the
// browser cache, so reuse the resolved URL for just under its validity.
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
export const resolveMediaSourceWithCache = (
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
media_content_id: string
): Promise<ResolvedMediaSource> =>
timeCacheEntityPromiseFunc(
"_resolvedMediaSource",
RESOLVE_CACHE_TIME,
resolveMediaSource,
hass,
media_content_id
);
export const browseLocalMediaPlayer = (
hass: HomeAssistant,
mediaContentId?: string
+94 -44
View File
@@ -7,8 +7,7 @@ import { computeAttributeNameDisplay } from "../../common/entity/compute_attribu
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import "../../components/ha-card";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -70,28 +69,43 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
<ha-grouped-list
.header=${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
>
${stateEntries.map(
(entry) =>
html`<ha-list-item-value
.label=${this.hass.localize(entry.translationKey)}
>
${entry.value}
</ha-list-item-value>`
)}
</ha-grouped-list>
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="data-group">
${stateEntries.map(
(entry) =>
html`<div class="data-entry">
<div class="key">
${this.hass.localize(entry.translationKey)}
</div>
<div class="value">${entry.value}</div>
</div>`
)}
</div>
</div>
</ha-card>
</section>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
>
${this._renderAttributes(attributes)}
</ha-grouped-list>
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="data-group">
${this._renderAttributes(attributes)}
</div>
</div>
</ha-card>
</section>
`
}
</div>
@@ -178,25 +192,28 @@ class HaMoreInfoDetails extends LitElement {
return attributes.map(
(attribute) => html`
<ha-list-item-value
.label=${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
>
${
attribute === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
}
</ha-list-item-value>
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
${
attribute === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
}
</div>
</div>
`
);
}
@@ -230,14 +247,47 @@ class HaMoreInfoDetails extends LitElement {
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-grouped-list + ha-grouped-list {
.section + .section {
margin-top: var(--ha-space-4);
}
.section-title {
margin: 0 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.data-group .data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
padding: var(--ha-space-3) var(--ha-space-4);
padding: var(--ha-space-2) 0;
}
`;
}
@@ -622,6 +622,8 @@ export class HaAutomationRowTargets extends LitElement {
var(--ha-color-border-neutral-quiet);
overflow: hidden;
height: 32px;
box-sizing: border-box;
font: inherit;
}
.target.warning {
background: var(--ha-color-fill-warning-normal-resting);
@@ -622,8 +622,7 @@ class HaBlueprintOverview extends LitElement {
}
),
confirmText: this.hass!.localize(
"ui.panel.config.blueprint.overview.blueprint_in_use_view",
{ type }
`ui.panel.config.blueprint.overview.blueprint_in_use_view_${blueprint.domain}`
),
});
if (result) {
-353
View File
@@ -1,353 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-alert";
import "../../components/ha-relative-time";
import "../../components/ha-spinner";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { fetchDateWS } from "../../data/history";
import type { LogbookEntry } from "../../data/logbook";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import {
buttonLinkStyle,
haStyle,
haStyleDialog,
} from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-chain";
import type { LogbookChain } from "./logbook-chain-resolver";
import { resolveLogbookChain } from "./logbook-chain-resolver";
import type { LogbookItem } from "./logbook-entry-model";
import { computeLogbookItem } from "./logbook-entry-model";
import {
entityNameButtonStyle,
renderEntityName,
transitionArrow,
} from "./logbook-entry-templates";
import type { LogbookDetailDialogParams } from "./show-dialog-logbook-detail";
@customElement("dialog-logbook-detail")
class DialogLogbookDetail
extends LitElement
implements HassDialog<LogbookDetailDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LogbookDetailDialogParams;
@state() private _open = false;
@state() private _chain?: LogbookChain;
@state() private _previousState?: string;
@state() private _error = false;
public showDialog(params: LogbookDetailDialogParams): void {
this._params = params;
this._open = true;
this._chain = undefined;
this._previousState = undefined;
this._error = false;
if (
params.entry.context_event_type === "call_service" &&
params.entry.context_domain
) {
this.hass.loadBackendTranslation("services", params.entry.context_domain);
}
this._loadDetails();
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._chain = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
// Both fetches resolve into a single render so the dialog reflows once.
private async _loadDetails() {
const { entry } = this._params!;
const [{ chain, errored }, previousState] = await Promise.all([
this._fetchChain(entry),
this._fetchPreviousState(entry),
]);
if (this._params?.entry !== entry) {
return;
}
this._error = errored;
this._chain = chain;
this._previousState = previousState;
}
private async _fetchChain(
entry: LogbookEntry
): Promise<{ chain: LogbookChain; errored: boolean }> {
const { userIdToName, systemUserIds } = this._params!;
const options = { userIdToName, systemUserIds };
const resolveWithoutFetch = () =>
resolveLogbookChain(this.hass, entry, options, async () => []);
try {
const chain = isComponentLoaded(this.hass.config, "logbook")
? await resolveLogbookChain(this.hass, entry, options)
: await resolveWithoutFetch();
return { chain, errored: false };
} catch {
return { chain: await resolveWithoutFetch(), errored: true };
}
}
// The feed the row was clicked in can be filtered or partially loaded, so
// the state active just before the entry is resolved from history instead.
private async _fetchPreviousState(
entry: LogbookEntry
): Promise<string | undefined> {
if (
!entry.entity_id ||
entry.state === undefined ||
!isComponentLoaded(this.hass.config, "history")
) {
return undefined;
}
const end = new Date(entry.when * 1000);
const start = new Date(end.getTime() - 1);
try {
const states = await fetchDateWS(this.hass, start, end, [
entry.entity_id,
]);
return states[entry.entity_id]?.[0]?.s;
} catch {
// The row is still useful without an old state.
return undefined;
}
}
protected render() {
if (!this._params) {
return nothing;
}
const { entry } = this._params;
const item = computeLogbookItem(this.hass, entry);
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this.hass.localize("ui.dialogs.logbook_detail.title")}
@closed=${this._dialogClosed}
@hass-more-info=${this._moreInfoOpened}
>
<div class="content">
${this._renderFacts(item, entry)} ${this._renderWhatHappened(entry)}
</div>
</ha-adaptive-dialog>
`;
}
private _renderFacts(item: LogbookItem, entry: LogbookEntry) {
const stateObj = entry.entity_id
? this.hass.states[entry.entity_id]
: undefined;
const transition = this._transitionValues(item, entry, stateObj);
const when = this._entryDate(item.when);
const subjectKey =
item.category === "entity"
? "entity"
: item.category === "automation"
? entry.domain === "script"
? "script"
: "automation"
: "integration";
return html`
<ha-grouped-list>
<ha-list-item-value
.label=${this.hass.localize(
`ui.dialogs.logbook_detail.${subjectKey}` as LocalizeKeys
)}
>
${renderEntityName(this.hass, item.name, entry.entity_id)}
${
item.context
? html`<span class="sub">${item.context}</span>`
: nothing
}
</ha-list-item-value>
${
transition
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.state"
)}
>
${
transition.oldState
? html`<span class="old-state"
>${transition.oldState}</span
><span class="arrow"
>${transitionArrow(this.hass)}</span
>`
: nothing
}<span class="new-state">${transition.newState}</span>
</ha-list-item-value>
`
: item.value
? html`
<ha-list-item-value
.label=${this.hass.localize(
"ui.dialogs.logbook_detail.event"
)}
>
${item.value.text}
</ha-list-item-value>
`
: nothing
}
<ha-list-item-value
class="time-value"
.label=${this.hass.localize("ui.dialogs.logbook_detail.time")}
>
${formatDateTimeWithSeconds(when, this.hass.locale, this.hass.config)}
<span class="sub">
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
</span>
</ha-list-item-value>
</ha-grouped-list>
`;
}
private _renderWhatHappened(entry: LogbookEntry) {
return html`
${
this._error
? html`<ha-alert alert-type="warning">
${this.hass.localize("ui.components.logbook.retrieval_error")}
</ha-alert>`
: nothing
}
<ha-grouped-list
.header=${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
>
<div class="chain-area">
${
this._chain === undefined
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
: html`<ha-logbook-chain
.hass=${this.hass}
.chain=${this._chain}
.subject=${entry}
.traceContexts=${this._params?.traceContexts ?? {}}
></ha-logbook-chain>`
}
</div>
</ha-grouped-list>
`;
}
private _entryDate = memoizeOne((when: number) => new Date(when));
private _transitionValues(
item: LogbookItem,
entry: LogbookEntry,
stateObj?: HassEntity
): { oldState?: string; newState: string } | undefined {
if (item.category !== "entity" || entry.state === undefined) {
return undefined;
}
const newState = stateObj
? this.hass.formatEntityState(stateObj, entry.state)
: entry.state;
const previousState = this._previousState;
const oldState =
previousState !== undefined && previousState !== entry.state
? stateObj
? this.hass.formatEntityState(stateObj, previousState)
: previousState
: undefined;
return { oldState, newState };
}
private _moreInfoOpened() {
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
buttonLinkStyle,
entityNameButtonStyle,
css`
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
ha-list-item-value .name {
font-weight: var(--ha-font-weight-medium);
}
.sub {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.old-state {
color: var(--secondary-text-color);
}
.arrow {
color: var(--disabled-color);
padding: 0 4px;
}
.new-state {
font-weight: var(--ha-font-weight-medium);
}
.time-value {
font-variant-numeric: tabular-nums;
}
ha-relative-time {
display: contents;
}
.chain-area {
display: grid;
}
/* Reserved at two chain rows, the typical chain height, so the swap
from spinner to content barely moves the dialog. Only the spinner
reserves it — a resolved chain sizes to its own rows. */
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 114px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-logbook-detail": DialogLogbookDetail;
}
}
-448
View File
@@ -1,448 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiStateMachine } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { navigate } from "../../common/navigate";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import { computeServiceLabel } from "../../data/compute-service-info";
import type { LogbookEntry } from "../../data/logbook";
import { createHistoricState, localizeTriggerSource } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import { buttonLinkStyle, haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { LogbookChain } from "./logbook-chain-resolver";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeLogbookItem,
computeTraceLink,
entityDisplay,
isRunRow,
isSameLogbookEntry,
nodeColor,
} from "./logbook-entry-model";
import {
entityNameButtonStyle,
renderEntityName,
renderLogbookCauseIcon,
renderLogbookGlyph,
} from "./logbook-entry-templates";
@customElement("ha-logbook-chain")
class HaLogbookChain extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public chain!: LogbookChain;
@property({ attribute: false }) public subject!: LogbookEntry;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
protected render() {
const { rows, runRow, origins, syntheticRun, triggerRow } = this.chain;
if (!origins.length && !runRow && !syntheticRun && rows.length <= 1) {
return html`
<p class="no-cause">
${this.hass.localize("ui.dialogs.logbook_detail.no_known_cause")}
</p>
`;
}
// The chain is the subject's cause path: the runs recorded before its
// rows (user → automation → script → entity). Sibling effects and runs
// recorded after the subject are not causes and stay out.
const isSubjectRow = (row: LogbookEntry) =>
this.subject.entity_id
? row.entity_id === this.subject.entity_id
: isSameLogbookEntry(row, this.subject);
const firstSubjectIndex = rows.findIndex(isSubjectRow);
const visibleRows = rows.filter(
(row, index) =>
isSubjectRow(row) ||
(isRunRow(row) &&
(firstSubjectIndex === -1 || index < firstSubjectIndex))
);
return html`
<div class="chain">
${origins.map((origin) =>
this._renderOriginNode(
origin,
runRow ?? this.subject,
origin.type === "state" ? triggerRow : undefined
)
)}
${syntheticRun ? this._renderSyntheticRunNode(syntheticRun) : nothing}
${visibleRows.map((row) =>
isRunRow(row) ? this._renderRunNode(row) : this._renderEffectNode(row)
)}
</div>
`;
}
private _renderSyntheticRunNode(cause: LogbookCause) {
const sub = this.subject.context_source
? localizeTriggerSource(this.hass.localize, this.subject.context_source)
: this.hass.localize(
cause.type === "script"
? "ui.components.logbook.script_ran"
: "ui.components.logbook.automation_triggered"
);
const traceLink = computeTraceLink(
this.traceContexts,
this.subject.context_id
);
return html`
<div class="chain-row">
<span class="chain-node run">${renderLogbookCauseIcon(cause)}</span>
<span class="chain-content">
${renderEntityName(this.hass, cause.name, cause.entityId)}
${
traceLink
? html`<a
class="trace-link"
href=${traceLink}
@click=${this._traceClicked}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`
: nothing
}
${sub ? html`<span class="chain-secondary">${sub}</span>` : nothing}
</span>
</div>
`;
}
private _renderOriginNode(
origin: LogbookCause,
originRow: LogbookEntry,
triggerRow?: LogbookEntry
) {
const name =
origin.name ||
this.hass.localize("ui.components.logbook.cause.scheduled");
const isState = origin.type === "state";
const triggerState = isState
? (triggerRow?.state ?? originRow.context_state)
: undefined;
const stateObj = origin.entityId
? this.hass.states[origin.entityId]
: undefined;
const triggerValue = triggerState
? stateObj
? this.hass.formatEntityState(stateObj, triggerState)
: triggerState
: undefined;
const secondary = isState
? origin.entityId
? entityDisplay(this.hass, origin.entityId).secondary
: undefined
: this._actionUsedText(originRow);
const isAvatar = origin.type === "user" && !origin.systemUser;
const historicStateObj =
stateObj && triggerState
? createHistoricState(stateObj, triggerState)
: stateObj;
const color =
isState && historicStateObj
? nodeColor("entity", historicStateObj)
: undefined;
return html`
<div class="chain-row">
<span
class="chain-node ${isAvatar ? "avatar" : ""}"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${this._renderOriginIcon(origin, historicStateObj)}
</span>
<span class="chain-content">
${renderEntityName(this.hass, name, origin.entityId)}
${
secondary
? html`<span class="chain-secondary">${secondary}</span>`
: nothing
}
</span>
${
triggerValue
? html`<span class="chain-trailing">
<span class="trailing-state">${triggerValue}</span>
${
triggerRow
? html`<span class="trailing-time"
>${this._formatTimeWithMs(triggerRow.when * 1000)}</span
>`
: nothing
}
</span>`
: nothing
}
</div>
`;
}
private _actionUsedText(originRow: LogbookEntry) {
if (
originRow.context_event_type === "call_service" &&
originRow.context_domain &&
originRow.context_service
) {
return this.hass.localize("ui.dialogs.logbook_detail.action_used", {
name: computeServiceLabel(
this.hass.localize,
this.hass.services,
`${originRow.context_domain}.${originRow.context_service}`
),
});
}
return "";
}
private _renderOriginIcon(origin: LogbookCause, stateObj?: HassEntity) {
if (origin.type === "state") {
return stateObj
? html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
: html`<ha-svg-icon .path=${mdiStateMachine}></ha-svg-icon>`;
}
return renderLogbookCauseIcon(origin);
}
private _formatTimeWithMs(when: number) {
const time = formatTimeWithSeconds(
new Date(when),
this.hass.locale,
this.hass.config
);
const ms = String(Math.floor(when % 1000)).padStart(3, "0");
return `${time}.${ms}`;
}
private _renderRunNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const time = this._formatTimeWithMs(item.when);
const traceLink = computeTraceLink(this.traceContexts, row.context_id);
return html`
<div class="chain-row">
<span class="chain-node run">
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
${renderEntityName(this.hass, item.name, row.entity_id)}
${
traceLink
? html`<a
class="trace-link"
href=${traceLink}
@click=${this._traceClicked}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`
: nothing
}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
</div>
`;
}
private _renderEffectNode(row: LogbookEntry) {
const item = computeLogbookItem(this.hass, row);
const stateObj = row.entity_id
? this.hass.states[row.entity_id]
: undefined;
const historicStateObj = stateObj
? createHistoricState(stateObj, row.state)
: undefined;
const color = nodeColor(item.category, historicStateObj);
const time = this._formatTimeWithMs(item.when);
return html`
<div class="chain-row">
<span
class="chain-node"
style=${color ? styleMap({ "--node-color": color }) : nothing}
>
${renderLogbookGlyph(this.hass, row, item.glyph)}
</span>
<span class="chain-content">
${renderEntityName(this.hass, item.name, row.entity_id)}
${
item.context
? html`<span class="chain-secondary">${item.context}</span>`
: nothing
}
</span>
<span class="chain-trailing">
${
item.value
? html`<span class="trailing-state">${item.value.text}</span>`
: nothing
}
<span class="trailing-time">${time}</span>
</span>
</div>
`;
}
private _traceClicked(ev: MouseEvent) {
const href = isNavigationClick(ev);
if (!href) {
return;
}
// navigate() closes the dialogs above this chain.
navigate(href);
}
static get styles(): CSSResultGroup {
return [
haStyle,
buttonLinkStyle,
entityNameButtonStyle,
css`
:host {
display: block;
}
.chain-row {
position: relative;
display: flex;
align-items: center;
gap: var(--ha-space-4);
min-height: 56px;
padding: var(--ha-space-2) var(--ha-space-4);
box-sizing: border-box;
}
/* Caret between rows: the chain reads top-down, cause to effects. */
.chain-row + .chain-row::before {
content: "";
position: absolute;
top: -3px;
inset-inline-start: 27px;
border-inline-start: 5px solid transparent;
border-inline-end: 5px solid transparent;
border-top: 6px solid var(--divider-color);
}
.chain-node {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--card-background-color);
color: var(--node-color, var(--secondary-text-color));
--mdc-icon-size: 18px;
}
.chain-node state-badge {
margin: 0;
color: inherit;
}
.chain-node::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: currentColor;
opacity: 0.15;
}
.chain-node > * {
position: relative;
}
.chain-node.run {
color: var(
--logbook-category-automation-color,
var(--light-blue-color)
);
border-radius: var(--ha-border-radius-md);
}
.chain-node.avatar::before {
display: none;
}
.chain-node.avatar ha-user-badge {
width: 32px;
height: 32px;
font-size: 14px;
}
.chain-content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.chain-content .name {
font-weight: var(--ha-font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: start;
}
.chain-secondary {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.chain-trailing {
text-align: end;
flex-shrink: 0;
}
.trailing-state {
display: block;
}
.trailing-time {
display: block;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-variant-numeric: tabular-nums;
}
.trace-link {
flex-shrink: 0;
color: var(--primary-color);
font-size: var(--ha-font-size-s);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
.no-cause {
margin: 0;
padding: var(--ha-space-3) var(--ha-space-4);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-logbook-chain": HaLogbookChain;
}
}
+229 -121
View File
@@ -1,50 +1,58 @@
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-relative-time";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-tooltip";
import "../../components/user/ha-user-badge";
import { UNAVAILABLE } from "../../data/entity/entity";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import type { User } from "../../data/user";
import { buttonLinkStyle, haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type {
LogbookCause,
LogbookCauseType,
LogbookGlyph,
LogbookItem,
LogbookNameDetail,
LogbookValue,
} from "./logbook-entry-model";
import { computeLogbookItem, nodeColor } from "./logbook-entry-model";
import {
renderLogbookCauseIcon,
renderLogbookGlyph,
transitionArrow,
} from "./logbook-entry-templates";
computeLogbookItem,
nodeColor,
TRIGGER_DOMAINS,
} from "./logbook-entry-model";
type EntryLayout = "timeline" | "list" | "inline";
interface LogbookRenderItem extends LogbookItem {
traceLink: string | undefined;
renderedTime: TemplateResult | string;
renderedValue: TemplateResult | string;
}
export interface LogbookEntrySelectedDetail {
item: LogbookEntry;
}
declare global {
interface HASSDomEvents {
"logbook-entry-selected": LogbookEntrySelectedDetail;
"logbook-toggle-time": undefined;
}
}
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
@customElement("ha-logbook-entry")
class HaLogbookEntry extends LitElement {
@@ -57,7 +65,7 @@ class HaLogbookEntry extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ type: Boolean }) public narrow = false;
@@ -91,6 +99,17 @@ class HaLogbookEntry extends LitElement {
systemUserIds: this.systemUserIds,
});
const traceContext =
entry.domain &&
TRIGGER_DOMAINS.includes(entry.domain) &&
entry.context_id &&
entry.context_id in this.traceContexts
? this.traceContexts[entry.context_id]
: undefined;
const traceLink = traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
const hideName = this.nameDetail === "none";
const layout: EntryLayout =
!this.narrow && !this.noIcon ? "timeline" : hideName ? "inline" : "list";
@@ -106,8 +125,9 @@ class HaLogbookEntry extends LitElement {
const ctx: LogbookRenderItem = {
...item,
traceLink,
renderedTime,
renderedValue: this._renderValue(item.value, seenEntityIds),
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
};
return html`
@@ -154,25 +174,33 @@ class HaLogbookEntry extends LitElement {
`;
}
private _toggleTime() {
fireEvent(this, "logbook-toggle-time");
private _toggleTime(e: Event) {
e.stopPropagation();
fireEvent(this, "logbook-toggle-time" as any);
}
private _timeKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fireEvent(this, "logbook-toggle-time");
fireEvent(this, "logbook-toggle-time" as any);
}
}
private _showDetail() {
fireEvent(this, "logbook-entry-selected", { item: this.item });
private _handleTraceClick(ev: MouseEvent) {
// Let modified clicks open in a new tab; otherwise route in-app.
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
return;
}
ev.preventDefault();
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
fireEvent(this, "closed");
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) return;
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "hass-more-info", { entityId });
}
@@ -189,40 +217,38 @@ class HaLogbookEntry extends LitElement {
private _renderTrailing(
cause: LogbookCause | undefined,
traceLink: string | undefined,
renderedTime: TemplateResult | string
) {
return html`<span class="trailing">
${cause ? this._renderCauseBadge(cause) : nothing}
${
cause
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge"
>${this._renderCauseIcon(cause)}</span
>`
: nothing
}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
${this._renderTimeChip(renderedTime)}
</span>`;
}
private _renderCauseBadge(cause: LogbookCause) {
const icon = renderLogbookCauseIcon(cause);
if (this.noDetail) {
return html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
<span class="cause-badge" id="cause-badge">${icon}</span>`;
}
return html`<button
class="cause-badge"
aria-label=${this.hass.localize("ui.components.logbook.view_details")}
@click=${this._showDetail}
>
${icon}
</button>`;
}
private _renderDetailsLink() {
if (this.noDetail) {
return nothing;
}
return html`<button class="link details-link" @click=${this._showDetail}>
${this.hass.localize("ui.components.logbook.view_details")}
</button>`;
private _renderTraceLink(traceLink: string) {
return html`<a
class="trace-link"
href=${traceLink}
@click=${this._handleTraceClick}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`;
}
private _renderTimeline(ctx: LogbookRenderItem) {
const hideName = this.nameDetail === "none";
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const valueIsState = ctx.value?.type === "state";
const causePhrase = ctx.cause
? this._renderCausePhrase(ctx.cause)
@@ -237,9 +263,7 @@ class HaLogbookEntry extends LitElement {
>${
ctx.renderedValue
? valueIsState
? html`<span class="arrow"
>${transitionArrow(this.hass)}</span
>`
? html`<span class="arrow">${rtl ? "←" : "→"}</span>`
: " "
: nothing
}`
@@ -255,10 +279,15 @@ class HaLogbookEntry extends LitElement {
: nothing
}
${
causePhrase
causePhrase || ctx.traceLink
? html`<div class="secondary">
<span class="cause-phrase">${causePhrase}</span>
${this.noDetail ? nothing : html`·`} ${this._renderDetailsLink()}
${
causePhrase
? html`<span class="cause-phrase">${causePhrase}</span>`
: nothing
}
${causePhrase && ctx.traceLink ? html`·` : nothing}
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
</div>`
: nothing
}
@@ -266,8 +295,11 @@ class HaLogbookEntry extends LitElement {
}
private _renderList(ctx: LogbookRenderItem) {
const cause = ctx.cause;
const showThirdLine = this.showCause && cause;
const cause =
this.showCause || ctx.category === "entity" ? ctx.cause : undefined;
const trailingTrace = this.showCause ? undefined : ctx.traceLink;
const thirdLineTrace = this.showCause ? ctx.traceLink : undefined;
const showThirdLine = this.showCause && (cause || thirdLineTrace);
return html`
<div class="primary">
<span class="subject"
@@ -281,22 +313,26 @@ class HaLogbookEntry extends LitElement {
<span class="secondary-text">${ctx.context ?? nothing}</span>
${this._renderTrailing(
showThirdLine ? undefined : cause,
trailingTrace,
ctx.renderedTime
)}
</div>
${
showThirdLine
? html`<div class="secondary">
${this._renderListCauseLine(cause)} ${this._renderDetailsLink()}
${this._renderListCauseLine(cause, thirdLineTrace)}
</div>`
: nothing
}
`;
}
private _renderListCauseLine(cause: LogbookCause | undefined) {
private _renderListCauseLine(
cause: LogbookCause | undefined,
traceLink: string | undefined
) {
if (!cause) {
return nothing;
return traceLink ? this._renderTraceLink(traceLink) : nothing;
}
const { localize } = this.hass;
if (cause.entityId) {
@@ -312,8 +348,8 @@ class HaLogbookEntry extends LitElement {
}),
};
const prefix = prefixMap[cause.type];
return html`<span class="cause-line">
${prefix ?? nothing}
return html`
${prefix ? html`<span class="cause-prefix">${prefix}</span>` : nothing}
<button
class="link cause-entity"
@click=${this._entityClicked}
@@ -321,37 +357,45 @@ class HaLogbookEntry extends LitElement {
>
${cause.name}
</button>
</span>`;
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
return html`<span class="cause-line"
>${this._renderCausePhrase(cause)}</span
>`;
return html`
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
${traceLink ? this._renderTraceLink(traceLink) : nothing}
`;
}
private _renderInline(ctx: LogbookRenderItem) {
return html`
<div class="primary">
<span class="primary-text">${ctx.renderedValue}</span>
${this._renderTrailing(ctx.cause, ctx.renderedTime)}
${this._renderTrailing(
ctx.category === "entity" ? ctx.cause : undefined,
ctx.traceLink,
ctx.renderedTime
)}
</div>
`;
}
private _renderValue(
value: LogbookValue | undefined,
seenEntityIds: string[]
seenEntityIds: string[],
noLink: boolean
): TemplateResult | string {
if (!value) {
return "";
}
return value.type === "message"
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds)
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds, noLink)
: value.text;
}
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined
entityName: string | undefined,
noLink?: boolean
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
@@ -362,13 +406,15 @@ class HaLogbookEntry extends LitElement {
if (!hasState) {
return displayName;
}
return html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
return noLink
? displayName
: html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _renderCausePhrase(cause: LogbookCause): TemplateResult | string {
@@ -424,9 +470,54 @@ class HaLogbookEntry extends LitElement {
}
}
private _renderCauseIcon(cause: LogbookCause) {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return nothing;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[]
seenEntities: string[],
noLink?: boolean
) {
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
@@ -442,7 +533,8 @@ class HaLogbookEntry extends LitElement {
return html`${messageParts.join(" ")}
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name
this.hass.states[entityId].attributes.friendly_name,
noLink
)}
${messageEnd.join(" ")}`;
}
@@ -479,11 +571,49 @@ class HaLogbookEntry extends LitElement {
const unavailable =
item.glyph.type === "state" && item.glyph.stateObj.state === UNAVAILABLE;
return html`<div class="node-glyph" style=${style}>
${renderLogbookGlyph(this.hass, this.item, item.glyph)}
${this._renderGlyph(item.glyph)}
${unavailable ? html`<span class="node-badge"></span>` : nothing}
</div>`;
}
private _renderGlyph(glyph: LogbookGlyph) {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${this._brandImage(glyph.domain)}
.stateColor=${false}
></state-badge>`;
}
private _brandImage(domain?: string): string | undefined {
if (
!domain ||
this.item.icon ||
this.item.state ||
!isComponentLoaded(this.hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -821,42 +951,12 @@ class HaLogbookEntry extends LitElement {
align-items: center;
}
button.cause-badge {
border: none;
background: none;
color: inherit;
font: inherit;
/* Grow the hit target without moving the icons. */
padding: var(--ha-space-2);
margin: calc(-1 * var(--ha-space-2));
border-radius: var(--ha-border-radius-pill);
cursor: pointer;
}
button.cause-badge:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.06);
}
button.cause-badge:focus-visible {
outline: 2px solid var(--primary-color);
}
.details-link {
flex-shrink: 0;
}
button.link.details-link {
color: var(--primary-color);
font-weight: var(--ha-font-weight-normal);
}
ha-relative-time {
display: contents;
}
.time-chip {
flex-shrink: 0;
min-width: 4.5em;
text-align: end;
line-height: 1;
font-size: var(--ha-font-size-s);
@@ -866,6 +966,10 @@ class HaLogbookEntry extends LitElement {
user-select: none;
}
.time-chip {
min-width: 4.5em;
}
.entry.time-am-pm .time-chip {
min-width: 6em;
}
@@ -887,29 +991,33 @@ class HaLogbookEntry extends LitElement {
font-size: 9px;
}
/* The cause reads as one sentence on one line: it truncates as a
whole and only the details link is kept out of the ellipsis. */
.cause-phrase {
flex: 0 1 auto;
min-width: 0;
.cause-prefix {
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cause-line {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cause-entity {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
/* The trace link sits after the cause; it never shrinks, so a long
cause truncates instead. */
.trace-link {
flex-shrink: 0;
color: var(--primary-color);
text-decoration: none;
}
.trace-link:hover {
text-decoration: underline;
}
/* Entity names read as the subject, not a wall of blue links — the
colored node is the scan anchor. */
button.link {
+6 -37
View File
@@ -6,7 +6,6 @@ import { customElement, eventOptions, property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
@@ -14,14 +13,13 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-entry";
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
import type { LogbookNameDetail } from "./logbook-entry-model";
import { sameDay } from "./logbook-entry-model";
import { showLogbookDetailDialog } from "./show-dialog-logbook-detail";
declare global {
interface HASSDomEvents {
"hass-logbook-live": { enable: boolean };
"logbook-toggle-time": undefined;
}
}
@@ -34,7 +32,6 @@ class HaLogbookRenderer extends LitElement {
@property({ attribute: false }) public systemUserIds = new Set<string>();
// Not rendered by rows; read at click time and handed to the detail dialog.
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@@ -52,8 +49,6 @@ class HaLogbookRenderer extends LitElement {
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
false;
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
@property({ type: String, attribute: "name-detail" })
public nameDetail?: LogbookNameDetail;
@@ -82,9 +77,7 @@ class HaLogbookRenderer extends LitElement {
return (
changedProps.has("entries") ||
changedProps.has("noDetail") ||
changedProps.has("userIdToName") ||
changedProps.has("systemUserIds") ||
changedProps.has("traceContexts") ||
changedProps.has("_showRelative" as never) ||
languageChanged
);
@@ -104,7 +97,6 @@ class HaLogbookRenderer extends LitElement {
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@logbook-toggle-time=${this._handleToggleTime}
@logbook-entry-selected=${this._handleEntrySelected}
>
${
this.virtualize
@@ -113,13 +105,7 @@ class HaLogbookRenderer extends LitElement {
scroller
class="ha-scrollbar"
.items=${this.entries}
.renderItem=${
this._getRenderRow(
this._showRelative,
this.userIdToName,
this.systemUserIds
) as any
}
.renderItem=${this._getRenderRow(this._showRelative) as any}
>
</lit-virtualizer>`
: this.entries.map((item, index) => this._renderItem(item, index))
@@ -128,16 +114,9 @@ class HaLogbookRenderer extends LitElement {
`;
}
// Memoized on every input the rows render from, so the virtualizer sees a
// new renderItem and refreshes already-rendered rows when one changes.
private _getRenderRow = memoizeOne(
(
_showRelative: boolean,
_userIdToName: Record<string, string>,
_systemUserIds: Set<string>
) =>
(item: LogbookEntry, index: number) =>
this._renderItem(item, index)
(_showRelative: boolean) => (item: LogbookEntry, index: number) =>
this._renderItem(item, index)
);
private _renderItem = (item: LogbookEntry, index: number) => {
@@ -162,6 +141,7 @@ class HaLogbookRenderer extends LitElement {
.item=${item}
.userIdToName=${this.userIdToName}
.systemUserIds=${this.systemUserIds}
.traceContexts=${this.traceContexts}
.narrow=${this.narrow}
.noIcon=${this.noIcon}
.graphColor=${this.graphColor}
@@ -170,7 +150,6 @@ class HaLogbookRenderer extends LitElement {
.lastOfDay=${lastOfDay}
.showRelative=${this._showRelative}
.showCause=${this.showCause}
.noDetail=${this.noDetail}
></ha-logbook-entry>
</div>
`;
@@ -180,16 +159,6 @@ class HaLogbookRenderer extends LitElement {
this._showRelative = !this._showRelative;
}
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
ev.stopPropagation();
showLogbookDetailDialog(this, {
entry: ev.detail.item,
traceContexts: this.traceContexts,
userIdToName: this.userIdToName,
systemUserIds: this.systemUserIds,
});
}
private _formatDateHeader(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -1,184 +0,0 @@
import type { LogbookEntry } from "../../data/logbook";
import { getLogbookDataFromServer } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
import type { LogbookCause } from "./logbook-entry-model";
import {
computeContextCause,
computeLogbookCause,
computeUserCause,
isRunCause,
isRunRow,
isSameLogbookEntry,
} from "./logbook-entry-model";
// No run start is available and a delayed script can start long before the
// clicked entry.
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
const WHEN_EPSILON = 0.001;
const MAX_RUN_CANDIDATES = 3;
export interface LogbookChain {
rows: LogbookEntry[];
runRow?: LogbookEntry;
// Causes shown above the run, topmost first.
origins: LogbookCause[];
// Stands in for the run row when it could not be fetched.
syntheticRun?: LogbookCause;
// The state change that fired a state trigger in `origins`.
triggerRow?: LogbookEntry;
}
export type LogbookFetcher = (
startDate: string,
endDate?: string,
entityIds?: string[],
contextId?: string
) => Promise<LogbookEntry[]>;
interface ResolveOptions {
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
const lookbackIso = (when: number) =>
new Date(when * 1000 - LOOKBACK_MS).toISOString();
const justAfterIso = (when: number) =>
new Date(when * 1000 + 1000).toISOString();
// Effect rows never carry a context_id, run rows do: the logbook processor
// only emits it on rows described by the automation/script platforms. This
// candidate discovery + verification is a workaround for that gap; once core
// emits context_id on described rows too, the fast path by entry.context_id
// covers every click and this can be deleted.
const resolveRowsByContextEntity = async (
entry: LogbookEntry,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry[]> => {
const contextEntityId = entry.context_entity_id!;
const runs = (
await fetchEvents(lookbackIso(entry.when), justAfterIso(entry.when), [
contextEntityId,
])
)
.filter((row) => row.context_id && row.when <= entry.when + WHEN_EPSILON)
.sort((a, b) => b.when - a.when)
.slice(0, MAX_RUN_CANDIDATES);
for (const run of runs) {
// eslint-disable-next-line no-await-in-loop
const rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
run.context_id
);
if (rows.some((row) => isSameLogbookEntry(row, entry))) {
return rows;
}
}
return [];
};
const resolveTriggerRow = async (
entityId: string,
beforeWhen: number,
fetchEvents: LogbookFetcher
): Promise<LogbookEntry | undefined> => {
const rows = await fetchEvents(
lookbackIso(beforeWhen),
justAfterIso(beforeWhen),
[entityId]
);
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.state !== undefined && row.when <= beforeWhen + WHEN_EPSILON) {
return row;
}
}
return undefined;
};
export const resolveLogbookChain = async (
hass: HomeAssistant,
entry: LogbookEntry,
options: ResolveOptions = {},
fetchEvents: LogbookFetcher = (startDate, endDate, entityIds, contextId) =>
getLogbookDataFromServer(hass, startDate, endDate, entityIds, contextId)
): Promise<LogbookChain> => {
const userIdToName = options.userIdToName ?? {};
const { systemUserIds } = options;
let rows: LogbookEntry[] = [];
if (entry.context_id) {
rows = await fetchEvents(
lookbackIso(entry.when),
undefined,
undefined,
entry.context_id
);
} else if (entry.context_entity_id) {
rows = await resolveRowsByContextEntity(entry, fetchEvents);
}
if (!rows.length) {
rows = [entry];
}
let runRow = rows.find(isRunRow);
if (runRow && runRow !== entry && isSameLogbookEntry(entry, runRow)) {
// The clicked feed copy carries the call_service description that the
// fetched copy of the run row never has.
rows = rows.map((row) => (row === runRow ? entry : row));
runRow = entry;
}
const origins: LogbookCause[] = [];
let syntheticRun: LogbookCause | undefined;
if (runRow) {
const runCause = computeLogbookCause(
hass,
runRow,
userIdToName,
systemUserIds
);
if (runCause?.type !== "user" && !isSameLogbookEntry(entry, runRow)) {
// The run row is its own context origin and comes back without the
// user its effects carry: read the user from the clicked entry.
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
if (userCause) {
origins.push(userCause);
}
}
if (runCause) {
origins.push(runCause);
}
} else {
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
const contextCause = computeContextCause(hass, entry);
if (isRunCause(contextCause)) {
syntheticRun = contextCause;
if (userCause) {
origins.push(userCause);
}
} else {
const cause = userCause ?? contextCause;
if (cause) {
origins.push(cause);
}
}
}
const stateOrigin = origins.find(
(cause) => cause.type === "state" && cause.entityId
);
const triggerRow = stateOrigin
? await resolveTriggerRow(
stateOrigin.entityId!,
(runRow ?? entry).when,
fetchEvents
)
: undefined;
return { rows, runRow, origins, syntheticRun, triggerRow };
};
+21 -68
View File
@@ -12,14 +12,13 @@ import {
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import type { HomeAssistant } from "../../types";
export type LogbookEntryCategory = "entity" | "automation" | "integration";
const TRIGGER_DOMAINS = ["automation", "script"];
export const TRIGGER_DOMAINS = ["automation", "script"];
const stripEntityId = (message: string, entityId?: string) =>
export const stripEntityId = (message: string, entityId?: string) =>
entityId ? message.replace(entityId, " ") : message;
export const classifyLogbookEntry = (
@@ -36,10 +35,6 @@ export const classifyLogbookEntry = (
return "integration";
};
// A run row is the acting automation or script; every other row is an effect.
export const isRunRow = (item: LogbookEntry): boolean =>
classifyLogbookEntry(item) === "automation";
// How much naming detail an entity row shows, from least to most. The value is
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
// entity), `area` (area ▸ device ▸ entity).
@@ -101,31 +96,12 @@ export const entityDisplay = (
return { primary, secondary };
};
const hasContext = (item: LogbookEntry) =>
export const hasContext = (item: LogbookEntry) =>
item.context_event_type || item.context_state || item.context_message;
export const sameDay = (a?: LogbookEntry, b?: LogbookEntry) =>
!!a?.when && !!b?.when && isSameDay(a.when * 1000, b.when * 1000);
export const isSameLogbookEntry = (a: LogbookEntry, b: LogbookEntry) =>
a.when === b.when &&
a.entity_id === b.entity_id &&
a.state === b.state &&
a.message === b.message &&
a.name === b.name;
// Every entry of a run shares the run's context id, so effect rows resolve
// to their cause's trace too.
export const computeTraceLink = (
traceContexts: TraceContexts,
contextId?: string
): string | undefined => {
const traceContext = contextId ? traceContexts[contextId] : undefined;
return traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
};
// Unavailable is flagged with an orange badge by the row, not a color change.
export const nodeColor = (
category: LogbookEntryCategory,
@@ -155,7 +131,8 @@ export interface LogbookCause {
brandDomain?: string;
}
export const computeUserCause = (
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
@@ -163,21 +140,15 @@ export const computeUserCause = (
const userName = item.context_user_id
? userIdToName[item.context_user_id]
: undefined;
if (!userName) {
return undefined;
if (userName) {
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
}
return {
type: "user",
name: userName,
userId: item.context_user_id,
systemUser: systemUserIds?.has(item.context_user_id!),
};
};
export const computeContextCause = (
hass: HomeAssistant,
item: LogbookEntry
): LogbookCause | undefined => {
if (
item.context_event_type === "automation_triggered" ||
item.context_event_type === "script_started"
@@ -268,18 +239,6 @@ export const computeContextCause = (
return undefined;
};
export const computeLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>,
systemUserIds?: Set<string>
): LogbookCause | undefined =>
computeUserCause(item, userIdToName, systemUserIds) ??
computeContextCause(hass, item);
export const isRunCause = (cause?: LogbookCause): boolean =>
cause?.type === "automation" || cause?.type === "script";
export type LogbookGlyph =
| { type: "state"; stateObj: HassEntity; icon?: string }
| { type: "automation"; script: boolean }
@@ -325,11 +284,10 @@ const computeLogbookValue = (
type: "state",
};
}
// Core sends run rows (carrying the automation/script entity) with a raw
// English message; use our own label. Domain-only entries (e.g. logbook.log)
// keep their custom message.
const isAutomationRun =
item.entity_id && domain && TRIGGER_DOMAINS.includes(domain);
domain &&
TRIGGER_DOMAINS.includes(domain) &&
(item.source || hasContext(item) || !!item.context_user_id);
if (isAutomationRun) {
return {
text: hass.localize(
@@ -390,13 +348,6 @@ export const computeLogbookItem = (
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
: undefined;
const userCause = computeUserCause(
entry,
opts.userIdToName ?? {},
opts.systemUserIds
);
const contextCause = computeContextCause(hass, entry);
return {
category,
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
@@ -404,10 +355,12 @@ export const computeLogbookItem = (
name: display?.primary ?? entry.name,
context: display?.secondary,
value: computeLogbookValue(hass, entry, domain, historicStateObj),
// A row shows the run over the user who started it; the dialog shows both.
cause: isRunCause(contextCause)
? contextCause
: (userCause ?? contextCause),
cause: computeLogbookCause(
hass,
entry,
opts.userIdToName ?? {},
opts.systemUserIds
),
when: entry.when * 1000,
};
};
@@ -1,177 +0,0 @@
import {
mdiCast,
mdiClockOutline,
mdiCloud,
mdiPuzzle,
mdiRobot,
mdiScriptText,
mdiStateMachine,
} from "@mdi/js";
import { css, html } from "lit";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/user/ha-user-badge";
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
import type { LogbookEntry } from "../../data/logbook";
import type { User } from "../../data/user";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { LogbookCause, LogbookGlyph } from "./logbook-entry-model";
// Names are the fixed system user names set by core (cloud/cast integrations).
const SYSTEM_USER_ICONS: Record<string, string> = {
"Home Assistant Cloud": mdiCloud,
"Home Assistant Cast": mdiCast,
};
export const renderLogbookCauseIcon = (cause: LogbookCause) => {
if (cause.type === "user") {
const systemIcon = cause.systemUser
? SYSTEM_USER_ICONS[cause.name]
: undefined;
if (systemIcon) {
return html`<ha-svg-icon
class="cause-icon"
.path=${systemIcon}
></ha-svg-icon>`;
}
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${{ id: cause.userId!, name: cause.name } as User}
></ha-user-badge>`;
}
if (cause.type === "automation") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiRobot}
></ha-svg-icon>`;
}
if (cause.type === "script") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiScriptText}
></ha-svg-icon>`;
}
if (cause.type === "state") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiStateMachine}
></ha-svg-icon>`;
}
if (cause.type === "scheduled") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiClockOutline}
></ha-svg-icon>`;
}
if (cause.type === "homeassistant") {
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiHomeAssistant}
></ha-svg-icon>`;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
return html`<ha-svg-icon
class="cause-icon"
.path=${mdiPuzzle}
></ha-svg-icon>`;
};
const brandImage = (
hass: HomeAssistant,
entry: LogbookEntry,
domain?: string
): string | undefined => {
if (
!domain ||
entry.icon ||
entry.state ||
!isComponentLoaded(hass.config, domain)
) {
return undefined;
}
return brandsUrl(
{
domain,
type: "icon",
darkOptimized: hass.themes?.darkMode,
},
hass.auth.data.hassUrl
);
};
const entityNameClicked = (ev: Event) => {
const target = ev.currentTarget as HTMLElement & { entityId?: string };
if (!target.entityId) {
return;
}
fireEvent(target, "hass-more-info", { entityId: target.entityId });
};
// The event bubbles from the button, so an enclosing dialog can react to it
// (ha-adaptive-dialog closes on hass-more-info).
export const renderEntityName = (
hass: HomeAssistant,
name: string | undefined,
entityId?: string
) => {
if (entityId && entityId in hass.states) {
return html`<button
class="link name"
.entityId=${entityId}
@click=${entityNameClicked}
>
${name}
</button>`;
}
return html`<span class="name">${name}</span>`;
};
export const entityNameButtonStyle = css`
button.link.name {
color: var(--primary-text-color);
text-align: inherit;
text-decoration: none;
}
button.link.name:hover {
text-decoration: underline;
}
`;
export const transitionArrow = (hass: HomeAssistant) =>
computeRTL(hass.language, hass.translationMetadata.translations) ? "←" : "→";
export const renderLogbookGlyph = (
hass: HomeAssistant,
entry: LogbookEntry,
glyph: LogbookGlyph
) => {
if (glyph.type === "automation") {
return html`<ha-svg-icon
.path=${glyph.script ? mdiScriptText : mdiRobot}
></ha-svg-icon>`;
}
if (glyph.type === "state") {
return html`<ha-state-icon
.stateObj=${glyph.stateObj}
.icon=${glyph.icon}
></ha-state-icon>`;
}
return html`<state-badge
.overrideIcon=${glyph.icon}
.overrideImage=${brandImage(hass, entry, glyph.domain)}
.stateColor=${false}
></state-badge>`;
};
@@ -1,23 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
export interface LogbookDetailDialogParams {
entry: LogbookEntry;
traceContexts?: TraceContexts;
userIdToName?: Record<string, string>;
systemUserIds?: Set<string>;
}
export const loadLogbookDetailDialog = () => import("./dialog-logbook-detail");
export const showLogbookDetailDialog = (
element: HTMLElement,
params: LogbookDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-logbook-detail",
dialogImport: loadLogbookDetailDialog,
dialogParams: params,
});
};
@@ -11,7 +11,7 @@ import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../../data/media_source";
@customElement("hui-view-background-editor")
@@ -136,21 +136,35 @@ export class HuiViewBackgroundEditor extends LitElement {
`${(background.opacity ?? 100) / 100}`
);
const backgroundImage =
typeof background.image === "object"
? background.image.media_content_id
: background.image;
const backgroundImage = this._currentBackgroundImage();
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this._resolvedImage = result.url;
});
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
(result) => {
// Discard if the image changed while resolving
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = result.url;
}
},
() => {
if (this._currentBackgroundImage() === backgroundImage) {
this._resolvedImage = undefined;
}
}
);
} else {
this._resolvedImage = backgroundImage;
}
}
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this._config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
}
protected render() {
if (!this.hass) {
return nothing;
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
import {
isMediaSourceContentId,
resolveMediaSource,
resolveMediaSourceWithCache,
} from "../../../data/media_source";
@customElement("hui-view-background")
@@ -21,20 +21,37 @@ export class HUIViewBackground extends LitElement {
return nothing;
}
private _fetchMedia() {
const backgroundImage =
typeof this.background === "string"
? this.background
: typeof this.background?.image === "object"
? this.background.image.media_content_id
: this.background?.image;
private _getBackgroundImage(
background?: string | LovelaceViewBackgroundConfig
): string | undefined {
if (typeof background === "string") {
return background;
}
if (typeof background?.image === "object") {
return background.image.media_content_id;
}
return background?.image;
}
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this.resolvedImage = result.url;
});
} else {
private async _fetchMedia() {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
this.resolvedImage = undefined;
return;
}
let resolvedUrl: string | undefined;
try {
resolvedUrl = (
await resolveMediaSourceWithCache(this.hass, backgroundImage)
).url;
} catch {
resolvedUrl = undefined;
}
// Discard if the background changed while resolving
if (this._getBackgroundImage(this.background) === backgroundImage) {
this.resolvedImage = resolvedUrl;
}
}
@@ -73,10 +90,7 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig
) {
if (typeof background === "object" && background.image) {
const image =
typeof background.image === "object"
? background.image.media_content_id || ""
: background.image;
const image = this._getBackgroundImage(background) || "";
if (isMediaSourceContentId(image) && !this.resolvedImage) {
return null;
}
+2 -15
View File
@@ -694,7 +694,6 @@
"automation_triggered": "Triggered",
"script_ran": "Ran",
"view_trace": "View trace",
"view_details": "View details",
"trigger_type": {
"calendar": "[%key:ui::panel::config::automation::editor::triggers::type::calendar::label%]",
"conversation": "[%key:ui::panel::config::automation::editor::triggers::type::conversation::label%]",
@@ -2050,19 +2049,6 @@
"description": "Description",
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
},
"logbook_detail": {
"title": "Activity details",
"entity": "Entity",
"automation": "Automation",
"script": "Script",
"integration": "Integration",
"state": "State",
"event": "Event",
"time": "Time",
"what_happened": "What happened",
"no_known_cause": "No cause was recorded for this activity.",
"action_used": "Action used: {name}"
},
"voice-settings": {
"expose_header": "Expose",
"aliases_header": "Aliases",
@@ -6145,7 +6131,8 @@
"error": "{path} could not be loaded",
"blueprint_in_use_title": "This blueprint is in use and cannot be deleted",
"blueprint_in_use_text": "Please remove all below {type} that use this blueprint before deleting it. {list}",
"blueprint_in_use_view": "view {type}",
"blueprint_in_use_view_automation": "View automations",
"blueprint_in_use_view_script": "View scripts",
"confirm_delete_title": "Delete blueprint?",
"confirm_delete_text": "{name} will be permanently deleted.",
"add_blueprint": "Import blueprint",
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
RESOLVE_CACHE_TIME,
resolveMediaSourceWithCache,
} from "../../src/data/media_source";
import type { HomeAssistant } from "../../src/types";
const CONTENT_ID = "media-source://image_upload/background";
const OTHER_CONTENT_ID = "media-source://image_upload/other";
// Core signs every resolution with a fresh timestamp, so an uncached resolve of
// the same id yields a different url each time. The mock reproduces that: the
// urls only stay equal if the resolution itself was reused.
const mockHass = () => {
let signature = 0;
return {
callWS: vi.fn(({ media_content_id }: { media_content_id: string }) => {
signature += 1;
return Promise.resolve({
url: `/api/image/serve/${media_content_id.split("/").pop()}/original?authSig=sig${signature}`,
mime_type: "image/jpeg",
});
}),
} as unknown as HomeAssistant;
};
describe("resolveMediaSourceWithCache", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("returns the same url when the same content id is resolved again", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("shares a single request between concurrent callers", async () => {
const hass = mockHass();
const [first, second] = await Promise.all([
resolveMediaSourceWithCache(hass, CONTENT_ID),
resolveMediaSourceWithCache(hass, CONTENT_ID),
]);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("resolves each content id to its own url", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
const other = await resolveMediaSourceWithCache(hass, OTHER_CONTENT_ID);
expect(first.url).toContain("/background/");
expect(other.url).toContain("/other/");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("keeps returning the same url when hass is updated", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
// State updates replace hass with a shallow copy
const second = await resolveMediaSourceWithCache(
{ ...hass } as HomeAssistant,
CONTENT_ID
);
expect(second.url).toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(1);
});
it("does not cache failures", async () => {
const hass = mockHass();
vi.mocked(hass.callWS).mockRejectedValueOnce(new Error("unresolvable"));
await expect(
resolveMediaSourceWithCache(hass, CONTENT_ID)
).rejects.toThrowError("unresolvable");
const retried = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(retried.url).toContain("authSig=");
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("resolves a fresh url once the cached one is about to expire", async () => {
const hass = mockHass();
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
vi.advanceTimersByTime(RESOLVE_CACHE_TIME);
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
expect(second.url).not.toBe(first.url);
expect(hass.callWS).toHaveBeenCalledTimes(2);
});
it("caches for less than the 24 hour signature validity", () => {
expect(RESOLVE_CACHE_TIME).toBeLessThan(24 * 60 * 60 * 1000);
});
});
@@ -0,0 +1,76 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import { describeCondition } from "../../src/data/automation_i18n";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
import en from "../../src/translations/en.json";
import type { HomeAssistant } from "../../src/types";
type TranslationNode = string | { [key: string]: TranslationNode };
const localize = (key: string, values?: Record<string, unknown>) => {
const message = key
.split(".")
.reduce<TranslationNode | undefined>(
(translations, part) =>
typeof translations === "object" ? translations[part] : undefined,
en as TranslationNode
);
return typeof message === "string"
? (new IntlMessageFormat(message, "en").format(values) as string)
: "";
};
const hass = {
localize,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
config: { time_zone: "Etc/UTC" },
states: {},
} as unknown as HomeAssistant;
const describeTimeCondition = (after?: string, before?: string) =>
describeCondition({ condition: "time", after, before }, hass, []);
describe("time condition description", () => {
it("joins a window within one day with 'and'", () => {
expect(describeTimeCondition("09:00:00", "17:00:00")).toBe(
"If the time is after 09:00 and before 17:00"
);
});
it("joins a window crossing midnight with 'or'", () => {
expect(describeTimeCondition("22:00:00", "06:00:00")).toBe(
"If the time is after 22:00 or before 06:00"
);
});
it("omits a 'before' boundary of midnight, which ends the window at the end of the day", () => {
expect(describeTimeCondition("10:00:00", "00:00:00")).toBe(
"If the time is after 10:00"
);
});
it("compares times numerically, not lexicographically", () => {
expect(describeTimeCondition("9:00:00", "10:00:00")).toBe(
"If the time is after 09:00 and before 10:00"
);
});
it("does not compare entity references", () => {
expect(describeTimeCondition("input_datetime.wake_up", "10:00:00")).toBe(
"If the time is after entity input_datetime.wake_up and before 10:00"
);
});
});
+2 -2
View File
@@ -77,8 +77,8 @@ export const moreInfoViewElements: ViewElementSmokeCase<MoreInfoView>[] = [
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes grouped lists.
content: [{ selector: "ha-grouped-list" }],
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
},
];
@@ -1,319 +0,0 @@
import { describe, expect, it } from "vitest";
import type { LogbookEntry } from "../../../src/data/logbook";
import type { LogbookFetcher } from "../../../src/panels/logbook/logbook-chain-resolver";
import { resolveLogbookChain } from "../../../src/panels/logbook/logbook-chain-resolver";
import type { HomeAssistant } from "../../../src/types";
const hass = {
language: "en",
translationMetadata: { translations: {} },
states: {},
entities: {},
devices: {},
areas: {},
floors: {},
localize: () => "",
} as unknown as HomeAssistant;
const USERS = { user_1: "Alice" };
const entry = (partial: Partial<LogbookEntry>): LogbookEntry => ({
when: 0,
name: "",
...partial,
});
const runRow = (when: number, contextId: string): LogbookEntry =>
entry({
when,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
source: "state of binary_sensor.motion",
context_id: contextId,
});
const effectRow = (when: number): LogbookEntry =>
entry({
when,
name: "Ceiling light",
entity_id: "light.ceiling",
state: "on",
context_event_type: "automation_triggered",
context_name: "Wake up",
context_entity_id: "automation.wake_up",
context_source: "state of binary_sensor.motion",
});
interface FetchCall {
entityIds?: string[];
contextId?: string;
}
const makeFetcher = (
handler: (entityIds?: string[], contextId?: string) => LogbookEntry[]
): { fetch: LogbookFetcher; calls: FetchCall[] } => {
const calls: FetchCall[] = [];
return {
calls,
fetch: async (_start, _end, entityIds, contextId) => {
calls.push({ entityIds, contextId });
return handler(entityIds, contextId);
},
};
};
describe("resolveLogbookChain", () => {
it("fetches by context id when the entry has one", async () => {
const run = runRow(10, "ctx_run");
const effect = { ...effectRow(11), context_id: "ctx_run" };
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
if (contextId === "ctx_run") {
return [run, effect];
}
if (entityIds?.includes("binary_sensor.motion")) {
return [
entry({ when: 9.8, entity_id: "binary_sensor.motion", state: "on" }),
];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
// One context fetch + one trigger fetch: no candidate discovery.
expect(calls).toHaveLength(2);
expect(calls[0].contextId).toBe("ctx_run");
expect(chain.runRow).toBe(run);
expect(chain.rows).toEqual([run, effect]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("state");
expect(chain.origins[0].entityId).toBe("binary_sensor.motion");
expect(chain.syntheticRun).toBeUndefined();
expect(chain.triggerRow?.when).toBe(9.8);
});
it("resolves the run through the context entity and verifies candidates", async () => {
const effect = effectRow(11);
const otherRun = runRow(10.9, "ctx_other");
const goodRun = runRow(10, "ctx_good");
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
if (entityIds?.includes("automation.wake_up")) {
return [goodRun, otherRun];
}
if (contextId === "ctx_other") {
// The closest run does not contain the clicked entry.
return [
otherRun,
entry({ when: 11, entity_id: "light.desk", state: "on" }),
];
}
if (contextId === "ctx_good") {
return [goodRun, effect];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(calls[0].entityIds).toEqual(["automation.wake_up"]);
expect(calls[1].contextId).toBe("ctx_other");
expect(calls[2].contextId).toBe("ctx_good");
expect(chain.runRow?.context_id).toBe("ctx_good");
expect(chain.rows).toEqual([goodRun, effect]);
});
it("falls back to a synthetic run when no candidate matches", async () => {
const effect = effectRow(11);
const { fetch } = makeFetcher((entityIds) =>
entityIds?.includes("automation.wake_up")
? [runRow(10, "ctx_unrelated")]
: []
);
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(chain.runRow).toBeUndefined();
expect(chain.rows).toEqual([effect]);
expect(chain.syntheticRun?.type).toBe("automation");
expect(chain.origins).toEqual([]);
});
it("keeps a direct user action as the only origin", async () => {
const direct = entry({
when: 5,
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "light",
context_service: "turn_on",
});
const { fetch, calls } = makeFetcher(() => []);
const chain = await resolveLogbookChain(
hass,
direct,
{ userIdToName: USERS },
fetch
);
expect(calls).toHaveLength(0);
expect(chain.rows).toEqual([direct]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
expect(chain.syntheticRun).toBeUndefined();
});
it("picks the last trigger state before the run, even minutes earlier", async () => {
const run = runRow(600, "ctx_run");
const effect = { ...effectRow(601), context_id: "ctx_run" };
const { fetch } = makeFetcher((entityIds, contextId) => {
if (contextId === "ctx_run") {
return [run, effect];
}
if (entityIds?.includes("binary_sensor.motion")) {
return [
entry({ when: 100, entity_id: "binary_sensor.motion", state: "on" }),
entry({ when: 480, entity_id: "binary_sensor.motion", state: "off" }),
entry({ when: 640, entity_id: "binary_sensor.motion", state: "on" }),
];
}
return [];
});
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
expect(chain.triggerRow?.when).toBe(480);
expect(chain.triggerRow?.state).toBe("off");
});
it("prefers the clicked copy of the run row over the fetched one", async () => {
const clicked = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "automation",
context_service: "trigger",
});
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = { ...effectRow(11), context_id: "ctx_run" };
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
clicked,
{ userIdToName: USERS },
fetch
);
expect(chain.runRow).toBe(clicked);
expect(chain.rows).toEqual([clicked, effect]);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
});
it("surfaces the integration that triggered the run", async () => {
const clicked = entry({
when: 20,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_event_type: "call_service",
context_domain: "homekit",
context_service: "turn_on",
});
const fetched = entry({
when: 20,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = { ...effectRow(21), context_id: "ctx_run" };
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(hass, clicked, {}, fetch);
expect(chain.runRow).toBe(clicked);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("integration");
expect(chain.origins[0].brandDomain).toBe("homekit");
});
it("keeps the user above a run row that does not carry one", async () => {
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
});
const effect = {
...effectRow(11),
context_id: "ctx_run",
context_user_id: "user_1",
};
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
effect,
{ userIdToName: USERS },
fetch
);
expect(chain.runRow).toBe(fetched);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
expect(chain.origins[0].name).toBe("Alice");
});
it("does not stack the user twice when the run row resolves it", async () => {
const fetched = entry({
when: 10,
name: "Wake up",
entity_id: "automation.wake_up",
domain: "automation",
context_id: "ctx_run",
context_user_id: "user_1",
});
const effect = {
...effectRow(11),
context_id: "ctx_run",
context_user_id: "user_1",
};
const { fetch } = makeFetcher((_entityIds, contextId) =>
contextId === "ctx_run" ? [fetched, effect] : []
);
const chain = await resolveLogbookChain(
hass,
effect,
{ userIdToName: USERS },
fetch
);
expect(chain.origins).toHaveLength(1);
expect(chain.origins[0].type).toBe("user");
});
});
+1 -144
View File
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
import {
computeLogbookItem,
computeTraceLink,
classifyLogbookEntry,
entityDisplay,
computeLogbookCause,
computeLogbookGlyph,
isSameLogbookEntry,
} from "../../../src/panels/logbook/logbook-entry-model";
import type { LogbookEntry } from "../../../src/data/logbook";
import type { HomeAssistant } from "../../../src/types";
@@ -243,7 +241,7 @@ describe("computeLogbookCause", () => {
const cause = computeLogbookCause(
hass,
entry({ context_user_id: "person_1" }),
{ person_1: "Alice" },
{ person_1: "Paul" },
new Set(["cloud_user"])
);
expect(cause?.type).toBe("user");
@@ -405,144 +403,3 @@ describe("computeLogbookItem", () => {
expect(model.value).toEqual({ text: "Ran", type: "state" });
});
});
describe("isSameLogbookEntry", () => {
const a = entry({
when: 1.234567,
entity_id: "light.x",
state: "on",
name: "Light",
});
it("matches an identical entry", () => {
expect(isSameLogbookEntry(a, { ...a })).toBe(true);
});
it("rejects an entry differing by any field", () => {
expect(isSameLogbookEntry(a, { ...a, when: 1.234568 })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, entity_id: "light.y" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, state: "off" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, name: "Other" })).toBe(false);
expect(isSameLogbookEntry(a, { ...a, message: "turned on" })).toBe(false);
});
it("matches entity-less entries on name/message/when", () => {
const b = entry({ when: 2, name: "HACS", message: "2 updates available" });
expect(isSameLogbookEntry(b, { ...b })).toBe(true);
expect(isSameLogbookEntry(b, { ...b, message: "other" })).toBe(false);
});
});
describe("computeTraceLink", () => {
const traceContexts = {
ctx_1: { run_id: "run_9", domain: "automation", item_id: "auto_1" },
};
it("builds the trace URL for a known context", () => {
expect(computeTraceLink(traceContexts, "ctx_1")).toBe(
"/config/automation/trace/auto_1?run_id=run_9"
);
});
it("returns undefined for an unknown or missing context", () => {
expect(computeTraceLink(traceContexts, "ctx_2")).toBeUndefined();
expect(computeTraceLink(traceContexts, undefined)).toBeUndefined();
expect(computeTraceLink({}, "ctx_1")).toBeUndefined();
});
});
describe("computeLogbookItem cause", () => {
const hass = baseHass({ localize: (() => "") as HomeAssistant["localize"] });
const users = { user_1: "Alice" };
it("prefers the automation over the user who ran it", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "automation_triggered",
context_entity_id: "automation.wake_up",
context_name: "Wake up",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("automation");
expect(model.cause?.name).toBe("Wake up");
});
it("keeps the user for a direct action call", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_user_id: "user_1",
context_event_type: "call_service",
context_domain: "light",
context_service: "turn_on",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("user");
expect(model.cause?.name).toBe("Alice");
});
it("falls back to the context cause without a user", () => {
const model = computeLogbookItem(
hass,
entry({
entity_id: "light.ceiling",
state: "on",
context_event_type: "automation_triggered",
context_name: "Wake up",
}),
{ userIdToName: users }
);
expect(model.cause?.type).toBe("automation");
});
it("leaves the cause empty without any context", () => {
const model = computeLogbookItem(
hass,
entry({ entity_id: "light.ceiling", state: "on" }),
{ userIdToName: users }
);
expect(model.cause).toBeUndefined();
});
});
describe("computeLogbookItem run rows", () => {
it("uses the run label instead of the raw backend message", () => {
const hass = baseHass({
localize: ((key: string) => key) as HomeAssistant["localize"],
});
const model = computeLogbookItem(
hass,
entry({
entity_id: "automation.wake_up",
domain: "automation",
name: "Wake up",
message: "triggered",
}),
{}
);
expect(model.value).toEqual({
text: "ui.components.logbook.automation_triggered",
type: "state",
});
});
it("keeps the custom message of a domain-only entry (logbook.log)", () => {
const hass = baseHass({
localize: ((key: string) => key) as HomeAssistant["localize"],
});
const model = computeLogbookItem(
hass,
entry({ domain: "script", name: "Backup", message: "Backup finished" }),
{}
);
expect(model.value).toEqual({ text: "Backup finished", type: "message" });
});
});
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import "../../../../src/panels/lovelace/views/hui-view-background";
import type { HUIViewBackground } from "../../../../src/panels/lovelace/views/hui-view-background";
import type { LovelaceViewBackgroundConfig } from "../../../../src/data/lovelace/config/view";
import type { HomeAssistant } from "../../../../src/types";
const IMAGE_A = "media-source://image_upload/a";
const IMAGE_B = "media-source://image_upload/b";
// Resolutions are answered by hand so a slow one can land after a later,
// already-resolved one — the ordering a cache hit makes easy to hit.
const deferredHass = () => {
const pending = new Map<string, (url: string) => void>();
const hass = {
callWS: vi.fn(
({ media_content_id }: { media_content_id: string }) =>
new Promise((resolve) => {
pending.set(media_content_id, (url: string) =>
resolve({ url, mime_type: "image/jpeg" })
);
})
),
hassUrl: (path?: string) => path ?? "",
} as unknown as HomeAssistant;
return { hass, pending };
};
let elements: HUIViewBackground[] = [];
const mount = async (
hass: HomeAssistant,
background: string | LovelaceViewBackgroundConfig
) => {
const el = document.createElement("hui-view-background") as HUIViewBackground;
el.hass = hass;
el.background = background;
document.body.appendChild(el);
elements.push(el);
await el.updateComplete;
return el;
};
const setBackground = async (
el: HUIViewBackground,
background: string | LovelaceViewBackgroundConfig
) => {
el.background = background;
await el.updateComplete;
};
// Let the resolve chain drain before checking what was applied
const settle = async (el: HUIViewBackground) => {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
await el.updateComplete;
};
const imageBackground = (
mediaContentId: string
): LovelaceViewBackgroundConfig => ({
image: { media_content_id: mediaContentId },
});
const backgroundUrl = (el: HUIViewBackground) =>
el.style.getPropertyValue("--view-background");
afterEach(() => {
elements.forEach((el) => el.remove());
elements = [];
vi.restoreAllMocks();
});
describe("hui-view-background", () => {
it("applies the resolved url of a media source background", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
expect(backgroundUrl(el)).toContain("/a.jpg?authSig=a");
});
it("ignores a resolution that arrives after the background changed", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
await setBackground(el, imageBackground(IMAGE_B));
// B resolves first, then the stale A resolution lands
pending.get(IMAGE_B)!("/b.jpg?authSig=b");
await settle(el);
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
expect(backgroundUrl(el)).toContain("/b.jpg?authSig=b");
expect(backgroundUrl(el)).not.toContain("/a.jpg");
});
it("keeps a plain css background untouched", async () => {
const { hass } = deferredHass();
const el = await mount(hass, "#3f51b5");
expect(hass.callWS).not.toHaveBeenCalled();
expect(backgroundUrl(el)).toBe("#3f51b5");
});
it("clears the resolved image when the background is replaced by a color", async () => {
const { hass, pending } = deferredHass();
const el = await mount(hass, imageBackground(IMAGE_A));
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
await settle(el);
await setBackground(el, "#3f51b5");
expect(backgroundUrl(el)).toBe("#3f51b5");
});
it("falls back to the theme background when resolving fails", async () => {
const hass = {
callWS: vi.fn().mockRejectedValue(new Error("unresolvable")),
hassUrl: (path?: string) => path ?? "",
} as unknown as HomeAssistant;
const el = await mount(hass, imageBackground(IMAGE_A));
await settle(el);
expect(backgroundUrl(el)).toBe("");
});
});