Compare commits

...

4 Commits

Author SHA1 Message Date
Bram Kragten 0f81311c76 Fix camera/image proxy URLs sent with token=undefined (#52514) 2026-06-09 15:34:49 +01:00
Aidan Timson 8a85d1cf31 Use typed query param handling in todo and refactor handler typing (#52505)
* Use typed query param handlers for todo

* Refactor to query param config obj

* Remove type casts

* Use main window

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Fix import

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-09 11:40:45 +00:00
Przemysław Szypowicz 9ba34bdf9a Add type and integration context to scene editor entity rows (#52494)
* Show entity area and device in scene editor entity lists

Entities already added to a scene only displayed their friendly name, so
several similarly named entities (e.g. multiple lights named LED) were
indistinguishable. Add a secondary line with the entity's area and device,
reusing computeEntityPickerDisplay so it matches the add-entity picker.
Applies to both the device-grouped and standalone entity lists.

* Add type and integration context to scene editor entity rows

Mirror the pickers used to add scene members: device-grouped entities show
their integration (e.g. Matter), like the device picker, while standalone
entities show their domain (e.g. Light), like the entity picker. Combined with
the area and device on the second line, this keeps entities distinguishable
once they are part of the scene.

---------

Co-authored-by: Przemysław Szypowicz <2733699+pszypowicz@users.noreply.github.com>
2026-06-09 14:36:34 +03:00
Jan-Philipp Benecke f0f28789de Fix scrolling behavior for auto-height data table (#52508) 2026-06-09 14:21:40 +03:00
12 changed files with 305 additions and 89 deletions
+6 -21
View File
@@ -16,32 +16,17 @@ export type HistoryLogbookTargetParamKey =
| "area_id"
| "device_id";
export type HistoryLogbookDateParamKey = "start_date" | "end_date";
export type HistoryLogbookBooleanParamKey = "back";
export type HistoryLogbookQueryParams = QueryParamValues<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
>;
export const historyLogbookTargetParamKeys: HistoryLogbookTargetParamKey[] = [
"entity_id",
"label_id",
"floor_id",
"area_id",
"device_id",
];
export const historyLogbookTargetParamKeys: readonly HistoryLogbookTargetParamKey[] =
["entity_id", "label_id", "floor_id", "area_id", "device_id"];
export const historyLogbookQueryParamConfig = {
list: historyLogbookTargetParamKeys,
date: ["start_date", "end_date"],
boolean: [{ key: "back", trueValue: "1" }],
} satisfies QueryParamConfig<
HistoryLogbookTargetParamKey,
HistoryLogbookDateParamKey,
HistoryLogbookBooleanParamKey
} as const satisfies QueryParamConfig;
export type HistoryLogbookQueryParams = QueryParamValues<
typeof historyLogbookQueryParamConfig
>;
export const decodeHistoryLogbookQueryParams = (
+73 -41
View File
@@ -6,29 +6,49 @@ export type SearchParamsSource =
| Record<string, string>
| string;
export interface QueryParamConfig<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> {
list?: readonly ListKey[];
date?: readonly DateKey[];
export interface QueryParamConfig {
list?: readonly string[];
date?: readonly string[];
boolean?: readonly {
key: BooleanKey;
key: string;
trueValue: string;
}[];
string?: readonly string[];
}
export type QueryParamValues<
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
> = Partial<
Record<ListKey, string[]> &
Record<DateKey, Date> &
Record<BooleanKey, boolean>
type ListKeyOf<C extends QueryParamConfig> = C extends {
list: readonly (infer K extends string)[];
}
? K
: never;
type DateKeyOf<C extends QueryParamConfig> = C extends {
date: readonly (infer K extends string)[];
}
? K
: never;
type BooleanKeyOf<C extends QueryParamConfig> = C extends {
boolean: readonly { key: infer K extends string }[];
}
? K
: never;
type StringKeyOf<C extends QueryParamConfig> = C extends {
string: readonly (infer K extends string)[];
}
? K
: never;
export type QueryParamValues<C extends QueryParamConfig> = Partial<
Record<ListKeyOf<C>, string[]> &
Record<DateKeyOf<C>, Date> &
Record<BooleanKeyOf<C>, boolean> &
Record<StringKeyOf<C>, string>
>;
type QueryParamValue = string[] | Date | boolean | string;
export type ServiceTargetQueryParams<
Key extends keyof HassServiceTarget & string,
> = Partial<Record<Key, string[]>>;
@@ -46,53 +66,59 @@ const getSearchParam = (
return searchParams[key] ?? null;
};
export const decodeQueryParams = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
export function decodeQueryParams<C extends QueryParamConfig>(
searchParams: SearchParamsSource,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): QueryParamValues<ListKey, DateKey, BooleanKey> => {
const params: QueryParamValues<ListKey, DateKey, BooleanKey> = {};
config: C
): QueryParamValues<C>;
export function decodeQueryParams(
searchParams: SearchParamsSource,
config: QueryParamConfig
): Record<string, QueryParamValue | undefined> {
const params: Record<string, QueryParamValue> = {};
for (const key of config.list ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value.split(",") as (typeof params)[typeof key];
params[key] = value.split(",");
}
}
for (const key of config.date ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = new Date(value) as (typeof params)[typeof key];
params[key] = new Date(value);
}
}
for (const { key, trueValue } of config.boolean ?? []) {
if (getSearchParam(searchParams, key) === trueValue) {
params[key] = true as (typeof params)[typeof key];
params[key] = true;
}
}
for (const key of config.string ?? []) {
const value = getSearchParam(searchParams, key);
if (value) {
params[key] = value;
}
}
return params;
};
}
export const createQueryString = <
ListKey extends string,
DateKey extends string,
BooleanKey extends string,
>(
values: QueryParamValues<ListKey, DateKey, BooleanKey>,
config: QueryParamConfig<ListKey, DateKey, BooleanKey>
): string => {
export function createQueryString<C extends QueryParamConfig>(
values: QueryParamValues<NoInfer<C>>,
config: C
): string;
export function createQueryString(
values: Record<string, QueryParamValue | undefined>,
config: QueryParamConfig
): string {
const searchParams = new URLSearchParams();
for (const key of config.list ?? []) {
const value = values[key] as string[] | undefined;
if (value?.length) {
const value = values[key];
if (Array.isArray(value) && value.length) {
searchParams.append(key, value.join(","));
}
}
for (const key of config.date ?? []) {
const value = values[key] as Date | undefined;
if (value) {
const value = values[key];
if (value instanceof Date) {
searchParams.append(key, value.toISOString());
}
}
@@ -101,8 +127,14 @@ export const createQueryString = <
searchParams.append(key, trueValue);
}
}
for (const key of config.string ?? []) {
const value = values[key];
if (typeof value === "string" && value) {
searchParams.append(key, value);
}
}
return searchParams.toString();
};
}
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
+21
View File
@@ -0,0 +1,21 @@
import {
createQueryString,
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export const todoQueryParamConfig = {
string: ["entity_id"],
boolean: [{ key: "add_item", trueValue: "true" }],
} as const satisfies QueryParamConfig;
export type TodoQueryParams = QueryParamValues<typeof todoQueryParamConfig>;
export const decodeTodoQueryParams = (
searchParams: SearchParamsSource
): TodoQueryParams => decodeQueryParams(searchParams, todoQueryParamConfig);
export const createTodoQueryString = (values: TodoQueryParams): string =>
createQueryString(values, todoQueryParamConfig);
@@ -1477,6 +1477,11 @@ export class HaDataTable extends LitElement {
.mdc-data-table__table.auto-height .scroller {
overflow-y: hidden !important;
}
.mdc-data-table__table.auto-height lit-virtualizer {
overscroll-behavior-y: auto;
}
.grows {
flex-grow: 1;
flex-shrink: 1;
+9 -5
View File
@@ -112,12 +112,16 @@ export class HaCameraStream extends LitElement {
return nothing;
}
if (stream.type === MJPEG_STREAM) {
const streamUrl = __DEMO__
? this.stateObj.attributes.entity_picture
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl;
if (!streamUrl) {
return nothing;
}
return html`<img
.src=${__DEMO__
? this.stateObj.attributes.entity_picture!
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl || ""}
.src=${streamUrl}
style=${styleMap({
aspectRatio: this.aspectRatio,
objectFit: this.fitMode,
+7 -3
View File
@@ -17,7 +17,7 @@ export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
interface CameraEntityAttributes extends HassEntityAttributeBase {
model_name: string;
access_token: string;
access_token?: string;
brand: string;
motion_detection: boolean;
frontend_stream_type: string;
@@ -78,8 +78,12 @@ export const cameraUrlWithWidthHeight = (
height: number
) => `${base_url}&width=${width}&height=${height}`;
export const computeMJPEGStreamUrl = (entity: CameraEntity) =>
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
export const computeMJPEGStreamUrl = (
entity: CameraEntity
): string | undefined =>
entity.attributes.access_token
? `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`
: undefined;
export const fetchThumbnailUrlWithCache = async (
hass: HomeAssistant,
+5 -3
View File
@@ -4,12 +4,14 @@ import type {
} from "home-assistant-js-websocket";
interface ImageEntityAttributes extends HassEntityAttributeBase {
access_token: string;
access_token?: string;
}
export interface ImageEntity extends HassEntityBase {
attributes: ImageEntityAttributes;
}
export const computeImageUrl = (entity: ImageEntity): string =>
`/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`;
export const computeImageUrl = (entity: ImageEntity): string | undefined =>
entity.attributes.access_token
? `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`
: undefined;
@@ -106,7 +106,9 @@ class EntityPreviewRow extends LitElement {
}
`;
private _renderEntityState(stateObj: HassEntity): TemplateResult | string {
private _renderEntityState(
stateObj: HassEntity
): TemplateResult | string | typeof nothing {
const domain = stateObj.entity_id.split(".", 1)[0];
const disabled = stateObj.state === UNAVAILABLE;
const noValue =
@@ -216,7 +218,10 @@ class EntityPreviewRow extends LitElement {
}
if (domain === "image") {
const image: string = computeImageUrl(stateObj as ImageEntity);
const image = computeImageUrl(stateObj as ImageEntity);
if (!image) {
return nothing;
}
return html`
<img
alt=${ifDefined(stateObj?.attributes.friendly_name)}
@@ -15,9 +15,13 @@ class MoreInfoImage extends LitElement {
if (!this.hass || !this.stateObj) {
return nothing;
}
const imageUrl = computeImageUrl(this.stateObj);
if (!imageUrl) {
return nothing;
}
return html`<img
alt=${this.stateObj.attributes.friendly_name || this.stateObj.entity_id}
src=${this.hass.hassUrl(computeImageUrl(this.stateObj))}
src=${this.hass.hassUrl(imageUrl)}
/> `;
}
+53 -1
View File
@@ -25,6 +25,7 @@ import { transform } from "../../../common/decorators/transform";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack, navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
@@ -47,7 +48,11 @@ import {
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
entityRegistryByEntityId,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { domainToName } from "../../../data/integration";
import type {
SceneConfig,
SceneEntities,
@@ -344,6 +349,9 @@ export class HaSceneEditor extends PreventUnsavedMixin(
this._deviceEntityLookup,
Object.values(this.hass.devices)
);
const entityRegistryLookup = entityRegistryByEntityId(
this._entityRegistryEntries
);
return html` <div
id="root"
class=${classMap({
@@ -423,9 +431,19 @@ export class HaSceneEditor extends PreventUnsavedMixin(
if (!entityStateObj) {
return nothing;
}
const { secondary } = computeEntityPickerDisplay(
this.hass,
entityStateObj
);
const platform =
entityRegistryLookup[entityId]?.platform;
const integrationName = platform
? domainToName(this.hass.localize, platform)
: undefined;
return html`
<ha-list-item
hasMeta
?twoline=${!!secondary}
.graphic=${this._mode === "live"
? "icon"
: undefined}
@@ -445,6 +463,14 @@ export class HaSceneEditor extends PreventUnsavedMixin(
`
: nothing}
${computeStateName(entityStateObj)}
${secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing}
${integrationName
? html`<span slot="meta" class="domain"
>${integrationName}</span
>`
: nothing}
</ha-list-item>
`;
})}
@@ -496,10 +522,19 @@ export class HaSceneEditor extends PreventUnsavedMixin(
if (!entityStateObj) {
return nothing;
}
const { secondary } = computeEntityPickerDisplay(
this.hass,
entityStateObj
);
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
return html`
<ha-list-item
class="entity"
hasMeta
?twoline=${!!secondary}
.graphic=${this._mode === "live"
? "icon"
: undefined}
@@ -517,7 +552,13 @@ export class HaSceneEditor extends PreventUnsavedMixin(
></state-badge>`
: nothing}
${computeStateName(entityStateObj)}
${secondary
? html`<span slot="secondary"
>${secondary}</span
>`
: nothing}
<div slot="meta">
<span class="domain">${domainName}</span>
<ha-icon-button
.path=${mdiDelete}
.entityId=${entityId}
@@ -1355,10 +1396,21 @@ export class HaSceneEditor extends PreventUnsavedMixin(
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
ha-list-item {
/* let the trailing label size to its content instead of the default
fixed meta width, which would clip it */
--mdc-list-item-meta-size: auto;
}
ha-list-item.entity {
padding-right: 28px;
}
.domain {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
white-space: nowrap;
}
`,
];
}
+18 -12
View File
@@ -19,11 +19,11 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { extractSearchParamsObject } from "../../common/url/search-params";
import {
createSearchParam,
extractSearchParam,
removeSearchParam,
} from "../../common/url/search-params";
createTodoQueryString,
decodeTodoQueryParams,
} from "../../common/url/todo-query-params";
import "../../components/ha-button";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
@@ -104,10 +104,11 @@ class PanelTodo extends LitElement {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
const urlEntityId = extractSearchParam("entity_id");
this._openAddItemFromUrl = extractSearchParam("add_item") === "true";
if (urlEntityId) {
this._entityId = urlEntityId;
const params = decodeTodoQueryParams(extractSearchParamsObject());
this._openAddItemFromUrl = params.add_item ?? false;
if (params.entity_id) {
this._entityId = params.entity_id;
} else {
if (this._entityId && !(this._entityId in this.hass.states)) {
this._entityId = undefined;
@@ -127,9 +128,12 @@ class PanelTodo extends LitElement {
}
this._openAddItemFromUrl = false;
navigate(constructUrlCurrentPath(removeSearchParam("add_item")), {
replace: true,
});
navigate(
constructUrlCurrentPath(
createTodoQueryString({ entity_id: this._entityId })
),
{ replace: true }
);
if (
supportsFeature(
this.hass.states[this._entityId],
@@ -146,7 +150,9 @@ class PanelTodo extends LitElement {
return;
}
navigate(
constructUrlCurrentPath(createSearchParam({ entity_id: this._entityId })),
constructUrlCurrentPath(
createTodoQueryString({ entity_id: this._entityId })
),
{ replace: true }
);
}
+96
View File
@@ -12,7 +12,12 @@ import {
decodeQueryParams,
queryParamsFromServiceTarget,
serviceTargetFromQueryParams,
type QueryParamConfig,
} from "../../../src/common/url/query-params";
import {
createTodoQueryString,
decodeTodoQueryParams,
} from "../../../src/common/url/todo-query-params";
const panelQueryParams = [
{
@@ -144,3 +149,94 @@ describe("history logbook query params", () => {
).toBeUndefined();
});
});
describe("string params", () => {
const stringConfig = {
string: ["name", "color"],
} as const satisfies QueryParamConfig;
it("decodes scalar string params", () => {
expect(decodeQueryParams("?name=hello&color=blue", stringConfig)).toEqual({
name: "hello",
color: "blue",
});
});
it("ignores empty string values", () => {
expect(decodeQueryParams("?name=&color=blue", stringConfig)).toEqual({
color: "blue",
});
});
it("ignores missing string params", () => {
expect(decodeQueryParams("?color=green", stringConfig)).toEqual({
color: "green",
});
});
it("encodes scalar string params", () => {
expect(
createQueryString({ name: "hello", color: "blue" }, stringConfig)
).toBe("name=hello&color=blue");
});
it("omits undefined string values from encoding", () => {
expect(createQueryString({ name: "hello" }, stringConfig)).toBe(
"name=hello"
);
});
});
describe("todo query params", () => {
it("decodes entity_id", () => {
expect(decodeTodoQueryParams("?entity_id=todo.shopping")).toEqual({
entity_id: "todo.shopping",
});
});
it("decodes entity_id with add_item", () => {
expect(
decodeTodoQueryParams("?entity_id=todo.shopping&add_item=true")
).toEqual({
entity_id: "todo.shopping",
add_item: true,
});
});
it("ignores add_item with non-true value", () => {
expect(
decodeTodoQueryParams("?entity_id=todo.shopping&add_item=false")
).toEqual({
entity_id: "todo.shopping",
});
});
it("returns empty for no params", () => {
expect(decodeTodoQueryParams("")).toEqual({});
});
it("creates query string with entity_id only", () => {
expect(createTodoQueryString({ entity_id: "todo.shopping" })).toBe(
"entity_id=todo.shopping"
);
});
it("creates query string with entity_id and add_item", () => {
expect(
createTodoQueryString({ entity_id: "todo.shopping", add_item: true })
).toBe("add_item=true&entity_id=todo.shopping");
});
it("omits add_item when false or undefined", () => {
expect(
createTodoQueryString({ entity_id: "todo.shopping", add_item: false })
).toBe("entity_id=todo.shopping");
});
it("round-trips decode and encode", () => {
const original = "?entity_id=todo.tasks&add_item=true";
const decoded = decodeTodoQueryParams(original);
const encoded = createTodoQueryString(decoded);
expect(encoded).toBe("add_item=true&entity_id=todo.tasks");
});
});