Compare commits

...
11 changed files with 427 additions and 37 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) {
+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 {
+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
@@ -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) {
@@ -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 -1
View File
@@ -6131,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"
);
});
});
@@ -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("");
});
});