mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-21 10:36:26 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88ef6fe7ad |
@@ -22,8 +22,7 @@ fireEvent(this, "show-dialog", {
|
||||
Dialog implementation requirements:
|
||||
|
||||
- Use `ha-dialog`.
|
||||
- Use `DialogMixin`, which implements `HassDialogNext<T>`, for new dialogs. See `src/dialogs/dialog-mixin.ts`.
|
||||
- Existing dialogs may implement the legacy `HassDialog<T>` interface from `src/dialogs/make-dialog-manager.ts`.
|
||||
- Implement `HassDialog<T>`.
|
||||
- Use `@state() private _open = false` to control visibility.
|
||||
- Set `_open = true` in `showDialog()` and `_open = false` in `closeDialog()`.
|
||||
- Return `nothing` while required params are absent.
|
||||
|
||||
@@ -33,13 +33,13 @@ For focused type feedback on one file, use editor diagnostics instead of a file-
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core configured through `development_repo`.
|
||||
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. The default is 8124, or 8123 in a devcontainer.
|
||||
`yarn dev:serve` also serves locally and supports `-c` for the core URL and `-p` for the port. Default local serving port is 8124.
|
||||
|
||||
Dev server commands support `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
## Playwright E2E
|
||||
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when its configured URL responds; otherwise it performs a slow full build. When a development watcher is being reused, rspack recompiles on save and reruns should not need a restart.
|
||||
Each suite has its own dev server port. Playwright reuses an existing server locally when the port is already running; otherwise it performs a slow full build. The rspack watcher recompiles on save, so reruns should not need a restart.
|
||||
|
||||
Start the relevant suite server, then run that suite:
|
||||
|
||||
@@ -49,7 +49,7 @@ Start the relevant suite server, then run that suite:
|
||||
| Demo | `yarn dev:demo` on 8090 | `yarn test:e2e:demo` |
|
||||
| Gallery | `yarn dev:gallery` on 8100 | `yarn test:e2e:gallery` |
|
||||
|
||||
The custom development wrappers use `/__ha_dev_status` to identify and manage their own suites. Playwright server reuse checks the configured URL instead. Wrapper start and stop operations are idempotent for a matching suite and reject an unrelated process occupying the port.
|
||||
Server reuse and `--stop` use the `/__ha_dev_status` health check, so starting or stopping twice is harmless.
|
||||
|
||||
Use `-g "<title>" --project=chromium` to narrow a run. `yarn test:e2e` runs all three suites. Run suites directly; piping through output truncation hides progress and failures.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Use this skill for all user-facing text, translations, labels, buttons, dialog c
|
||||
- Give translators enough context through key naming and placeholders.
|
||||
|
||||
```ts
|
||||
this.hass.localize("ui.panel.config.updates.updates_refreshed", {
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
@@ -15,10 +15,6 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
labels:
|
||||
- Dependencies
|
||||
- GitHub Actions
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
Agents:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "**/AGENTS.md"
|
||||
- "**/CLAUDE.md"
|
||||
- "**/GEMINI.md"
|
||||
- .agents/**
|
||||
- .claude/**
|
||||
- .github/agents/**
|
||||
- .github/copilot-instructions.md
|
||||
- .github/hooks/**
|
||||
- .github/instructions/**
|
||||
- .github/plugin.json
|
||||
- .github/plugin/**
|
||||
- .github/prompts/**
|
||||
- .github/skills/**
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -50,11 +50,3 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
|
||||
## Pull Requests
|
||||
|
||||
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the PR body. Preserve template sections, check only the appropriate type-of-change boxes, and do not check checklist items on behalf of the user. If the PR includes UI changes, remind the user to add screenshots or a short video.
|
||||
|
||||
## AI policy
|
||||
|
||||
This project follows the [Open Home Foundation AI Policy](AI_POLICY.md).
|
||||
Autonomous contributions are not accepted: a human must review, understand,
|
||||
and be able to explain every change before it is submitted. Do not open
|
||||
issues or pull requests autonomously, and do not post comments on behalf of
|
||||
a user without their review.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Open Home Foundation - AI Policy
|
||||
|
||||
We support using AI (i.e., LLMs) as tools when contributing to Open Home Foundation projects. However, you are responsible for any contributions you submit, and we are responsible for any contributions we merge and release. We hold a high bar for all contributions to our projects.
|
||||
|
||||
Our maintainers dedicate their time and expertise to reviewing contributions. Submitting AI-generated content that you have not personally reviewed and understood wastes that time and will not be accepted.
|
||||
|
||||
## Autonomous agents
|
||||
|
||||
**We do not allow autonomous agents to be used for contributing to our projects.** We will close any pull requests or issues that we believe were created autonomously, and may mark automated comments as spam. This includes contributions that bypass the provided issue or pull request templates.
|
||||
|
||||
## Communication on issues, pull requests, and code reviews
|
||||
|
||||
We don't mind if you use AI tools to help you write. However, do not have tools post unreviewed content on your behalf. Keep responses to the minimum needed to communicate your intent. We may hide any comments that we believe are unreviewed AI output.
|
||||
|
||||
If you are opening a pull request, we expect you to be able to explain the proposed changes in your own words. This includes the pull request description and responses to questions. If you use AI to help generate the pull request summary, you must review it for technical accuracy.
|
||||
|
||||
**Do not use AI to generate answers to questions from maintainers.** You should understand and be able to explain your own work. Using AI to improve grammar or clarity is fine, but the substance of your responses must be your own.
|
||||
|
||||
If you wish to include context from an interaction with AI in your comments, it must be in a quote block (e.g., using `>`) and disclosed as such. It must be accompanied by your own commentary explaining the relevance and implications of the context. Do not share long snippets.
|
||||
|
||||
## Non-native English speakers
|
||||
|
||||
We understand that AI is useful when communicating as a non-native English speaker. Using AI to improve the grammar or clarity of text you have written yourself is fine. If you are using AI to translate your comments, please ensure the translation accurately reflects your intent. Including your original text in a details block shows the effort behind your contribution, helps maintainers verify the translation if needed, and keeps the conversation readable.
|
||||
|
||||
## Code and documentation contributions
|
||||
|
||||
AI can be a helpful tool for writing code and documentation. However, due to the foundational open source nature of our projects, we require a human in the loop who understands the work produced by AI.
|
||||
|
||||
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
|
||||
|
||||
## Our use of AI
|
||||
|
||||
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
|
||||
|
||||
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Contributions that do not follow this policy will be closed. Repeated violations may result in being blocked from contributing to OHF projects. If you believe your contribution was closed in error, you are welcome to reach out to a maintainer to discuss.
|
||||
|
||||
---
|
||||
|
||||
The canonical version of this policy is published at
|
||||
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
the published version applies.
|
||||
+11
-11
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.2",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.13",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.14",
|
||||
"@formatjs/intl-pluralrules": "6.3.13",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.13",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -107,7 +107,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -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.3.0",
|
||||
"@lokalise/node-api": "16.1.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.6.0",
|
||||
"@rspack/core": "2.1.4",
|
||||
"@rsdoctor/rspack-plugin": "1.5.18",
|
||||
"@rspack/core": "2.1.3",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
|
||||
@@ -86,11 +86,6 @@
|
||||
"description": "Group date-fns with dependent timezone package",
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
"matchPackageNames": ["@formatjs/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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));
|
||||
};
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -69,21 +68,19 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${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>
|
||||
`
|
||||
)}
|
||||
${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>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } 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 = {
|
||||
@@ -99,11 +98,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !isFieldHidden(item, this.data)
|
||||
);
|
||||
|
||||
visibleSchema.forEach((item, index) => {
|
||||
this.schema.forEach((item, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -169,10 +164,6 @@ 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);
|
||||
|
||||
|
||||
@@ -22,9 +22,6 @@ 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
|
||||
@@ -33,36 +30,6 @@ 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,13 +133,7 @@ export class HaAreaSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.area.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -147,13 +147,7 @@ export class HaDeviceSelector extends LitElement {
|
||||
|
||||
private _filterEntities = (entity: HassEntity): boolean =>
|
||||
ensureArray(this.selector.device!.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,8 @@ 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";
|
||||
@@ -27,8 +23,6 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -43,38 +37,12 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
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;
|
||||
private _hasIntegration(selector: EntitySelector) {
|
||||
return (
|
||||
selector.entity?.filter &&
|
||||
ensureArray(selector.entity.filter).some((filter) => filter.integration)
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||
@@ -89,11 +57,7 @@ export class HaEntitySelector extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
if (
|
||||
(needs.entitySources && !this._entitySources) ||
|
||||
(needs.configEntries && !this._configEntries)
|
||||
) {
|
||||
if (this._hasIntegration(this.selector) && !this._entitySources) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -132,37 +96,15 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
// 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) {
|
||||
if (
|
||||
changedProps.has("selector") &&
|
||||
this._hasIntegration(this.selector) &&
|
||||
!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);
|
||||
}
|
||||
@@ -172,25 +114,8 @@ 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,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
deviceIntegrationLookup
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,13 +133,7 @@ export class HaFloorSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.floor.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -93,13 +93,7 @@ export class HaTargetSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.target.entity).some((filter) =>
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { TimezoneSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-timezone-picker";
|
||||
|
||||
@customElement("ha-selector-timezone")
|
||||
export class HaTimezoneSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: TimezoneSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -20,6 +23,7 @@ export class HaTimezoneSelector extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -895,13 +895,7 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
if (targetEntities.length) {
|
||||
targetEntities = targetEntities.filter((entity) =>
|
||||
entityMeetsTargetSelector(
|
||||
this.hass.states[entity],
|
||||
targetSelector,
|
||||
undefined,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
entityMeetsTargetSelector(this.hass.states[entity], targetSelector)
|
||||
);
|
||||
}
|
||||
target = {
|
||||
|
||||
@@ -2,10 +2,8 @@ import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-generic-picker";
|
||||
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
@@ -54,8 +52,7 @@ export const getTimezoneOptions = (): PickerComboBoxItem[] => {
|
||||
|
||||
@customElement("ha-timezone-picker")
|
||||
export class HaTimeZonePicker extends LitElement {
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@@ -85,14 +82,15 @@ export class HaTimeZonePicker extends LitElement {
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ??
|
||||
(this._localize("ui.components.timezone-picker.time_zone") ||
|
||||
(this.hass?.localize("ui.components.timezone-picker.time_zone") ||
|
||||
"Time zone");
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${
|
||||
this._localize("ui.components.timezone-picker.no_timezones") ||
|
||||
this.hass?.localize("ui.components.timezone-picker.no_timezones") ||
|
||||
"No time zones available"
|
||||
}
|
||||
.label=${label}
|
||||
@@ -126,10 +124,9 @@ export class HaTimeZonePicker extends LitElement {
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return (
|
||||
this._localize("ui.components.timezone-picker.no_match", { term }) ||
|
||||
html`No time zones found for ${term}`
|
||||
);
|
||||
return this.hass
|
||||
? this.hass.localize("ui.components.timezone-picker.no_match", { term })
|
||||
: html`No time zones found for ${term}`;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { memoize } from "@fullcalendar/core/internal";
|
||||
import { setHours, setMinutes } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -410,7 +411,7 @@ export type BackupType = "automatic" | "manual" | "app_update";
|
||||
|
||||
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "app_update", "manual"];
|
||||
|
||||
export const getBackupTypes = memoizeOne((isHassio: boolean) =>
|
||||
export const getBackupTypes = memoize((isHassio: boolean) =>
|
||||
isHassio
|
||||
? BACKUP_TYPE_ORDER
|
||||
: BACKUP_TYPE_ORDER.filter((type) => type !== "app_update")
|
||||
|
||||
+32
-3
@@ -15,10 +15,26 @@ 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: HttpConfig;
|
||||
pending: HttpConfig | null;
|
||||
stable: HttpConfigWithMeta;
|
||||
pending: HttpConfigWithMeta | 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)[] = [
|
||||
@@ -40,6 +56,19 @@ 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" });
|
||||
|
||||
@@ -49,7 +78,7 @@ export const saveHttpConfig = (
|
||||
) =>
|
||||
hass.callWS<SaveHttpConfigResult>({
|
||||
type: "http/config/configure",
|
||||
config,
|
||||
config: config ? stripHttpConfigMeta(config) : null,
|
||||
});
|
||||
|
||||
export const promoteHttpConfig = (hass: HomeAssistant) =>
|
||||
|
||||
+10
-53
@@ -266,10 +266,6 @@ interface EntitySelectorFilter {
|
||||
unit_of_measurement?: string | readonly string[];
|
||||
}
|
||||
|
||||
interface EntitySelectorEntityFilter extends EntitySelectorFilter {
|
||||
device?: DeviceSelectorFilter;
|
||||
}
|
||||
|
||||
export interface EntitySelectorExtraOption {
|
||||
id: string;
|
||||
primary: string;
|
||||
@@ -285,7 +281,7 @@ export interface EntitySelector {
|
||||
multiple?: boolean;
|
||||
include_entities?: string[];
|
||||
exclude_entities?: string[];
|
||||
filter?: EntitySelectorEntityFilter | readonly EntitySelectorEntityFilter[];
|
||||
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
|
||||
reorder?: boolean;
|
||||
extra_options?: EntitySelectorExtraOption[];
|
||||
} | null;
|
||||
@@ -675,9 +671,7 @@ export const expandLabelTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -743,9 +737,7 @@ export const expandAreaTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -768,9 +760,7 @@ export const expandDeviceTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -811,9 +801,7 @@ export const areaMeetsTargetSelector = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
@@ -861,22 +849,14 @@ export const deviceMeetsTargetSelector = (
|
||||
export const entityMeetsTargetSelector = (
|
||||
entity: HassEntity | undefined,
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources,
|
||||
entities?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"]
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (targetSelector.target?.entity) {
|
||||
return ensureArray(targetSelector.target!.entity).some((filterEntity) =>
|
||||
filterSelectorEntities(
|
||||
filterEntity,
|
||||
entity,
|
||||
entitySources,
|
||||
entities,
|
||||
devices
|
||||
)
|
||||
filterSelectorEntities(filterEntity, entity, entitySources)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -915,12 +895,9 @@ export const filterSelectorDevices = (
|
||||
};
|
||||
|
||||
export const filterSelectorEntities = (
|
||||
filterEntity: EntitySelectorEntityFilter,
|
||||
filterEntity: EntitySelectorFilter,
|
||||
entity: HassEntity,
|
||||
entitySources?: EntitySources,
|
||||
entityRegistry?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"],
|
||||
deviceIntegrationLookup?: Record<string, Set<string>>
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
const {
|
||||
domain: filterDomain,
|
||||
@@ -928,7 +905,6 @@ export const filterSelectorEntities = (
|
||||
supported_features: filterSupportedFeature,
|
||||
unit_of_measurement: filterUnitOfMeasurement,
|
||||
integration: filterIntegration,
|
||||
device: filterDevice,
|
||||
} = filterEntity;
|
||||
|
||||
if (filterDomain) {
|
||||
@@ -975,24 +951,6 @@ 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
|
||||
@@ -1062,7 +1020,7 @@ export const handleLegacyDeviceSelector = (
|
||||
export const computeCreateDomains = (
|
||||
selector: EntitySelector | TargetSelector
|
||||
): undefined | string[] => {
|
||||
let entityFilters: EntitySelectorEntityFilter[] | undefined;
|
||||
let entityFilters: EntitySelectorFilter[] | undefined;
|
||||
|
||||
if ("target" in selector) {
|
||||
entityFilters = ensureArray(selector.target?.entity);
|
||||
@@ -1080,7 +1038,6 @@ export const computeCreateDomains = (
|
||||
!entityFilter.integration &&
|
||||
!entityFilter.device_class &&
|
||||
!entityFilter.supported_features &&
|
||||
!entityFilter.device &&
|
||||
entityFilter.domain
|
||||
? ensureArray(entityFilter.domain).filter((domain) =>
|
||||
isHelperDomain(domain)
|
||||
|
||||
@@ -250,7 +250,14 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// The check re-runs on the next reconnect; ignore transient failures.
|
||||
return;
|
||||
}
|
||||
if (!httpConfig.pending || this._httpPendingDialogOpen) {
|
||||
// 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
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._httpPendingDialogOpen = true;
|
||||
|
||||
@@ -179,6 +179,7 @@ class HaConfigSectionGeneral extends LitElement {
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.time_zone"
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
HTTP_CONFIG_FIELDS,
|
||||
saveHttpConfig,
|
||||
} from "../../../data/http";
|
||||
import type { HttpConfig } from "../../../data/http";
|
||||
import type {
|
||||
ActiveConfigType,
|
||||
HttpConfig,
|
||||
HttpConfigWithMeta,
|
||||
} from "../../../data/http";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -161,6 +165,11 @@ 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;
|
||||
@@ -201,6 +210,40 @@ 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`
|
||||
@@ -266,16 +309,30 @@ class HaConfigHttpForm extends LitElement {
|
||||
|
||||
private async _fetchConfig(): Promise<void> {
|
||||
try {
|
||||
// Pending is exclusively handled by the global confirm/revert dialog, so
|
||||
// the form only ever displays stable.
|
||||
const { stable } = await fetchHttpConfig(this.hass);
|
||||
const { stable, pending, active_config_type } = 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 => {
|
||||
|
||||
@@ -156,7 +156,6 @@ class MoveDatadiskDialog extends DirtyStateProviderMixin<MoveDatadiskFormState>(
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.storage.datadisk.select_device"
|
||||
)}
|
||||
.value=${this._selectedDevice}
|
||||
@selected=${this._selectDevice}
|
||||
.options=${this._disks.map((disk) => ({
|
||||
value: disk.id,
|
||||
|
||||
@@ -179,16 +179,6 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
|
||||
|
||||
const anyExposed = uiExposed || manExposedAlexa || manExposedGoogle;
|
||||
|
||||
const exposedToAlexa =
|
||||
showAssistants.includes("cloud.alexa") &&
|
||||
(alexaManual ? manExposedAlexa : this.exposed["cloud.alexa"]);
|
||||
const exposedToGoogle =
|
||||
showAssistants.includes("cloud.google_assistant") &&
|
||||
(googleManual
|
||||
? manExposedGoogle
|
||||
: this.exposed["cloud.google_assistant"]);
|
||||
const exposedToAssist = this.exposed.conversation;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item>
|
||||
<h3 slot="headline">
|
||||
@@ -285,24 +275,7 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
|
||||
</h3>
|
||||
|
||||
<p class="description">
|
||||
${[
|
||||
this.hass.localize("ui.dialogs.voice-settings.aliases_description"),
|
||||
exposedToAlexa &&
|
||||
this.hass.localize(
|
||||
"ui.dialogs.voice-settings.aliases_description_alexa"
|
||||
),
|
||||
exposedToGoogle &&
|
||||
this.hass.localize(
|
||||
"ui.dialogs.voice-settings.aliases_description_google"
|
||||
),
|
||||
exposedToAssist &&
|
||||
(exposedToAlexa || exposedToGoogle) &&
|
||||
this.hass.localize(
|
||||
"ui.dialogs.voice-settings.aliases_description_assist"
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
${this.hass.localize("ui.dialogs.voice-settings.aliases_description")}
|
||||
</p>
|
||||
|
||||
${
|
||||
|
||||
@@ -866,10 +866,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
.primary {
|
||||
display: flex;
|
||||
/* Baseline-align so a wrapped multi-line value keeps the subject and
|
||||
the trailing time on its first line, while a single-line entry
|
||||
stays aligned with the text. */
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
@@ -881,9 +878,9 @@ class HaLogbookEntry extends LitElement {
|
||||
.primary-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Wrap long entries onto multiple lines instead of truncating them to
|
||||
a single line with an ellipsis. */
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.primary > .subject {
|
||||
@@ -908,12 +905,13 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
|
||||
.value {
|
||||
/* Don't shrink: the subject absorbs truncation so a short state stays
|
||||
whole. A long value wraps within its max-width instead of being cut
|
||||
off. */
|
||||
/* Don't shrink: the subject absorbs all truncation so a short state
|
||||
stays whole. max-width still caps a long one. */
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
array,
|
||||
@@ -12,15 +12,14 @@ import {
|
||||
optional,
|
||||
string,
|
||||
} from "superstruct";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { ValueChangedEvent } from "../../../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { ClockCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
|
||||
@@ -65,13 +64,17 @@ export class HuiClockCardEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardEditor
|
||||
{
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _config?: ClockCardConfig;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc) =>
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
clockStyle: ClockCardConfig["clock_style"],
|
||||
ticks: ClockCardConfig["ticks"],
|
||||
showSeconds: boolean | undefined
|
||||
) =>
|
||||
[
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
@@ -111,122 +114,124 @@ export class HuiClockCardEditor
|
||||
ui_clock_date_format: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
hidden: {
|
||||
field: "clock_style",
|
||||
operator: "not_eq",
|
||||
value: "digital",
|
||||
},
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["auto", ...Object.values(TimeFormat)].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "border",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
),
|
||||
},
|
||||
default: false,
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ticks",
|
||||
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
),
|
||||
},
|
||||
default: "hour",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["none", "quarter", "hour", "minute"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "seconds_motion",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "show_seconds", operator: "not_eq", value: true },
|
||||
],
|
||||
},
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
),
|
||||
},
|
||||
default: "continuous",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["continuous", "tick"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "face_style",
|
||||
hidden: {
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "clock_style", operator: "not_eq", value: "analog" },
|
||||
{ field: "ticks", value: "none" },
|
||||
],
|
||||
},
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
),
|
||||
},
|
||||
default: "markers",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["markers", "numbers_upright", "roman"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
...(clockStyle === "digital"
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["auto", ...Object.values(TimeFormat)].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_formats.${value}`
|
||||
),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: clockStyle === "analog"
|
||||
? ([
|
||||
{
|
||||
name: "border",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
),
|
||||
},
|
||||
default: false,
|
||||
selector: {
|
||||
boolean: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ticks",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
),
|
||||
},
|
||||
default: "hour",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["none", "quarter", "hour", "minute"].map(
|
||||
(value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.${value}.description`
|
||||
),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...(showSeconds
|
||||
? ([
|
||||
{
|
||||
name: "seconds_motion",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
),
|
||||
},
|
||||
default: "continuous",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ["continuous", "tick"].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
...(ticks !== "none"
|
||||
? ([
|
||||
{
|
||||
name: "face_style",
|
||||
description: {
|
||||
suffix: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
),
|
||||
},
|
||||
default: "markers",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: [
|
||||
"markers",
|
||||
"numbers_upright",
|
||||
"roman",
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.label`
|
||||
),
|
||||
description: localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.${value}.description`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{ name: "time_zone", selector: { timezone: {} } },
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
@@ -260,14 +265,20 @@ export class HuiClockCardEditor
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config) {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data(this._config)}
|
||||
.schema=${this._schema(this._localize)}
|
||||
.schema=${this._schema(
|
||||
this.hass.localize,
|
||||
this._data(this._config).clock_style,
|
||||
this._data(this._config).ticks,
|
||||
this._data(this._config).show_seconds
|
||||
)}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -316,43 +327,51 @@ export class HuiClockCardEditor
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "title":
|
||||
return this._localize("ui.panel.lovelace.editor.card.generic.title");
|
||||
return this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.card.generic.title"
|
||||
);
|
||||
case "clock_style":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.clock_style`
|
||||
);
|
||||
case "clock_size":
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.clock_size`);
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.clock_size`
|
||||
);
|
||||
case "time_format":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_format`
|
||||
);
|
||||
case "time_zone":
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.time_zone`);
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.time_zone`
|
||||
);
|
||||
case "show_seconds":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.show_seconds`
|
||||
);
|
||||
case "no_background":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.no_background`
|
||||
);
|
||||
case "date_format":
|
||||
return this._localize(`ui.panel.lovelace.editor.card.clock.date.label`);
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.label`
|
||||
);
|
||||
case "border":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.label`
|
||||
);
|
||||
case "ticks":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.label`
|
||||
);
|
||||
case "seconds_motion":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.label`
|
||||
);
|
||||
case "face_style":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.label`
|
||||
);
|
||||
default:
|
||||
@@ -365,23 +384,23 @@ export class HuiClockCardEditor
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "date_format":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.description`
|
||||
);
|
||||
case "border":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.border.description`
|
||||
);
|
||||
case "ticks":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.ticks.description`
|
||||
);
|
||||
case "seconds_motion":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
|
||||
);
|
||||
case "face_style":
|
||||
return this._localize(
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.face_style.description`
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -54,65 +54,6 @@ 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
|
||||
@@ -129,6 +70,65 @@ 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,6 +176,11 @@ 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[])
|
||||
: [];
|
||||
@@ -183,7 +188,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>
|
||||
@@ -278,7 +283,9 @@ export class HuiHistoryGraphCardEditor
|
||||
) as HistoryGraphCardConfig;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
switch (schema.name) {
|
||||
case "show_names":
|
||||
case "logarithmic_scale":
|
||||
|
||||
@@ -92,6 +92,7 @@ export class HuiTileCardEditor
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
entityId: string | undefined,
|
||||
hideState: boolean,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
[
|
||||
@@ -143,25 +144,31 @@ export class HuiTileCardEditor
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
...(!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: "content_layout",
|
||||
required: true,
|
||||
@@ -286,7 +293,12 @@ export class HuiTileCardEditor
|
||||
this._config.state_content
|
||||
);
|
||||
|
||||
const schema = this._schema(this.hass.localize, entityId, showTimeFormat);
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
entityId,
|
||||
this._config.hide_state ?? false,
|
||||
showTimeFormat
|
||||
);
|
||||
|
||||
const vertical = this._config.vertical ?? false;
|
||||
|
||||
|
||||
@@ -419,6 +419,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: area.icon || "mdi:shape-square-rounded-plus",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home-area.no_devices_title"
|
||||
|
||||
@@ -151,6 +151,7 @@ export class HomeOtherDevicesViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:check-all",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home-other-devices.all_organized_title"
|
||||
|
||||
@@ -483,6 +483,7 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_title"
|
||||
|
||||
@@ -84,6 +84,7 @@ export class OriginalStatesViewStrategy extends ReactiveElement {
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.original-states.empty_state_title"
|
||||
|
||||
@@ -2025,12 +2025,9 @@
|
||||
"voice-settings": {
|
||||
"expose_header": "Expose",
|
||||
"aliases_header": "Aliases",
|
||||
"aliases_description": "Aliases are the names voice assistants use for this entity.",
|
||||
"aliases_description_assist": "Assist uses all aliases equally.",
|
||||
"aliases_description_alexa": "Amazon Alexa uses only the first alias.",
|
||||
"aliases_description_google": "Google Assistant uses the first alias as the main name and the rest as alternatives.",
|
||||
"aliases_description": "Aliases are alternative names to call your entity. Only supported by Assist and Google Assistant.",
|
||||
"aliases_no_unique_id": "Aliases are not supported for entities without a unique ID. See the {faq_link} for more detail.",
|
||||
"entity_name_alias_description": "Default name. When enabled, it is used as the first alias.",
|
||||
"entity_name_alias_description": "Default name. Disable it if you want your voice assistants to ignore it and just use aliases.",
|
||||
"ask_pin": "Ask for PIN",
|
||||
"manual_config": "Managed in configuration.yaml",
|
||||
"unsupported": "Unsupported",
|
||||
@@ -8687,6 +8684,10 @@
|
||||
"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.",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
cleanupMediaTitle,
|
||||
MediaPlayerEntityFeature,
|
||||
} from "../data/media-player";
|
||||
import { cleanupMediaTitle } from "../data/media-player";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export default class MediaPlayerEntity {
|
||||
@@ -78,60 +75,51 @@ export default class MediaPlayerEntity {
|
||||
}
|
||||
|
||||
get supportsPause() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PAUSE);
|
||||
return supportsFeature(this.stateObj, 1);
|
||||
}
|
||||
|
||||
get supportsVolumeSet() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_SET);
|
||||
return supportsFeature(this.stateObj, 4);
|
||||
}
|
||||
|
||||
get supportsVolumeMute() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_MUTE);
|
||||
return supportsFeature(this.stateObj, 8);
|
||||
}
|
||||
|
||||
get supportsPreviousTrack() {
|
||||
return supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
);
|
||||
return supportsFeature(this.stateObj, 16);
|
||||
}
|
||||
|
||||
get supportsNextTrack() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.NEXT_TRACK);
|
||||
return supportsFeature(this.stateObj, 32);
|
||||
}
|
||||
|
||||
get supportsTurnOn() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.TURN_ON);
|
||||
return supportsFeature(this.stateObj, 128);
|
||||
}
|
||||
|
||||
get supportsTurnOff() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.TURN_OFF);
|
||||
return supportsFeature(this.stateObj, 256);
|
||||
}
|
||||
|
||||
get supportsPlayMedia() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PLAY_MEDIA);
|
||||
return supportsFeature(this.stateObj, 512);
|
||||
}
|
||||
|
||||
get supportsVolumeButtons() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.VOLUME_STEP);
|
||||
return supportsFeature(this.stateObj, 1024);
|
||||
}
|
||||
|
||||
get supportsSelectSource() {
|
||||
return supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
);
|
||||
return supportsFeature(this.stateObj, 2048);
|
||||
}
|
||||
|
||||
get supportsSelectSoundMode() {
|
||||
return supportsFeature(
|
||||
this.stateObj,
|
||||
MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
);
|
||||
return supportsFeature(this.stateObj, 65536);
|
||||
}
|
||||
|
||||
get supportsPlay() {
|
||||
return supportsFeature(this.stateObj, MediaPlayerEntityFeature.PLAY);
|
||||
return supportsFeature(this.stateObj, 16384);
|
||||
}
|
||||
|
||||
get primaryTitle() {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user