Compare commits

..

10 Commits

Author SHA1 Message Date
Simon Lamon 90edd05c41 Fix indentation in dependabot.yml for codeql-action 2026-07-19 09:23:52 +02:00
Simon Lamon 3ef904dea7 Add CodeQL action to Dependabot groups 2026-07-19 09:18:54 +02:00
renovate[bot] 650444e92d Update dependency @lokalise/node-api to v16.2.0 (#53177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:13:40 +02:00
dependabot[bot] da9a78f752 Bump actions/stale from 10.3.0 to 10.4.0 (#53188)
Bumps [actions/stale](https://github.com/actions/stale) from 10.3.0 to 10.4.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899...1e223db275d687790206a7acac4d1a11bd6fe629)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 09:13:19 +02:00
dependabot[bot] 9077290b39 Bump actions/labeler from 6.1.0 to 6.2.0 (#53187)
Bumps [actions/labeler](https://github.com/actions/labeler) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/f27b608878404679385c85cfa523b85ccb86e213...b8dd2d9be0f68b860e7dae5dae7d772984eacd6d)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 09:12:59 +02:00
renovate[bot] e4c6ea29b3 Lock file maintenance (#53179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:12:40 +02:00
renovate[bot] aa57c27739 Update dependency @rsdoctor/rspack-plugin to v1.6.0 (#53174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 19:12:42 +02:00
Niklas Wagner 420137478e Add manufacturer, model, and model_id filtering to entity filter selector (#29636)
* Add manufacturer, model, and model_id filtering to entity selector

* Implement suggestion from review

* fix: simplify device data type

* Nest device filtering under `device` key in entity selector

* Fix race condition when fetching data

* Avoid config entries refetch loop on fetch failure

The catch handler reset _fetchedConfigEntries to false while also
assigning _configEntries, which re-rendered the component and re-armed
the fetch guard in updated() — an unbounded, no-backoff retry loop on
persistent failure. Keep the flag set so a failed fetch is not retried
on every re-render; the connection-change handler still retries on
reconnect.

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-18 09:51:15 +02:00
renovate[bot] 25bc81cf1f Update dependency @rspack/core to v2.1.4 (#53170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-17 17:38:02 +02:00
Paul Bottein 6b0f543fdb Add conditional field visibility to ha-form (#53165) 2026-07-17 14:06:04 +01:00
24 changed files with 1219 additions and 770 deletions
+4
View File
@@ -15,6 +15,10 @@ updates:
cooldown:
default-days: 7
open-pull-requests-limit: 10
groups:
codeql-action:
patterns:
- "github/codeql-action/*"
labels:
- Dependencies
- GitHub Actions
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
with:
sync-labels: true
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90
+3 -3
View File
@@ -146,13 +146,13 @@
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.1.0",
"@lokalise/node-api": "16.2.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.5.18",
"@rspack/core": "2.1.3",
"@rsdoctor/rspack-plugin": "1.6.0",
"@rspack/core": "2.1.4",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
+73
View File
@@ -0,0 +1,73 @@
import type {
HaFormBaseSchema,
HaFormCondition,
HaFormDataContainer,
HaFormFieldCondition,
HaFormSchema,
} from "./types";
const isEmpty = (value: unknown): boolean =>
value === undefined || value === null || value === "";
const matchFieldCondition = (
condition: HaFormFieldCondition,
data: HaFormDataContainer | undefined
): boolean => {
const actual = data?.[condition.field];
switch (condition.operator ?? "eq") {
case "eq":
return actual === condition.value;
case "not_eq":
return actual !== condition.value;
case "in":
return (
Array.isArray(condition.value) &&
condition.value.includes(actual as any)
);
case "not_in":
return (
Array.isArray(condition.value) &&
!condition.value.includes(actual as any)
);
case "exists":
return !isEmpty(actual);
case "not_exists":
return isEmpty(actual);
default:
return false;
}
};
export const evaluateCondition = (
condition: HaFormCondition,
data: HaFormDataContainer | undefined
): boolean => {
if ("condition" in condition) {
switch (condition.condition) {
case "and":
return condition.conditions.every((c) => evaluateCondition(c, data));
case "or":
return condition.conditions.some((c) => evaluateCondition(c, data));
case "not":
return !condition.conditions.some((c) => evaluateCondition(c, data));
default:
return false;
}
}
return matchFieldCondition(condition, data);
};
export const isFieldHidden = (
schema: HaFormSchema,
data: HaFormDataContainer | undefined
): boolean => {
const { hidden } = schema as HaFormBaseSchema;
if (!hidden) {
return false;
}
if (hidden === true) {
return true;
}
const conditions = Array.isArray(hidden) ? hidden : [hidden];
return conditions.every((condition) => evaluateCondition(condition, data));
};
+16 -13
View File
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { isFieldHidden } from "./conditions";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
@@ -68,19 +69,21 @@ export class HaFormGrid extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
${this.schema.schema.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
${this.schema.schema
.filter((item) => !isFieldHidden(item, this.data))
.map(
(item) => html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${[item]}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
)}
`;
}
+11 -2
View File
@@ -1,11 +1,12 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-selector/ha-selector";
import { isFieldHidden } from "./conditions";
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
const LOAD_ELEMENTS = {
@@ -98,7 +99,11 @@ export class HaForm extends LitElement implements HaFormElement {
let isValid = true;
let firstInvalidElement: HTMLElement | undefined;
this.schema.forEach((item, index) => {
const visibleSchema = this.schema.filter(
(item) => !isFieldHidden(item, this.data)
);
visibleSchema.forEach((item, index) => {
const element = elements[index];
if (!element) {
return;
@@ -164,6 +169,10 @@ export class HaForm extends LitElement implements HaFormElement {
: ""
}
${this.schema.map((item) => {
if (isFieldHidden(item, this.data)) {
return nothing;
}
const error = getError(this.error, item);
const warning = getWarning(this.warning, item);
+33
View File
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
default?: HaFormData;
required?: boolean;
disabled?: boolean;
// Field is hidden while the condition holds. Serializable so it can be
// shared with the backend and other renderers.
hidden?: boolean | HaFormCondition | HaFormCondition[];
description?: {
suffix?: string;
// This value will be set initially when form is loaded
@@ -30,6 +33,36 @@ export interface HaFormBaseSchema {
context?: Record<string, string>;
}
export type HaFormConditionOperator =
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
export interface HaFormFieldCondition {
field: string;
operator?: HaFormConditionOperator;
value?: HaFormData | readonly HaFormData[];
}
export interface HaFormAndCondition {
condition: "and";
conditions: readonly HaFormCondition[];
}
export interface HaFormOrCondition {
condition: "or";
conditions: readonly HaFormCondition[];
}
export interface HaFormNotCondition {
condition: "not";
conditions: readonly HaFormCondition[];
}
export type HaFormCondition =
| HaFormFieldCondition
| HaFormAndCondition
| HaFormOrCondition
| HaFormNotCondition;
export interface HaFormGridSchema extends HaFormBaseSchema {
type: "grid";
flatten?: boolean;
@@ -133,7 +133,13 @@ export class HaAreaSelector extends LitElement {
}
return ensureArray(this.selector.area.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
};
@@ -147,7 +147,13 @@ export class HaDeviceSelector extends LitElement {
private _filterEntities = (entity: HassEntity): boolean =>
ensureArray(this.selector.device!.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
}
@@ -2,8 +2,12 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { EntitySelector } from "../../data/selector";
@@ -23,6 +27,8 @@ export class HaEntitySelector extends LitElement {
@state() private _entitySources?: EntitySources;
@state() private _configEntries?: ConfigEntry[];
@property() public value?: any;
@property() public label?: string;
@@ -37,12 +43,38 @@ export class HaEntitySelector extends LitElement {
@state() private _createDomains: string[] | undefined;
private _hasIntegration(selector: EntitySelector) {
return (
selector.entity?.filter &&
ensureArray(selector.entity.filter).some((filter) => filter.integration)
);
}
private _deviceIntegrationLookup = memoizeOne(
(
entitySources: EntitySources,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
configEntries?: ConfigEntry[]
) =>
getDeviceIntegrationLookup(
entitySources,
Object.values(entities),
Object.values(devices),
configEntries
)
);
// Which async data the current filter needs to be evaluated: a top-level or
// device `integration` filter needs entity sources, and a `device.integration`
// filter additionally needs config entries (the device integration lookup is
// built from both).
private _dataNeeds = memoizeOne((selector: EntitySelector) => {
const filters = selector.entity?.filter
? ensureArray(selector.entity.filter)
: [];
return {
entitySources: filters.some(
(f) => f.integration || f.device?.integration
),
configEntries: filters.some((f) => f.device?.integration),
};
});
private _fetchedConfigEntries = false;
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.get("selector") && this.value !== undefined) {
@@ -57,7 +89,11 @@ export class HaEntitySelector extends LitElement {
}
protected render() {
if (this._hasIntegration(this.selector) && !this._entitySources) {
const needs = this._dataNeeds(this.selector);
if (
(needs.entitySources && !this._entitySources) ||
(needs.configEntries && !this._configEntries)
) {
return nothing;
}
@@ -96,15 +132,37 @@ export class HaEntitySelector extends LitElement {
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
changedProps.has("selector") &&
this._hasIntegration(this.selector) &&
!this._entitySources
) {
// The connection changed (e.g. reconnect); refetch config entries.
const oldHass = changedProps.get("hass");
if (oldHass && oldHass.connection !== this.hass.connection) {
this._fetchedConfigEntries = false;
this._configEntries = undefined;
}
const needs = this._dataNeeds(this.selector);
if (needs.entitySources && !this._entitySources) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
if (needs.configEntries && !this._fetchedConfigEntries) {
this._fetchedConfigEntries = true;
getConfigEntries(this.hass)
.then((entries) => {
this._configEntries = entries;
})
.catch(() => {
// Fall back to no entries so the picker still renders. We keep
// `_fetchedConfigEntries` set so the failed fetch is not retried on
// every re-render; the connection-change handler above retries on
// reconnect.
this._configEntries = [];
});
}
if (changedProps.has("selector")) {
this._createDomains = computeCreateDomains(this.selector);
}
@@ -114,8 +172,25 @@ export class HaEntitySelector extends LitElement {
if (!this.selector?.entity?.filter) {
return true;
}
const deviceIntegrationLookup =
this._entitySources && this._dataNeeds(this.selector).configEntries
? this._deviceIntegrationLookup(
this._entitySources,
this.hass.entities,
this.hass.devices,
this._configEntries
)
: undefined;
return ensureArray(this.selector.entity.filter).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices,
deviceIntegrationLookup
)
);
};
}
@@ -133,7 +133,13 @@ export class HaFloorSelector extends LitElement {
}
return ensureArray(this.selector.floor.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
};
@@ -93,7 +93,13 @@ export class HaTargetSelector extends LitElement {
}
return ensureArray(this.selector.target.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices
)
);
};
+7 -1
View File
@@ -895,7 +895,13 @@ export class HaServiceControl extends LitElement {
}
if (targetEntities.length) {
targetEntities = targetEntities.filter((entity) =>
entityMeetsTargetSelector(this.hass.states[entity], targetSelector)
entityMeetsTargetSelector(
this.hass.states[entity],
targetSelector,
undefined,
this.hass.entities,
this.hass.devices
)
);
}
target = {
+3 -32
View File
@@ -15,26 +15,10 @@ export interface HttpConfig {
ssl_profile?: "modern" | "intermediate";
}
// The slot the running HTTP server was actually started with.
export type ActiveConfigType = "stable" | "pending" | "default";
// A stored config slot carries metadata alongside the editable fields:
// - created_at: when the slot was staged
// - error: null while healthy; set once a slot could not be applied or a
// pending trial was not confirmed (then it is kept for display, not retried)
export interface HttpConfigWithMeta extends HttpConfig {
created_at?: string;
error?: string | null;
}
export interface HttpConfigState {
stable: HttpConfigWithMeta;
pending: HttpConfigWithMeta | null;
stable: HttpConfig;
pending: HttpConfig | null;
revert_at: string | null;
// Added in the "active HTTP config slot" backend change; optional so the
// frontend keeps working against cores without it.
active_config_type?: ActiveConfigType;
default?: HttpConfigWithMeta;
}
export const HTTP_CONFIG_FIELDS: (keyof HttpConfig)[] = [
@@ -56,19 +40,6 @@ export interface SaveHttpConfigResult {
restart: boolean;
}
// Keep only the editable fields; the backend storage schema rejects unknown
// keys, so the created_at/error metadata that rides along on a fetched slot
// must be dropped before configuring.
export const stripHttpConfigMeta = (config: HttpConfig): HttpConfig => {
const stripped: Partial<Record<keyof HttpConfig, unknown>> = {};
for (const key of HTTP_CONFIG_FIELDS) {
if (config[key] !== undefined) {
stripped[key] = config[key];
}
}
return stripped as HttpConfig;
};
export const fetchHttpConfig = (hass: HomeAssistant) =>
hass.callWS<HttpConfigState>({ type: "http/config" });
@@ -78,7 +49,7 @@ export const saveHttpConfig = (
) =>
hass.callWS<SaveHttpConfigResult>({
type: "http/config/configure",
config: config ? stripHttpConfigMeta(config) : null,
config,
});
export const promoteHttpConfig = (hass: HomeAssistant) =>
+53 -10
View File
@@ -266,6 +266,10 @@ interface EntitySelectorFilter {
unit_of_measurement?: string | readonly string[];
}
interface EntitySelectorEntityFilter extends EntitySelectorFilter {
device?: DeviceSelectorFilter;
}
export interface EntitySelectorExtraOption {
id: string;
primary: string;
@@ -281,7 +285,7 @@ export interface EntitySelector {
multiple?: boolean;
include_entities?: string[];
exclude_entities?: string[];
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
filter?: EntitySelectorEntityFilter | readonly EntitySelectorEntityFilter[];
reorder?: boolean;
extra_options?: EntitySelectorExtraOption[];
} | null;
@@ -671,7 +675,9 @@ export const expandLabelTarget = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
newEntities.push(entity.entity_id);
@@ -737,7 +743,9 @@ export const expandAreaTarget = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
newEntities.push(entity.entity_id);
@@ -760,7 +768,9 @@ export const expandDeviceTarget = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
newEntities.push(entity.entity_id);
@@ -801,7 +811,9 @@ export const areaMeetsTargetSelector = (
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
entitySources,
hass.entities,
hass.devices
)
) {
return true;
@@ -849,14 +861,22 @@ export const deviceMeetsTargetSelector = (
export const entityMeetsTargetSelector = (
entity: HassEntity | undefined,
targetSelector: TargetSelector,
entitySources?: EntitySources
entitySources?: EntitySources,
entities?: HomeAssistant["entities"],
devices?: HomeAssistant["devices"]
): boolean => {
if (!entity) {
return false;
}
if (targetSelector.target?.entity) {
return ensureArray(targetSelector.target!.entity).some((filterEntity) =>
filterSelectorEntities(filterEntity, entity, entitySources)
filterSelectorEntities(
filterEntity,
entity,
entitySources,
entities,
devices
)
);
}
return true;
@@ -895,9 +915,12 @@ export const filterSelectorDevices = (
};
export const filterSelectorEntities = (
filterEntity: EntitySelectorFilter,
filterEntity: EntitySelectorEntityFilter,
entity: HassEntity,
entitySources?: EntitySources
entitySources?: EntitySources,
entityRegistry?: HomeAssistant["entities"],
devices?: HomeAssistant["devices"],
deviceIntegrationLookup?: Record<string, Set<string>>
): boolean => {
const {
domain: filterDomain,
@@ -905,6 +928,7 @@ export const filterSelectorEntities = (
supported_features: filterSupportedFeature,
unit_of_measurement: filterUnitOfMeasurement,
integration: filterIntegration,
device: filterDevice,
} = filterEntity;
if (filterDomain) {
@@ -951,6 +975,24 @@ export const filterSelectorEntities = (
}
}
if (filterDevice) {
if (!entityRegistry || !devices) {
return false;
}
const deviceId = entityRegistry[entity.entity_id]?.device_id;
if (!deviceId) {
return false;
}
const device = devices[deviceId];
if (!device) {
return false;
}
if (!filterSelectorDevices(filterDevice, device, deviceIntegrationLookup)) {
return false;
}
}
if (
filterIntegration &&
entitySources?.[entity.entity_id]?.domain !== filterIntegration
@@ -1020,7 +1062,7 @@ export const handleLegacyDeviceSelector = (
export const computeCreateDomains = (
selector: EntitySelector | TargetSelector
): undefined | string[] => {
let entityFilters: EntitySelectorFilter[] | undefined;
let entityFilters: EntitySelectorEntityFilter[] | undefined;
if ("target" in selector) {
entityFilters = ensureArray(selector.target?.entity);
@@ -1038,6 +1080,7 @@ export const computeCreateDomains = (
!entityFilter.integration &&
!entityFilter.device_class &&
!entityFilter.supported_features &&
!entityFilter.device &&
entityFilter.domain
? ensureArray(entityFilter.domain).filter((domain) =>
isHelperDomain(domain)
+1 -8
View File
@@ -250,14 +250,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// The check re-runs on the next reconnect; ignore transient failures.
return;
}
// Only prompt for an active trial. A pending config with an error was
// already reverted/failed and is kept only for display in the config form,
// so it must not pop the confirm/revert dialog.
if (
!httpConfig.pending ||
httpConfig.pending.error ||
this._httpPendingDialogOpen
) {
if (!httpConfig.pending || this._httpPendingDialogOpen) {
return;
}
this._httpPendingDialogOpen = true;
@@ -19,11 +19,7 @@ import {
HTTP_CONFIG_FIELDS,
saveHttpConfig,
} from "../../../data/http";
import type {
ActiveConfigType,
HttpConfig,
HttpConfigWithMeta,
} from "../../../data/http";
import type { HttpConfig } from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -165,11 +161,6 @@ class HaConfigHttpForm extends LitElement {
@state() private _showNoChanges = false;
@state() private _activeConfigType?: ActiveConfigType;
// A pending config that was reverted/failed and kept only for display.
@state() private _revertedPending?: HttpConfigWithMeta;
@query("ha-form") private _form?: HaForm;
@query("ha-alert") private _firstAlert?: HTMLElement;
@@ -210,40 +201,6 @@ class HaConfigHttpForm extends LitElement {
<p class="description">
${this.hass.localize("ui.panel.config.network.http.description")}
</p>
${
this._activeConfigType === "default"
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.running_default"
)}
</ha-alert>
`
: nothing
}
${
this._revertedPending
? html`
<ha-alert alert-type="warning">
${
this._revertedPending.error === "not_promoted"
? this.hass.localize(
"ui.panel.config.network.http.reverted_not_confirmed"
)
: this.hass.localize(
"ui.panel.config.network.http.reverted_failed",
{ error: this._revertedPending.error ?? "" }
)
}
<ha-button slot="action" @click=${this._reviewReverted}>
${this.hass.localize(
"ui.panel.config.network.http.reverted_action"
)}
</ha-button>
</ha-alert>
`
: nothing
}
${
portChanged
? html`
@@ -309,30 +266,16 @@ class HaConfigHttpForm extends LitElement {
private async _fetchConfig(): Promise<void> {
try {
const { stable, pending, active_config_type } = await fetchHttpConfig(
this.hass
);
// Pending is exclusively handled by the global confirm/revert dialog, so
// the form only ever displays stable.
const { stable } = await fetchHttpConfig(this.hass);
this._stable = stable;
this._config = { ...stable };
this._activeConfigType = active_config_type;
// An active trial pending (no error) is handled by the global
// confirm/revert dialog. A pending carrying an error was reverted or
// failed to apply and is kept only so we can surface it here.
this._revertedPending = pending?.error ? pending : undefined;
} catch (err: any) {
this._error = err.message;
}
}
private _reviewReverted(): void {
if (!this._revertedPending) {
return;
}
// Load the reverted values into the form so the user can fix and re-save.
this._config = { ...this._revertedPending };
this._revertedPending = undefined;
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof SCHEMA>>
): string => {
@@ -54,6 +54,65 @@ const cardConfigStruct = assign(
})
);
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
{
name: "fit_y_data",
required: false,
hidden: {
condition: "and",
conditions: [
{ field: "min_y_axis", operator: "not_exists" },
{ field: "max_y_axis", operator: "not_exists" },
],
},
selector: { boolean: {} },
},
] as const satisfies readonly HaFormSchema[];
@customElement("hui-history-graph-card-editor")
export class HuiHistoryGraphCardEditor
extends LitElement
@@ -70,65 +129,6 @@ export class HuiHistoryGraphCardEditor
this._config = config;
}
private _schema = memoizeOne(
(showFitOption: boolean) =>
[
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 0, step: "any", mode: "box" } },
},
{
name: "show_names",
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
selector: { boolean: {} },
},
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
],
},
{
name: "",
type: "grid",
schema: [
{
name: "min_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
{
name: "max_y_axis",
required: false,
selector: { number: { mode: "box", step: "any" } },
},
],
},
...(showFitOption
? [
{
name: "fit_y_data",
required: false,
selector: { boolean: {} },
},
]
: []),
] as const
);
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
schema: [
{ name: "entity", selector: { entity: {} }, required: true },
@@ -176,11 +176,6 @@ export class HuiHistoryGraphCardEditor
`;
}
const schema = this._schema(
this._config!.min_y_axis !== undefined ||
this._config!.max_y_axis !== undefined
);
const configEntities = this._config.entities
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
: [];
@@ -188,7 +183,7 @@ export class HuiHistoryGraphCardEditor
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
@@ -283,9 +278,7 @@ export class HuiHistoryGraphCardEditor
) as HistoryGraphCardConfig;
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "show_names":
case "logarithmic_scale":
@@ -92,7 +92,6 @@ export class HuiTileCardEditor
(
localize: LocalizeFunc,
entityId: string | undefined,
hideState: boolean,
showTimeFormat: boolean
) =>
[
@@ -144,31 +143,25 @@ export class HuiTileCardEditor
},
],
},
...(!hideState
? ([
{
name: "state_content",
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
] as const satisfies readonly HaFormSchema[])
: []),
...(showTimeFormat
? ([
{
name: "time_format",
selector: {
ui_time_format: {},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "state_content",
hidden: { field: "hide_state", value: true },
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
hidden: !showTimeFormat,
selector: {
ui_time_format: {},
},
},
{
name: "content_layout",
required: true,
@@ -293,12 +286,7 @@ export class HuiTileCardEditor
this._config.state_content
);
const schema = this._schema(
this.hass.localize,
entityId,
this._config.hide_state ?? false,
showTimeFormat
);
const schema = this._schema(this.hass.localize, entityId, showTimeFormat);
const vertical = this._config.vertical ?? false;
-4
View File
@@ -8684,10 +8684,6 @@
"port_warning": "Clients such as the Home Assistant mobile apps will lose their connection until you update the URL in their settings. If Home Assistant is not confirmed reachable on the new port, the change is rolled back automatically after 5 minutes.",
"invalid_host": "Enter a valid IP address.",
"invalid_network": "Enter a valid IP address or network.",
"running_default": "Your saved HTTP configuration could not be applied, so Home Assistant is running on the built-in default configuration.",
"reverted_not_confirmed": "The last HTTP configuration change was not confirmed in time and was rolled back. Home Assistant is running on the previous configuration.",
"reverted_failed": "The last HTTP configuration change could not be applied and was rolled back. Home Assistant is running on the previous configuration. Reason: {error}",
"reverted_action": "Review the change",
"save_confirm": {
"title": "Restart required",
"text": "Saving will restart Home Assistant to apply the new HTTP settings.",
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { isFieldHidden } from "../../../src/components/ha-form/conditions";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const field = (hidden: HaFormSchema["hidden"]): HaFormSchema =>
({ name: "field", selector: { text: {} }, hidden }) as HaFormSchema;
describe("isFieldHidden", () => {
it("shows a field without a hidden condition", () => {
expect(isFieldHidden(field(undefined), { a: 1 })).toBe(false);
});
it("honors a boolean hidden", () => {
expect(isFieldHidden(field(true), {})).toBe(true);
expect(isFieldHidden(field(false), {})).toBe(false);
});
describe("operators", () => {
it("eq (default) matches equal values", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 1 })).toBe(
true
);
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 2 })).toBe(
false
);
});
it("not_eq matches different values", () => {
const schema = field({ field: "a", operator: "not_eq", value: 1 });
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("in matches membership", () => {
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
expect(isFieldHidden(schema, { a: "y" })).toBe(true);
expect(isFieldHidden(schema, { a: "z" })).toBe(false);
});
it("not_in matches non-membership", () => {
const schema = field({
field: "a",
operator: "not_in",
value: ["x", "y"],
});
expect(isFieldHidden(schema, { a: "z" })).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
it("exists matches a defined non-empty value", () => {
const schema = field({ field: "a", operator: "exists" });
expect(isFieldHidden(schema, { a: "x" })).toBe(true);
expect(isFieldHidden(schema, { a: "" })).toBe(false);
expect(isFieldHidden(schema, {})).toBe(false);
});
it("not_exists matches a missing or empty value", () => {
const schema = field({ field: "a", operator: "not_exists" });
expect(isFieldHidden(schema, {})).toBe(true);
expect(isFieldHidden(schema, { a: null } as any)).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
});
describe("combinators", () => {
it("and requires every condition", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("or requires any condition", () => {
const schema = field({
condition: "or",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 9, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 9, b: 9 })).toBe(false);
});
it("not negates its conditions", () => {
const schema = field({
condition: "not",
conditions: [{ field: "a", value: 1 }],
});
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("nests combinators", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{
condition: "or",
conditions: [
{ field: "b", value: 2 },
{ field: "c", value: 3 },
],
},
],
});
expect(isFieldHidden(schema, { a: 1, b: 9, c: 3 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9, c: 9 })).toBe(false);
expect(isFieldHidden(schema, { a: 9, b: 2, c: 3 })).toBe(false);
});
});
it("treats an array of conditions as AND", () => {
const schema = field([
{ field: "a", value: 1 },
{ field: "b", value: 2 },
]);
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("handles missing data", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), undefined)).toBe(
false
);
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import { filterSelectorEntities } from "../../src/data/selector";
import type { HomeAssistant } from "../../src/types";
const entity = {
entity_id: "light.living_room",
state: "on",
attributes: {},
} as HassEntity;
const entityRegistry = {
"light.living_room": { device_id: "device_1" },
} as unknown as HomeAssistant["entities"];
const devices = {
device_1: {
id: "device_1",
manufacturer: "Signify",
model: "Hue Bulb",
model_id: "LCT015",
} as DeviceRegistryEntry,
} as unknown as HomeAssistant["devices"];
describe("filterSelectorEntities device filter", () => {
it("matches when the nested device manufacturer matches", () => {
expect(
filterSelectorEntities(
{ device: { manufacturer: "Signify" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(true);
});
it("does not match when the nested device manufacturer differs", () => {
expect(
filterSelectorEntities(
{ device: { manufacturer: "Sonos" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(false);
});
it("matches when model and model_id both match", () => {
expect(
filterSelectorEntities(
{ device: { model: "Hue Bulb", model_id: "LCT015" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(true);
});
it("does not match when one of model or model_id differs", () => {
expect(
filterSelectorEntities(
{ device: { model: "Hue Bulb", model_id: "OTHER" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(false);
});
it("matches the device integration via the lookup", () => {
expect(
filterSelectorEntities(
{ device: { integration: "hue" } },
entity,
undefined,
entityRegistry,
devices,
{ device_1: new Set(["hue"]) }
)
).toBe(true);
});
it("does not match a device integration that is absent from the lookup", () => {
expect(
filterSelectorEntities(
{ device: { integration: "zha" } },
entity,
undefined,
entityRegistry,
devices,
{ device_1: new Set(["hue"]) }
)
).toBe(false);
});
it("does not match when the entity has no underlying device", () => {
expect(
filterSelectorEntities(
{ device: { manufacturer: "Signify" } },
entity,
undefined,
{} as HomeAssistant["entities"],
devices
)
).toBe(false);
});
it("combines device conditions with other entity conditions (AND)", () => {
expect(
filterSelectorEntities(
{ domain: "light", device: { manufacturer: "Signify" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(true);
expect(
filterSelectorEntities(
{ domain: "switch", device: { manufacturer: "Signify" } },
entity,
undefined,
entityRegistry,
devices
)
).toBe(false);
});
});
+544 -517
View File
File diff suppressed because it is too large Load Diff