Compare commits

..
11 changed files with 223 additions and 700 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260729.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+18 -79
View File
@@ -10,16 +10,12 @@ interface MeanFrame {
}
interface MinMaxFrame {
// A frame can hold a gap marker before any value lands in it, so the min/max
// slots below only mean something once this is true.
hasValue: boolean;
minPoint: Point;
minX: number;
minY: number;
maxPoint: Point;
maxX: number;
maxY: number;
gapPoint: Point | undefined;
}
const SECOND = 1000;
@@ -53,25 +49,6 @@ function snapFrameSize(step: number): number {
return snapped;
}
// y is NaN for a frame seeded by a gap marker, which has no value yet.
function newFrame(
point: Point,
x: number,
y: number,
gapPoint: Point | undefined
): MinMaxFrame {
return {
hasValue: gapPoint === undefined,
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
gapPoint,
};
}
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
@@ -105,10 +82,7 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const rawY = pointData[1] as number | null;
// Number(null) is 0, which would drag the mean towards zero
if (rawY === null) continue;
const y = Number(rawY);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
@@ -146,34 +120,21 @@ export function downSampleLineData<
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
if (isNaN(x)) continue;
const rawY = pointData[1] as number | null;
if (rawY === null) {
// The chart data modules push a null value to break the line where an
// entity was unavailable. Number(null) is 0, so such a marker must stay
// out of the comparisons below, where it would win the minimum slot
// whenever the readings are positive and discard the frame's real
// minimum. One marker per frame is enough to break the line, and keeping
// them all would blow up the output on series that are mostly null. The
// last one wins: where the break lands only depends on which points it
// sits between, not on its own x.
const gapIndex = Math.floor(x / step);
const gapFrame = frames.get(gapIndex);
if (gapFrame) {
gapFrame.gapPoint = point;
} else {
frames.set(gapIndex, newFrame(point, x, NaN, point));
}
continue;
}
const y = Number(rawY);
if (isNaN(y)) continue;
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor(x / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, newFrame(point, x, y, undefined));
} else if (frame.hasValue) {
frames.set(frameIndex, {
minPoint: point,
minX: x,
minY: y,
maxPoint: point,
maxX: x,
maxY: y,
});
} else {
// Match the original strict-less / strict-greater comparisons so the
// first occurrence wins on ties.
if (y < frame.minY) {
@@ -186,40 +147,18 @@ export function downSampleLineData<
frame.maxX = x;
frame.maxY = y;
}
} else {
// the frame held nothing but a marker so far
frame.hasValue = true;
frame.minPoint = point;
frame.minX = x;
frame.minY = y;
frame.maxPoint = point;
frame.maxX = x;
frame.maxY = y;
}
}
const result: T[] = [];
for (const frame of frames.values()) {
if (frame.hasValue) {
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
// The order of the data must be preserved so max may be before min
if (frame.minX > frame.maxX) {
result.push(frame.maxPoint as T);
}
if (frame.gapPoint !== undefined) {
// A marker followed by a value in its own frame is a gap that closed
// within one frame, which is about one device pixel: too narrow to show.
// The kept points are exactly min and max, so comparing against the
// later of the two catches that without any work on the ingest path. A
// marker-only frame compares against its own x and always passes.
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
result.push(frame.gapPoint as T);
}
result.push(frame.minPoint as T);
if (frame.minX < frame.maxX) {
result.push(frame.maxPoint as T);
}
}
+26 -6
View File
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
@@ -12,6 +13,7 @@ import {
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
@@ -115,6 +117,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@query("wa-popover") private _popover?: WaPopover;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@@ -125,6 +129,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@state() private _popoverWidth = 0;
@state() private _popoverMinHeight = 0;
@state() private _openedNarrow = false;
@state() private _unknownValue = false;
@@ -232,9 +238,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
style=${styleMap({
"--body-width": `${this._popoverWidth}px`,
"--body-min-height": `${this._popoverMinHeight}px`,
})}
without-arrow
distance="-4"
distance="0"
.placement=${this.popoverPlacement}
.for=${this.popoverAnchor ? null : "picker"}
.anchor=${this.popoverAnchor ?? null}
@@ -257,12 +266,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
private _renderComboBox(dialogMode = false) {
if (!this._opened) {
// The list sizes the popover, so it is rendered as it opens, not once shown.
if (!this._pickerWrapperOpen && !this._opened) {
return nothing;
}
return html`
<ha-picker-combo-box
id="combo-box"
.shown=${this._opened}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel}
.value=${this._selectedValue ?? this.value}
@@ -330,6 +341,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _dialogOpened = () => {
this._opened = true;
// Filtering must not shrink the popover under the size it opened with.
this._popoverMinHeight = this._popover?.body.offsetHeight ?? 0;
fireEvent(this, "picker-opened");
requestAnimationFrame(() => {
// Set initial field value if needed
@@ -363,6 +376,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._opened = false;
this._pickerWrapperOpen = false;
this._popoverMinHeight = 0;
this._selectedValue = undefined;
this._unsubscribeTinyKeys?.();
fireEvent(this, "picker-closed");
@@ -465,6 +479,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
wa-popover {
--wa-space-l: 0;
/* The surface of a dropdown menu, not of a dialog. */
--wa-panel-border-radius: var(--ha-border-radius-md);
}
wa-popover::part(dialog)::backdrop {
@@ -477,14 +493,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
--ha-generic-picker-max-width,
var(--ha-generic-picker-width, max(var(--body-width), 250px))
);
max-height: 500px;
height: 70vh;
display: flex;
flex-direction: column;
box-shadow: var(--ha-box-shadow-m);
height: fit-content;
min-height: var(--body-min-height, 0);
max-height: min(70vh, 500px);
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
max-height: min(70vh, 400px);
}
}
+146 -68
View File
@@ -13,6 +13,7 @@ import {
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { repeat } from "lit/directives/repeat";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
@@ -74,6 +75,10 @@ type PickerComboBoxRowElement = HTMLDivElement & {
value: string;
};
// Under this count the list is rendered without the virtualizer, so it can size the
// popover to its content instead of filling a fixed height.
const MAX_PLAIN_LIST_ITEMS = 12;
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
@@ -153,6 +158,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
/** Set once the surface holding the list is done animating in. */
@property({ type: Boolean }) public shown = false;
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
@@ -178,6 +186,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query(".plain-list") private _plainListElement?: HTMLElement;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@state()
@@ -186,6 +196,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: PickerComboBoxItem[] = [];
@state() private _plainList = false;
@state() private _selectedSection?: string;
public setFieldValue(value: string) {
@@ -195,7 +207,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
protected get scrollableElement(): HTMLElement | null {
return this.virtualizerElement as HTMLElement | null;
return this._listElement ?? null;
}
private get _listElement(): HTMLElement | undefined {
return this._plainList ? this._plainListElement : this.virtualizerElement;
}
@state() private _sectionTitle?: string;
@@ -221,10 +237,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
this._selectedSection = this.selectedSection;
this._allItems = this._getItems();
this._items = this._allItems;
this._updateListMode();
}
}
@@ -238,6 +254,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (!this._search || this.sections?.length) {
this._items = this._allItems;
}
this._updateListMode();
}
// Filtering keeps the mode it opened with, only the full list decides it.
private _updateListMode() {
this._plainList =
!this.sections?.length && this._allItems.length <= MAX_PLAIN_LIST_ITEMS;
if (!this._plainList) {
loadVirtualizer();
}
}
protected render() {
@@ -271,36 +297,61 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
`
: nothing
}
<div class="virtualizer-wrapper">
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${
this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined
}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
<div class="list-wrapper ${this._plainList ? "" : "virtualized"}">
${this._plainList ? this._renderPlainList() : this._renderVirtualList()}
${this.renderScrollableFades()}
</div>`;
}
private _renderPlainList() {
return html`
<div
class="plain-list ${this._listScrolled ? "scrolled" : ""}"
tabindex="0"
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
>
${repeat(this._items, this._keyFunction, this._renderItem)}
</div>
`;
}
private _renderVirtualList() {
// The virtualizer measures its rows, so it must not do it through the scale
// the surface animates in with.
if (!this.shown) {
return nothing;
}
return html`
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${
this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined
}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
`;
}
private _renderSectionButtons() {
if (!this.sections || this.sections.length === 0) {
return nothing;
@@ -564,7 +615,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._items = this._getItems();
// Reset scroll position when filter changes
this.virtualizerElement?.element(0)?.scrollIntoView();
this._resetListScroll();
}
private _registerKeyboardShortcuts() {
@@ -578,6 +629,26 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
private _resetListScroll() {
if (this._plainList) {
this._listElement?.scrollTo({ top: 0 });
return;
}
this.virtualizerElement?.element(0)?.scrollIntoView();
}
private _scrollRowIntoView(index: number) {
if (this._plainList) {
this._listElement
?.querySelector(`#list-item-${index}`)
?.scrollIntoView({ block: "nearest" });
return;
}
this.virtualizerElement?.element(index)?.scrollIntoView({
block: "nearest",
});
}
private _focusList() {
if (this._selectedItemIndex === -1) {
this._initializeSelectedIndex();
@@ -589,7 +660,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
* or fall back to the first item when searching (skipping section titles).
*/
private _initializeSelectedIndex(): void {
if (!this.virtualizerElement?.items?.length) {
if (!this._items.length) {
return;
}
const initialIndex = this._getInitialSelectedIndex();
@@ -599,11 +670,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
let index = initialIndex;
// Skip section titles (strings)
if (typeof this.virtualizerElement.items[index] === "string") {
if (typeof this._items[index] === "string") {
index += 1;
}
// Bounds check: ensure index is valid after skipping section title
if (index >= this.virtualizerElement.items.length) {
if (index >= this._items.length) {
return;
}
this._selectedItemIndex = index;
@@ -613,13 +684,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this.virtualizerElement) {
if (!this._listElement) {
return;
}
this._searchFieldElement?.focus();
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const items = this._items;
const maxItems = items.length - 1;
@@ -661,14 +732,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this.virtualizerElement) {
if (!this._listElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const items = this._items;
if (!items[nextIndex]) {
return;
@@ -690,13 +761,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
if (!this._listElement || !this._items.length) {
return;
}
const nextIndex = 0;
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
if (typeof this._items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -707,13 +778,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
if (!this._listElement || !this._items.length) {
return;
}
const nextIndex = this.virtualizerElement.items.length - 1;
const nextIndex = this._items.length - 1;
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
if (typeof this._items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -723,16 +794,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _scrollToSelectedItem = () => {
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this.virtualizerElement
?.element(this._selectedItemIndex)
?.scrollIntoView({ block: "nearest" });
this._scrollRowIntoView(this._selectedItemIndex);
requestAnimationFrame(() => {
this.virtualizerElement
this._listElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
@@ -749,14 +816,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
ev.stopPropagation();
if (
this.virtualizerElement?.items?.length !== undefined &&
this.virtualizerElement.items.length < 4 && // it still can have a section title and a padding item
this.virtualizerElement.items.filter((item) => typeof item !== "string")
.length === 1
this._items.length < 4 && // it still can have a section title and a padding item
this._items.filter((item) => typeof item !== "string").length === 1
) {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
this._items.forEach((item, index) => {
if (typeof item !== "string" && !item.disabled) {
this._fireSelectedEvents(item.id, index, newTab);
}
@@ -774,18 +837,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
// if filter button is focused
ev.preventDefault();
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
const item = this._items[this._selectedItemIndex];
if (item && !item.disabled) {
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
}
};
private _resetSelectedItem() {
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._listElement?.querySelector(".selected")?.classList.remove("selected");
this._selectedItemIndex = -1;
}
@@ -793,11 +852,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() {
if (!this.virtualizerElement || this._search || !this.value) {
if (this._search || !this.value) {
return 0;
}
const index = this.virtualizerElement.items.findIndex(
const index = this._items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
@@ -820,6 +879,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
flex-direction: column;
padding-top: var(--ha-space-4);
flex: 1;
min-height: 0;
}
:host([clearable]) {
@@ -851,23 +911,41 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
.virtualizer-wrapper {
.list-wrapper {
position: relative;
flex: 1;
flex: 0 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
lit-virtualizer {
/* The virtualizer is size contained, so it fills a height rather than
providing one. Asking for the whole viewport leaves the popover to cap it. */
.list-wrapper.virtualized {
flex: 1 1 100vh;
}
/* A sheet has its own height, so the list fills it instead of sizing it. */
:host([mode="dialog"]) .list-wrapper {
flex: 1;
}
lit-virtualizer:focus-visible {
lit-virtualizer,
.plain-list {
flex: 1;
min-height: 0;
}
.plain-list {
overflow: auto;
}
lit-virtualizer:focus-visible,
.plain-list:focus-visible {
outline: none;
}
lit-virtualizer.scrolled {
.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
+1 -19
View File
@@ -1,4 +1,3 @@
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
@@ -8,7 +7,7 @@ export interface ResolvedMediaSource {
}
export const resolveMediaSource = (
hass: Pick<HomeAssistant, "callWS">,
hass: HomeAssistant,
media_content_id: string
) =>
hass.callWS<ResolvedMediaSource>({
@@ -16,23 +15,6 @@ 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
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
import {
isMediaSourceContentId,
resolveMediaSourceWithCache,
resolveMediaSource,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
@@ -142,35 +142,21 @@ export class HuiViewBackgroundEditor extends LitElement {
`${(background.opacity ?? 100) / 100}`
);
const backgroundImage = this._currentBackgroundImage();
const backgroundImage =
typeof background.image === "object"
? background.image.media_content_id
: background.image;
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
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;
}
}
);
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this._resolvedImage = result.url;
});
} 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,
resolveMediaSourceWithCache,
resolveMediaSource,
} from "../../../data/media_source";
@customElement("hui-view-background")
@@ -21,37 +21,20 @@ export class HUIViewBackground extends LitElement {
return nothing;
}
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;
}
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 async _fetchMedia() {
const backgroundImage = this._getBackgroundImage(this.background);
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
resolveMediaSource(this.hass, backgroundImage).then((result) => {
this.resolvedImage = result.url;
});
} else {
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;
}
}
@@ -90,7 +73,10 @@ export class HUIViewBackground extends LitElement {
background?: string | LovelaceViewBackgroundConfig
) {
if (typeof background === "object" && background.image) {
const image = this._getBackgroundImage(background) || "";
const image =
typeof background.image === "object"
? background.image.media_content_id || ""
: background.image;
if (isMediaSourceContentId(image) && !this.resolvedImage) {
return null;
}
-30
View File
@@ -17,24 +17,10 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
return points;
};
// The chart data modules break the line with a null value. A handful of them
// stands for an entity that went unavailable; a series that is mostly null
// stands for the climate heating dataset, which emits one per inactive state.
const withGaps = (
points: [number, number][],
isGap: (index: number) => boolean
): [number, number | null][] =>
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
const small = generatePoints(1, SCALES.small);
const medium = generatePoints(2, SCALES.medium);
const large = generatePoints(3, SCALES.large);
const largeObjects = large.map((value) => ({ value }));
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
const largeMostlyGaps = withGaps(
large,
(index) => Math.floor(index / 50) % 3 !== 0
);
describe("downSampleLineData", () => {
bench("min/max small (1k points)", () => {
@@ -68,20 +54,4 @@ describe("downSampleLineData", () => {
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large with a few gaps (100k points)",
() => {
downSampleLineData(largeFewGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
bench(
"min/max large mostly gaps (100k points)",
() => {
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
},
{ time: 1000, warmupIterations: 2 }
);
});
+6 -204
View File
@@ -19,52 +19,9 @@ const generatePoints = (
return points;
};
// Gap markers: the chart data modules push a null value to break the line
// where an entity was unavailable.
type GappedPoint = [number, number | null | undefined];
const toObjectPoints = (points: GappedPoint[]) =>
const toObjectPoints = (points: [number, number][]) =>
points.map((value) => ({ value }));
const expectXOrdered = (result: { [0]: number }[]) => {
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
};
// A series whose readings are all positive, with an unavailable stretch that
// starts inside the first frame. Mirrors the point sequence
// state-history-chart-line-data.ts emits for a gap.
const gappedPoints: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90], // frame maximum
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 3_000, 10], // frame minimum
[FIXED_EPOCH_MS + 4_000, 20], // last reading before the gap
[FIXED_EPOCH_MS + 4_001, null], // gap marker
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
// A generated series with three unavailable stretches of different lengths.
const generateGappedPoints = (seed: number, count: number) => {
const points: GappedPoint[] = generatePoints(seed, count);
for (const [start, length] of [
[Math.floor(count * 0.13), 3],
[Math.floor(count * 0.4), 25],
[Math.floor(count * 0.83), 1],
]) {
const gapStart = points[start][0];
points.splice(
start + 1,
length,
[gapStart + 1, points[start][1]],
[gapStart + 1, null]
);
}
return points;
};
describe("downSampleLineData", () => {
it("returns empty array for undefined data", () => {
expect(downSampleLineData(undefined, 100)).toEqual([]);
@@ -109,7 +66,11 @@ describe("downSampleLineData", () => {
});
it("min/max mode preserves x-order for sorted input", () => {
expectXOrdered(downSampleLineData(generatePoints(4, 1000), 50));
const points = generatePoints(4, 1000);
const result = downSampleLineData(points, 50);
for (let i = 1; i < result.length; i++) {
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
}
});
it("min/max mode matches characterization snapshot", () => {
@@ -232,165 +193,6 @@ describe("downSampleLineData", () => {
).toMatchSnapshot();
});
it("keeps the frame minimum when a gap marker shares the frame", () => {
// Without special handling the marker becomes y=0, wins the minimum slot
// and the real minimum (10) is dropped.
expect(downSampleLineData(gappedPoints, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 3_000, 10],
[FIXED_EPOCH_MS + 4_001, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("drops a marker whose gap closes within the same frame", () => {
// A frame spans about one device pixel, so a gap that opens and closes
// inside one is too narrow to show. The values around it stay.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 10], // frame minimum, before the marker
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 90], // frame maximum, after the marker
[FIXED_EPOCH_MS + 3_000, 60],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 10],
[FIXED_EPOCH_MS + 2_000, 90],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a marker sharing its x with a value after that value", () => {
// statistics-chart-data.ts ends the line and breaks it at the same x
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 2_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a gap marker whose frame holds no values", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 60],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 15_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("keeps a single gap marker per frame", () => {
// A run of nulls inside one frame renders the same as a single null: the
// break only depends on which points the marker sits between.
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 50],
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 3_000, null],
[FIXED_EPOCH_MS + 4_000, null],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
];
expect(downSampleLineData(points, 3)).toEqual([
[FIXED_EPOCH_MS + 1_000, 90],
[FIXED_EPOCH_MS + 2_000, 10],
[FIXED_EPOCH_MS + 5_000, null],
[FIXED_EPOCH_MS + 30_000, 55],
[FIXED_EPOCH_MS + 31_000, 45],
]);
});
it("bounds the output on a series where most points are null", () => {
// The climate heating/cooling datasets push a null for every state where
// the mode is inactive, so markers must not escape the frame budget.
const values = generatePoints(20, SCALES.medium);
const random = createSeededRandom(21);
const gapped: GappedPoint[] = values.map(([x, y]) =>
random() < 0.65 ? [x, null] : [x, y]
);
const gapless = downSampleLineData(values, 500);
const result = downSampleLineData(gapped, 500);
// Both series share an x grid, so they share frames, and the gapless one
// emits at least one point per frame. A gapped frame emits at most three:
// min, max and a single marker.
expect(result.length).toBeLessThanOrEqual(3 * gapless.length);
expect(
result.filter((point) => point[1] === null).length
).toBeLessThanOrEqual(gapless.length);
});
it("handles gap markers on object-shaped points", () => {
const points = toObjectPoints(gappedPoints);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("handles gap markers on Date x values", () => {
// statistics charts use Date objects for x
const points = gappedPoints.map(
([x, y]) => [new Date(x), y] as [Date, number | null | undefined]
);
expect(downSampleLineData(points, 3)).toEqual([
points[1],
points[3],
points[5],
points[6],
points[7],
]);
});
it("mean mode leaves gap markers out of the average", () => {
const points: GappedPoint[] = [
[FIXED_EPOCH_MS, 10],
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 1_001, null],
[FIXED_EPOCH_MS + 2_000, 30],
[FIXED_EPOCH_MS + 30_000, 100],
[FIXED_EPOCH_MS + 31_000, 100],
];
expect(downSampleLineData(points, 3, undefined, undefined, true)).toEqual([
// (10 + 20 + 30) / 3, not (10 + 20 + 0 + 30) / 4
[FIXED_EPOCH_MS + 1_000, 20],
[FIXED_EPOCH_MS + 30_500, 100],
]);
});
it("min/max mode preserves x-order for gapped input", () => {
const result = downSampleLineData(generateGappedPoints(22, 1000), 50);
// Of the three gaps only the 25 point one outlasts its frame; the one and
// three point gaps close within theirs and are dropped.
expect(result.filter((point) => point[1] === null)).toHaveLength(1);
expectXOrdered(result);
});
it("large scale mean-mode digest is stable", () => {
expect(
digestResult(
-111
View File
@@ -1,111 +0,0 @@
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);
});
});
@@ -1,129 +0,0 @@
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("");
});
});