20240927.0 (#22138)

This commit is contained in:
Bram Kragten 2024-09-27 17:28:42 +02:00 committed by GitHub
commit 394d8ddd6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1677 additions and 747 deletions

9
demo/src/stubs/config.ts Normal file
View File

@ -0,0 +1,9 @@
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockConfig = (hass: MockHomeAssistant) => {
hass.mockWS("validate_config", () => ({
actions: { valid: true },
conditions: { valid: true },
triggers: { valid: true },
}));
};

6
demo/src/stubs/tags.ts Normal file
View File

@ -0,0 +1,6 @@
import { Tag } from "../../../src/data/tag";
import { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockTags = (hass: MockHomeAssistant) => {
hass.mockWS("tag/list", () => [{ id: "my-tag", name: "My Tag" }] as Tag[]);
};

View File

@ -58,6 +58,12 @@ const triggers = [
command: ["Turn on the lights", "Turn the lights on"], command: ["Turn on the lights", "Turn the lights on"],
}, },
{ trigger: "event", event_type: "homeassistant_started" }, { trigger: "event", event_type: "homeassistant_started" },
{
triggers: [
{ trigger: "state", entity_id: "light.kitchen", to: "on" },
{ trigger: "state", entity_id: "light.kitchen", to: "off" },
],
},
]; ];
const initialTrigger: Trigger = { const initialTrigger: Trigger = {

View File

@ -8,6 +8,9 @@ import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry"; import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry"; import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor"; import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockConfig } from "../../../../demo/src/stubs/config";
import { mockTags } from "../../../../demo/src/stubs/tags";
import { mockAuth } from "../../../../demo/src/stubs/auth";
import type { Trigger } from "../../../../src/data/automation"; import type { Trigger } from "../../../../src/data/automation";
import { HaGeolocationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-geo_location"; import { HaGeolocationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-geo_location";
import { HaEventTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-event"; import { HaEventTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-event";
@ -26,6 +29,7 @@ import { HaStateTrigger } from "../../../../src/panels/config/automation/trigger
import { HaMQTTTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-mqtt"; import { HaMQTTTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-mqtt";
import "../../../../src/panels/config/automation/trigger/ha-automation-trigger"; import "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
import { HaConversationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-conversation"; import { HaConversationTrigger } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-conversation";
import { HaTriggerList } from "../../../../src/panels/config/automation/trigger/types/ha-automation-trigger-list";
const SCHEMAS: { name: string; triggers: Trigger[] }[] = [ const SCHEMAS: { name: string; triggers: Trigger[] }[] = [
{ {
@ -116,6 +120,10 @@ const SCHEMAS: { name: string; triggers: Trigger[] }[] = [
}, },
], ],
}, },
{
name: "Trigger list",
triggers: [{ ...HaTriggerList.defaultConfig }],
},
]; ];
@customElement("demo-automation-editor-trigger") @customElement("demo-automation-editor-trigger")
@ -135,6 +143,9 @@ export class DemoAutomationEditorTrigger extends LitElement {
mockDeviceRegistry(hass); mockDeviceRegistry(hass);
mockAreaRegistry(hass); mockAreaRegistry(hass);
mockHassioSupervisor(hass); mockHassioSupervisor(hass);
mockConfig(hass);
mockTags(hass);
mockAuth(hass);
} }
protected render(): TemplateResult { protected render(): TemplateResult {

View File

@ -15,6 +15,7 @@ import { LocalizeFunc } from "../../../src/common/translations/localize";
import "../../../src/components/ha-checkbox"; import "../../../src/components/ha-checkbox";
import "../../../src/components/ha-formfield"; import "../../../src/components/ha-formfield";
import "../../../src/components/ha-textfield"; import "../../../src/components/ha-textfield";
import "../../../src/components/ha-password-field";
import "../../../src/components/ha-radio"; import "../../../src/components/ha-radio";
import type { HaRadio } from "../../../src/components/ha-radio"; import type { HaRadio } from "../../../src/components/ha-radio";
import { import {
@ -261,23 +262,21 @@ export class SupervisorBackupContent extends LitElement {
: ""} : ""}
${this.backupHasPassword ${this.backupHasPassword
? html` ? html`
<ha-textfield <ha-password-field
.label=${this._localize("password")} .label=${this._localize("password")}
type="password"
name="backupPassword" name="backupPassword"
.value=${this.backupPassword} .value=${this.backupPassword}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}
> >
</ha-textfield> </ha-password-field>
${!this.backup ${!this.backup
? html`<ha-textfield ? html`<ha-password-field
.label=${this._localize("confirm_password")} .label=${this._localize("confirm_password")}
type="password"
name="confirmBackupPassword" name="confirmBackupPassword"
.value=${this.confirmBackupPassword} .value=${this.confirmBackupPassword}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}
> >
</ha-textfield>` </ha-password-field>`
: ""} : ""}
` `
: ""} : ""}

View File

@ -13,10 +13,12 @@ import "../../../../src/components/ha-circular-progress";
import "../../../../src/components/ha-dialog"; import "../../../../src/components/ha-dialog";
import "../../../../src/components/ha-expansion-panel"; import "../../../../src/components/ha-expansion-panel";
import "../../../../src/components/ha-formfield"; import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-textfield";
import "../../../../src/components/ha-header-bar"; import "../../../../src/components/ha-header-bar";
import "../../../../src/components/ha-icon-button"; import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-password-field";
import "../../../../src/components/ha-radio"; import "../../../../src/components/ha-radio";
import "../../../../src/components/ha-textfield";
import type { HaTextField } from "../../../../src/components/ha-textfield";
import { extractApiErrorMessage } from "../../../../src/data/hassio/common"; import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
import { import {
AccessPoints, AccessPoints,
@ -34,7 +36,6 @@ import { HassDialog } from "../../../../src/dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../src/resources/styles"; import { haStyleDialog } from "../../../../src/resources/styles";
import type { HomeAssistant } from "../../../../src/types"; import type { HomeAssistant } from "../../../../src/types";
import { HassioNetworkDialogParams } from "./show-dialog-network"; import { HassioNetworkDialogParams } from "./show-dialog-network";
import type { HaTextField } from "../../../../src/components/ha-textfield";
const IP_VERSIONS = ["ipv4", "ipv6"]; const IP_VERSIONS = ["ipv4", "ipv6"];
@ -246,9 +247,8 @@ export class DialogHassioNetwork
${this._wifiConfiguration.auth === "wpa-psk" || ${this._wifiConfiguration.auth === "wpa-psk" ||
this._wifiConfiguration.auth === "wep" this._wifiConfiguration.auth === "wep"
? html` ? html`
<ha-textfield <ha-password-field
class="flex-auto" class="flex-auto"
type="password"
id="psk" id="psk"
.label=${this.supervisor.localize( .label=${this.supervisor.localize(
"dialog.network.wifi_password" "dialog.network.wifi_password"
@ -256,7 +256,7 @@ export class DialogHassioNetwork
version="wifi" version="wifi"
@change=${this._handleInputValueChangedWifi} @change=${this._handleInputValueChangedWifi}
> >
</ha-textfield> </ha-password-field>
` `
: ""} : ""}
` `

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20240926.0" version = "20240927.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"

View File

@ -30,6 +30,10 @@ export class HaFormExpendable extends LitElement implements HaFormElement {
options?: { path?: string[] } options?: { path?: string[] }
) => string; ) => string;
@property({ attribute: false }) public localizeValue?: (
key: string
) => string;
private _renderDescription() { private _renderDescription() {
const description = this.computeHelper?.(this.schema); const description = this.computeHelper?.(this.schema);
return description ? html`<p>${description}</p>` : nothing; return description ? html`<p>${description}</p>` : nothing;
@ -86,6 +90,7 @@ export class HaFormExpendable extends LitElement implements HaFormElement {
.disabled=${this.disabled} .disabled=${this.disabled}
.computeLabel=${this._computeLabel} .computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper} .computeHelper=${this._computeHelper}
.localizeValue=${this.localizeValue}
></ha-form> ></ha-form>
</div> </div>
</ha-expansion-panel> </ha-expansion-panel>

View File

@ -35,6 +35,10 @@ export class HaFormGrid extends LitElement implements HaFormElement {
schema: HaFormSchema schema: HaFormSchema
) => string; ) => string;
@property({ attribute: false }) public localizeValue?: (
key: string
) => string;
public async focus() { public async focus() {
await this.updateComplete; await this.updateComplete;
this.renderRoot.querySelector("ha-form")?.focus(); this.renderRoot.querySelector("ha-form")?.focus();
@ -65,6 +69,7 @@ export class HaFormGrid extends LitElement implements HaFormElement {
.disabled=${this.disabled} .disabled=${this.disabled}
.computeLabel=${this.computeLabel} .computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper} .computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form> ></ha-form>
` `
)} )}

View File

@ -163,6 +163,7 @@ export class HaForm extends LitElement implements HaFormElement {
localize: this.hass?.localize, localize: this.hass?.localize,
computeLabel: this.computeLabel, computeLabel: this.computeLabel,
computeHelper: this.computeHelper, computeHelper: this.computeHelper,
localizeValue: this.localizeValue,
context: this._generateContext(item), context: this._generateContext(item),
...this.getFormProperties(), ...this.getFormProperties(),
})} })}

View File

@ -0,0 +1,58 @@
import { css, CSSResultGroup, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
type HeadingBadgeType = "text" | "button";
@customElement("ha-heading-badge")
export class HaBadge extends LitElement {
@property() public type: HeadingBadgeType = "text";
protected render() {
return html`
<div
class="heading-badge"
role=${ifDefined(this.type === "button" ? "button" : undefined)}
tabindex=${ifDefined(this.type === "button" ? "0" : undefined)}
>
<slot name="icon"></slot>
<slot></slot>
</div>
`;
}
static get styles(): CSSResultGroup {
return css`
:host {
color: var(--secondary-text-color);
}
[role="button"] {
cursor: pointer;
}
.heading-badge {
display: flex;
flex-direction: row;
white-space: nowrap;
align-items: center;
gap: 3px;
font-family: Roboto;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.1px;
--mdc-icon-size: 14px;
}
::slotted([slot="icon"]) {
--ha-icon-display: block;
color: var(--icon-color, inherit);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-heading-badge": HaBadge;
}
}

View File

@ -0,0 +1,160 @@
import { TextAreaCharCounter } from "@material/mwc-textfield/mwc-textfield-base";
import { mdiEye, mdiEyeOff } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { HomeAssistant } from "../types";
import "./ha-icon-button";
import "./ha-textfield";
@customElement("ha-password-field")
export class HaPasswordField extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public invalid?: boolean;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public icon = false;
@property({ type: Boolean }) public iconTrailing = false;
@property() public autocomplete?: string;
@property() public autocorrect?: string;
@property({ attribute: "input-spellcheck" })
public inputSpellcheck?: string;
@property({ type: String }) value = "";
@property({ type: String }) placeholder = "";
@property({ type: String }) label = "";
@property({ type: Boolean, reflect: true }) disabled = false;
@property({ type: Boolean }) required = false;
@property({ type: Number }) minLength = -1;
@property({ type: Number }) maxLength = -1;
@property({ type: Boolean, reflect: true }) outlined = false;
@property({ type: String }) helper = "";
@property({ type: Boolean }) validateOnInitialRender = false;
@property({ type: String }) validationMessage = "";
@property({ type: Boolean }) autoValidate = false;
@property({ type: String }) pattern = "";
@property({ type: Number }) size: number | null = null;
@property({ type: Boolean }) helperPersistent = false;
@property({ type: Boolean }) charCounter: boolean | TextAreaCharCounter =
false;
@property({ type: Boolean }) endAligned = false;
@property({ type: String }) prefix = "";
@property({ type: String }) suffix = "";
@property({ type: String }) name = "";
@property({ type: String, attribute: "input-mode" })
inputMode!: string;
@property({ type: Boolean }) readOnly = false;
@property({ type: String }) autocapitalize = "";
@state() private _unmaskedPassword = false;
protected render() {
return html`<ha-textfield
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.icon=${this.icon}
.iconTrailing=${this.iconTrailing}
.autocomplete=${this.autocomplete}
.autocorrect=${this.autocorrect}
.inputSpellcheck=${this.inputSpellcheck}
.value=${this.value}
.placeholder=${this.placeholder}
.label=${this.label}
.disabled=${this.disabled}
.required=${this.required}
.minLength=${this.minLength}
.maxLength=${this.maxLength}
.outlined=${this.outlined}
.helper=${this.helper}
.validateOnInitialRender=${this.validateOnInitialRender}
.validationMessage=${this.validationMessage}
.autoValidate=${this.autoValidate}
.pattern=${this.pattern}
.size=${this.size}
.helperPersistent=${this.helperPersistent}
.charCounter=${this.charCounter}
.endAligned=${this.endAligned}
.prefix=${this.prefix}
.name=${this.name}
.inputMode=${this.inputMode}
.readOnly=${this.readOnly}
.autocapitalize=${this.autocapitalize}
.type=${this._unmaskedPassword ? "text" : "password"}
.suffix=${html`<div style="width: 24px"></div>`}
@input=${this._handleInputChange}
></ha-textfield>
<ha-icon-button
toggles
.label=${this.hass?.localize(
this._unmaskedPassword
? "ui.components.selectors.text.hide_password"
: "ui.components.selectors.text.show_password"
) || (this._unmaskedPassword ? "Hide password" : "Show password")}
@click=${this._toggleUnmaskedPassword}
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-icon-button>`;
}
private _toggleUnmaskedPassword(): void {
this._unmaskedPassword = !this._unmaskedPassword;
}
@eventOptions({ passive: true })
private _handleInputChange(ev) {
this.value = ev.target.value;
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-textfield {
width: 100%;
}
ha-icon-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-password-field": HaPasswordField;
}
}

View File

@ -6,7 +6,7 @@ import { mainWindow } from "../common/dom/get_main_window";
@customElement("ha-textfield") @customElement("ha-textfield")
export class HaTextField extends TextFieldBase { export class HaTextField extends TextFieldBase {
@property({ type: Boolean }) public invalid = false; @property({ type: Boolean }) public invalid?: boolean;
@property({ attribute: "error-message" }) public errorMessage?: string; @property({ attribute: "error-message" }) public errorMessage?: string;
@ -28,14 +28,24 @@ export class HaTextField extends TextFieldBase {
override updated(changedProperties: PropertyValues) { override updated(changedProperties: PropertyValues) {
super.updated(changedProperties); super.updated(changedProperties);
if ( if (
(changedProperties.has("invalid") && changedProperties.has("invalid") ||
(this.invalid || changedProperties.get("invalid") !== undefined)) ||
changedProperties.has("errorMessage") changedProperties.has("errorMessage")
) { ) {
this.setCustomValidity( this.setCustomValidity(
this.invalid ? this.errorMessage || "Invalid" : "" this.invalid
? this.errorMessage || this.validationMessage || "Invalid"
: ""
); );
this.reportValidity(); if (
this.invalid ||
this.validateOnInitialRender ||
(changedProperties.has("invalid") &&
changedProperties.get("invalid") !== undefined)
) {
// Only report validity if the field is invalid or the invalid state has changed from
// true to false to prevent setting empty required fields to invalid on first render
this.reportValidity();
}
} }
if (changedProperties.has("autocomplete")) { if (changedProperties.has("autocomplete")) {
if (this.autocomplete) { if (this.autocomplete) {

View File

@ -94,7 +94,7 @@ export class HatScriptGraph extends LitElement {
@focus=${this.selectNode(config, path)} @focus=${this.selectNode(config, path)}
?active=${this.selected === path} ?active=${this.selected === path}
.iconPath=${mdiAsterisk} .iconPath=${mdiAsterisk}
.notEnabled=${config.enabled === false} .notEnabled=${"enabled" in config && config.enabled === false}
.error=${this.trace.trace[path]?.some((tr) => tr.error)} .error=${this.trace.trace[path]?.some((tr) => tr.error)}
tabindex=${track ? "0" : "-1"} tabindex=${track ? "0" : "-1"}
></hat-graph-node> ></hat-graph-node>

View File

@ -206,7 +206,8 @@ export type Trigger =
| TemplateTrigger | TemplateTrigger
| EventTrigger | EventTrigger
| DeviceTrigger | DeviceTrigger
| CalendarTrigger; | CalendarTrigger
| TriggerList;
interface BaseCondition { interface BaseCondition {
condition: string; condition: string;
@ -426,6 +427,10 @@ export const migrateAutomationTrigger = (
return trigger.map(migrateAutomationTrigger) as Trigger[]; return trigger.map(migrateAutomationTrigger) as Trigger[];
} }
if ("triggers" in trigger && trigger.triggers) {
trigger.triggers = migrateAutomationTrigger(trigger.triggers);
}
if ("platform" in trigger) { if ("platform" in trigger) {
if (!("trigger" in trigger)) { if (!("trigger" in trigger)) {
// @ts-ignore // @ts-ignore
@ -437,7 +442,7 @@ export const migrateAutomationTrigger = (
}; };
export const flattenTriggers = ( export const flattenTriggers = (
triggers: undefined | Trigger | (Trigger | TriggerList)[] triggers: undefined | Trigger | Trigger[]
): Trigger[] => { ): Trigger[] => {
if (!triggers) { if (!triggers) {
return []; return [];
@ -448,7 +453,7 @@ export const flattenTriggers = (
ensureArray(triggers).forEach((t) => { ensureArray(triggers).forEach((t) => {
if ("triggers" in t) { if ("triggers" in t) {
if (t.triggers) { if (t.triggers) {
flatTriggers.push(...ensureArray(t.triggers)); flatTriggers.push(...flattenTriggers(t.triggers));
} }
} else { } else {
flatTriggers.push(t); flatTriggers.push(t);

View File

@ -22,6 +22,7 @@ import {
formatListWithAnds, formatListWithAnds,
formatListWithOrs, formatListWithOrs,
} from "../common/string/format-list"; } from "../common/string/format-list";
import { isTriggerList } from "./trigger";
const triggerTranslationBaseKey = const triggerTranslationBaseKey =
"ui.panel.config.automation.editor.triggers.type"; "ui.panel.config.automation.editor.triggers.type";
@ -98,6 +99,20 @@ const tryDescribeTrigger = (
entityRegistry: EntityRegistryEntry[], entityRegistry: EntityRegistryEntry[],
ignoreAlias = false ignoreAlias = false
) => { ) => {
if (isTriggerList(trigger)) {
const triggers = ensureArray(trigger.triggers);
if (!triggers || triggers.length === 0) {
return hass.localize(
`${triggerTranslationBaseKey}.list.description.no_trigger`
);
}
const count = triggers.length;
return hass.localize(`${triggerTranslationBaseKey}.list.description.full`, {
count: count,
});
}
if (trigger.alias && !ignoreAlias) { if (trigger.alias && !ignoreAlias) {
return trigger.alias; return trigger.alias;
} }

View File

@ -50,7 +50,7 @@ export interface StatisticsMetaData {
export const STATISTIC_TYPES: StatisticsValidationResult["type"][] = [ export const STATISTIC_TYPES: StatisticsValidationResult["type"][] = [
"entity_not_recorded", "entity_not_recorded",
"entity_no_longer_recorded", "entity_no_longer_recorded",
"unsupported_state_class", "state_class_removed",
"units_changed", "units_changed",
"no_state", "no_state",
]; ];
@ -59,7 +59,7 @@ export type StatisticsValidationResult =
| StatisticsValidationResultNoState | StatisticsValidationResultNoState
| StatisticsValidationResultEntityNotRecorded | StatisticsValidationResultEntityNotRecorded
| StatisticsValidationResultEntityNoLongerRecorded | StatisticsValidationResultEntityNoLongerRecorded
| StatisticsValidationResultUnsupportedStateClass | StatisticsValidationResultStateClassRemoved
| StatisticsValidationResultUnitsChanged; | StatisticsValidationResultUnitsChanged;
export interface StatisticsValidationResultNoState { export interface StatisticsValidationResultNoState {
@ -77,9 +77,9 @@ export interface StatisticsValidationResultEntityNotRecorded {
data: { statistic_id: string }; data: { statistic_id: string };
} }
export interface StatisticsValidationResultUnsupportedStateClass { export interface StatisticsValidationResultStateClassRemoved {
type: "unsupported_state_class"; type: "state_class_removed";
data: { statistic_id: string; state_class: string }; data: { statistic_id: string };
} }
export interface StatisticsValidationResultUnitsChanged { export interface StatisticsValidationResultUnitsChanged {

View File

@ -5,6 +5,7 @@ import {
mdiCodeBraces, mdiCodeBraces,
mdiDevices, mdiDevices,
mdiDotsHorizontal, mdiDotsHorizontal,
mdiFormatListBulleted,
mdiGestureDoubleTap, mdiGestureDoubleTap,
mdiMapClock, mdiMapClock,
mdiMapMarker, mdiMapMarker,
@ -21,7 +22,7 @@ import {
} from "@mdi/js"; } from "@mdi/js";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { AutomationElementGroup } from "./automation"; import { AutomationElementGroup, Trigger, TriggerList } from "./automation";
export const TRIGGER_ICONS = { export const TRIGGER_ICONS = {
calendar: mdiCalendar, calendar: mdiCalendar,
@ -41,6 +42,7 @@ export const TRIGGER_ICONS = {
webhook: mdiWebhook, webhook: mdiWebhook,
persistent_notification: mdiMessageAlert, persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius, zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
}; };
export const TRIGGER_GROUPS: AutomationElementGroup = { export const TRIGGER_GROUPS: AutomationElementGroup = {
@ -65,3 +67,6 @@ export const TRIGGER_GROUPS: AutomationElementGroup = {
}, },
}, },
} as const; } as const;
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
"triggers" in trigger;

View File

@ -8,7 +8,6 @@ export const AssistantSetupStyles = [
align-items: center; align-items: center;
text-align: center; text-align: center;
min-height: 300px; min-height: 300px;
max-width: 500px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@ -21,16 +20,27 @@ export const AssistantSetupStyles = [
} }
.content img { .content img {
width: 120px; width: 120px;
margin-top: 68px; }
margin-bottom: 68px; @media all and (max-width: 450px), all and (max-height: 500px) {
.content img {
margin-top: 68px;
margin-bottom: 68px;
}
} }
.footer { .footer {
width: 100%;
display: flex; display: flex;
width: 100%;
flex-direction: row;
justify-content: flex-end;
}
.footer.full-width {
flex-direction: column; flex-direction: column;
} }
.footer ha-button { .footer.full-width ha-button {
width: 100%; width: 100%;
} }
.footer.side-by-side {
justify-content: space-between;
}
`, `,
]; ];

View File

@ -1,5 +1,5 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import { mdiChevronLeft } from "@mdi/js"; import { mdiChevronLeft, mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@ -50,6 +50,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
private _previousSteps: STEP[] = []; private _previousSteps: STEP[] = [];
private _nextStep?: STEP;
public async showDialog( public async showDialog(
params: VoiceAssistantSetupDialogParams params: VoiceAssistantSetupDialogParams
): Promise<void> { ): Promise<void> {
@ -113,19 +115,38 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@closed=${this._dialogClosed} @closed=${this._dialogClosed}
.heading=${"Voice Satellite setup"} .heading=${"Voice Satellite setup"}
hideActions hideActions
escapeKeyAction
scrimClickAction
> >
<ha-dialog-header slot="heading"> <ha-dialog-header slot="heading">
${this._previousSteps.length ${this._previousSteps.length
? html`<ha-icon-button ? html`<ha-icon-button
slot="navigationIcon" slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close") ?? .label=${this.hass.localize("ui.common.back") ?? "Back"}
"Close"}
.path=${mdiChevronLeft} .path=${mdiChevronLeft}
@click=${this._goToPreviousStep} @click=${this._goToPreviousStep}
></ha-icon-button>` ></ha-icon-button>`
: this._step !== STEP.UPDATE
? html`<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close") ??
"Close"}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>`
: nothing}
${this._step === STEP.WAKEWORD ||
this._step === STEP.AREA ||
this._step === STEP.PIPELINE
? html`<ha-button
@click=${this._goToNextStep}
class="skip-btn"
slot="actionItems"
>Skip</ha-button
>`
: nothing} : nothing}
</ha-dialog-header> </ha-dialog-header>
<div class="content" @next-step=${this._nextStep}> <div class="content" @next-step=${this._goToNextStep}>
${this._step === STEP.UPDATE ${this._step === STEP.UPDATE
? html`<ha-voice-assistant-setup-step-update ? html`<ha-voice-assistant-setup-step-update
.hass=${this.hass} .hass=${this.hass}
@ -229,15 +250,21 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._step = this._previousSteps.pop()!; this._step = this._previousSteps.pop()!;
} }
private _nextStep(ev) { private _goToNextStep(ev) {
if (ev.detail?.updateConfig) { if (ev.detail?.updateConfig) {
this._fetchAssistConfiguration(); this._fetchAssistConfiguration();
} }
if (ev.detail?.nextStep) {
this._nextStep = ev.detail.nextStep;
}
if (!ev.detail?.noPrevious) { if (!ev.detail?.noPrevious) {
this._previousSteps.push(this._step); this._previousSteps.push(this._step);
} }
if (ev.detail?.step) { if (ev.detail?.step) {
this._step = ev.detail.step; this._step = ev.detail.step;
} else if (this._nextStep) {
this._step = this._nextStep;
this._nextStep = undefined;
} else { } else {
this._step += 1; this._step += 1;
} }
@ -250,6 +277,14 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
ha-dialog { ha-dialog {
--dialog-content-padding: 0; --dialog-content-padding: 0;
} }
@media all and (min-width: 450px) and (min-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: 560px;
--mdc-dialog-max-width: 560px;
--mdc-dialog-min-width: min(560px, 95vw);
--mdc-dialog-max-width: min(560px, 95vw);
}
}
ha-dialog-header { ha-dialog-header {
height: 56px; height: 56px;
} }
@ -258,6 +293,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
height: calc(100vh - 56px); height: calc(100vh - 56px);
} }
} }
.skip-btn {
margin-top: 6px;
}
`, `,
]; ];
} }
@ -270,7 +308,12 @@ declare global {
interface HASSDomEvents { interface HASSDomEvents {
"next-step": "next-step":
| { step?: STEP; updateConfig?: boolean; noPrevious?: boolean } | {
step?: STEP;
updateConfig?: boolean;
noPrevious?: boolean;
nextStep?: STEP;
}
| undefined; | undefined;
} }
} }

View File

@ -42,24 +42,7 @@ export class HaVoiceAssistantSetupStepAddons extends LitElement {
powerful device to run. If you device is not powerful enough, Home powerful device to run. If you device is not powerful enough, Home
Assistant cloud might be a better option. Assistant cloud might be a better option.
</p> </p>
<h3>Home Assistant Cloud:</h3> <h3>Raspberry Pi 4</h3>
<div class="messages-container cloud">
<div class="message user ${this._showFirst ? "show" : ""}">
${!this._showFirst ? "…" : "Turn on the lights in the bedroom"}
</div>
${this._showFirst
? html`<div class="timing user">0.2 seconds</div>`
: nothing}
${this._showFirst
? html` <div class="message hass ${this._showSecond ? "show" : ""}">
${!this._showSecond ? "…" : "Turned on the lights"}
</div>`
: nothing}
${this._showSecond
? html`<div class="timing hass">0.4 seconds</div>`
: nothing}
</div>
<h3>Raspberry Pi 4:</h3>
<div class="messages-container rpi"> <div class="messages-container rpi">
<div class="message user ${this._showThird ? "show" : ""}"> <div class="message user ${this._showThird ? "show" : ""}">
${!this._showThird ? "…" : "Turn on the lights in the bedroom"} ${!this._showThird ? "…" : "Turn on the lights in the bedroom"}
@ -76,8 +59,28 @@ export class HaVoiceAssistantSetupStepAddons extends LitElement {
? html`<div class="timing hass">5 seconds</div>` ? html`<div class="timing hass">5 seconds</div>`
: nothing} : nothing}
</div> </div>
<h3>Home Assistant Cloud</h3>
<div class="messages-container cloud">
<div class="message user ${this._showFirst ? "show" : ""}">
${!this._showFirst ? "…" : "Turn on the lights in the bedroom"}
</div>
${this._showFirst
? html`<div class="timing user">0.2 seconds</div>`
: nothing}
${this._showFirst
? html` <div class="message hass ${this._showSecond ? "show" : ""}">
${!this._showSecond ? "…" : "Turned on the lights"}
</div>`
: nothing}
${this._showSecond
? html`<div class="timing hass">0.4 seconds</div>`
: nothing}
</div>
</div> </div>
<div class="footer"> <div class="footer side-by-side">
<ha-button @click=${this._goToCloud}
>Try Home Assistant Cloud</ha-button
>
<a <a
href=${documentationUrl( href=${documentationUrl(
this.hass, this.hass,
@ -85,19 +88,14 @@ export class HaVoiceAssistantSetupStepAddons extends LitElement {
)} )}
target="_blank" target="_blank"
rel="noreferrer noopenner" rel="noreferrer noopenner"
@click=${this._close}
><ha-button unelevated
>Learn how to setup local assistant</ha-button
></a
>
<ha-button @click=${this._skip}
>I already have a local assistant</ha-button
> >
<ha-button @click=${this._skip} unelevated>Learn more</ha-button>
</a>
</div>`; </div>`;
} }
private _close() { private _goToCloud() {
fireEvent(this, "closed"); fireEvent(this, "next-step", { step: STEP.CLOUD });
} }
private _skip() { private _skip() {

View File

@ -28,7 +28,7 @@ export class HaVoiceAssistantSetupStepArea extends LitElement {
></ha-area-picker> ></ha-area-picker>
</div> </div>
<div class="footer"> <div class="footer">
<ha-button @click=${this._setArea}>Next</ha-button> <ha-button @click=${this._setArea} unelevated>Next</ha-button>
</div>`; </div>`;
} }

View File

@ -25,8 +25,8 @@ export class HaVoiceAssistantSetupStepChangeWakeWord extends LitElement {
<img src="/static/icons/casita/smiling.png" /> <img src="/static/icons/casita/smiling.png" />
<h1>Change wake word</h1> <h1>Change wake word</h1>
<p class="secondary"> <p class="secondary">
When you voice assistant knows where it is, it can better control the Some wake words are better for [your language] and voice than others.
devices around it. Please try them out.
</p> </p>
</div> </div>
<ha-md-list> <ha-md-list>
@ -72,6 +72,7 @@ export class HaVoiceAssistantSetupStepChangeWakeWord extends LitElement {
ha-md-list { ha-md-list {
width: 100%; width: 100%;
text-align: initial; text-align: initial;
margin-bottom: 24px;
} }
`, `,
]; ];

View File

@ -22,7 +22,7 @@ export class HaVoiceAssistantSetupStepCheck extends LitElement {
if ( if (
this._status === "success" && this._status === "success" &&
changedProperties.has("hass") && changedProperties.has("hass") &&
this.hass.states[this.assistEntityId!]?.state === "listening_wake_word" this.hass.states[this.assistEntityId!]?.state === "idle"
) { ) {
this._nextStep(); this._nextStep();
} }
@ -38,16 +38,13 @@ export class HaVoiceAssistantSetupStepCheck extends LitElement {
</p>` </p>`
: this._status === "timeout" : this._status === "timeout"
? html`<img src="/static/icons/casita/sad.png" /> ? html`<img src="/static/icons/casita/sad.png" />
<h1>Error</h1> <h1>Voice assistant can not connect to Home Assistant</h1>
<p class="secondary"> <p class="secondary">
Your device was unable to reach Home Assistant. Make sure you A good explanation what is happening and what action you should
have setup your take.
<a href="/config/network" @click=${this._close}
>Home Assistant URL's</a
>
correctly.
</p> </p>
<div class="footer"> <div class="footer">
<a href="#"><ha-button>Help me</ha-button></a>
<ha-button @click=${this._testConnection}>Retry</ha-button> <ha-button @click=${this._testConnection}>Retry</ha-button>
</div>` </div>`
: html`<img src="/static/icons/casita/loading.png" /> : html`<img src="/static/icons/casita/loading.png" />
@ -73,10 +70,6 @@ export class HaVoiceAssistantSetupStepCheck extends LitElement {
fireEvent(this, "next-step", { noPrevious: true }); fireEvent(this, "next-step", { noPrevious: true });
} }
private _close() {
fireEvent(this, "closed");
}
static styles = AssistantSetupStyles; static styles = AssistantSetupStyles;
} }

View File

@ -10,16 +10,24 @@ export class HaVoiceAssistantSetupStepCloud extends LitElement {
protected override render() { protected override render() {
return html`<div class="content"> return html`<div class="content">
<img src="/static/icons/casita/loving.png" /> <img src="/static/images/logo_nabu_casa.png" />
<h1>Home Assistant Cloud</h1> <h1>Supercharge your assistant with Home Assistant Cloud</h1>
<p class="secondary"> <p class="secondary">
With Home Assistant Cloud, you get the best results for your voice Speed up and take the load off your system by running your
assistant, sign up for a free trial now. text-to-speech and speech-to-text in our private and secure cloud.
Cloud also includes secure remote access to your system while
supporting the development of Home Assistant.
</p> </p>
</div> </div>
<div class="footer"> <div class="footer side-by-side">
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer noopenner"
><ha-button>Learn more</ha-button></a
>
<a href="/config/cloud/register" @click=${this._close} <a href="/config/cloud/register" @click=${this._close}
><ha-button>Start your free trial</ha-button></a ><ha-button unelevated>Try 1 month for free</ha-button></a
> >
</div>`; </div>`;
} }

View File

@ -92,7 +92,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
)} )}
rel="noreferrer noopenner" rel="noreferrer noopenner"
target="_blank" target="_blank"
@click=${this._close} @click=${this._skip}
> >
Use external system Use external system
<span slot="supporting-text" <span slot="supporting-text"
@ -221,12 +221,12 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
fireEvent(this, "next-step", { step: STEP.ADDONS }); fireEvent(this, "next-step", { step: STEP.ADDONS });
} }
private _nextStep(step?: STEP) { private _skip() {
fireEvent(this, "next-step", { step }); this._nextStep(STEP.SUCCESS);
} }
private _close() { private _nextStep(step?: STEP) {
fireEvent(this, "closed"); fireEvent(this, "next-step", { step });
} }
static styles = [ static styles = [

View File

@ -1,9 +1,9 @@
import { mdiCog, mdiMicrophone, mdiPlay } from "@mdi/js";
import { css, html, LitElement, nothing, PropertyValues } from "lit"; import { css, html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation"; import { stopPropagation } from "../../common/dom/stop_propagation";
import "../../components/ha-md-list-item"; import "../../components/ha-select";
import "../../components/ha-tts-voice-picker"; import "../../components/ha-tts-voice-picker";
import { import {
AssistPipeline, AssistPipeline,
@ -56,58 +56,78 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
} }
} }
private _activeWakeWord = memoizeOne(
(config: AssistSatelliteConfiguration | undefined) => {
if (!config) {
return "";
}
const activeId = config.active_wake_words[0];
return config.available_wake_words.find((ww) => ww.id === activeId)
?.wake_word;
}
);
protected override render() { protected override render() {
const pipelineEntity = this.assistConfiguration
? this.hass.states[this.assistConfiguration.pipeline_entity_id]
: undefined;
return html`<div class="content"> return html`<div class="content">
<img src="/static/icons/casita/loving.png" /> <img src="/static/icons/casita/loving.png" />
<h1>Ready to assist!</h1> <h1>Ready to assist!</h1>
<p class="secondary"> <p class="secondary">
Make your assistant more personal by customizing shizzle to the Your device is all ready to go! If you want to tweak some more
manizzle settings, you can change that below.
</p> </p>
<ha-md-list-item <div class="rows">
interactive <div class="row">
type="button" <ha-select
@click=${this._changeWakeWord} .label=${"Wake word"}
>
Change wake word
<span slot="supporting-text"
>${this._activeWakeWord(this.assistConfiguration)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<hui-select-entity-row
.hass=${this.hass}
._config=${{
entity: this.assistConfiguration?.pipeline_entity_id,
}}
></hui-select-entity-row>
${this._ttsSettings
? html`<ha-tts-voice-picker
.hass=${this.hass}
required
.engineId=${this._ttsSettings.engine}
.language=${this._ttsSettings.language}
.value=${this._ttsSettings.voice}
@value-changed=${this._voicePicked}
@closed=${stopPropagation} @closed=${stopPropagation}
></ha-tts-voice-picker>` fixedMenuPosition
: nothing} naturalMenuWidth
.value=${this.assistConfiguration?.active_wake_words[0]}
>
${this.assistConfiguration?.available_wake_words.map(
(wakeword) =>
html`<ha-list-item .value=${wakeword.id}>
${wakeword.wake_word}
</ha-list-item>`
)}
</ha-select>
<ha-button @click=${this._testWakeWord}>
<ha-svg-icon slot="icon" .path=${mdiMicrophone}></ha-svg-icon>
Test
</ha-button>
</div>
<div class="row">
<ha-select
.label=${"Assistant"}
@closed=${stopPropagation}
.value=${pipelineEntity?.state}
fixedMenuPosition
naturalMenuWidth
>
${pipelineEntity?.attributes.options.map(
(pipeline) =>
html`<ha-list-item .value=${pipeline}>
${this.hass.formatEntityState(pipelineEntity, pipeline)}
</ha-list-item>`
)}
</ha-select>
<ha-button @click=${this._openPipeline}>
<ha-svg-icon slot="icon" .path=${mdiCog}></ha-svg-icon>
Edit
</ha-button>
</div>
${this._ttsSettings
? html`<div class="row">
<ha-tts-voice-picker
.hass=${this.hass}
.engineId=${this._ttsSettings.engine}
.language=${this._ttsSettings.language}
.value=${this._ttsSettings.voice}
@value-changed=${this._voicePicked}
@closed=${stopPropagation}
></ha-tts-voice-picker>
<ha-button @click=${this._testTts}>
<ha-svg-icon slot="icon" .path=${mdiPlay}></ha-svg-icon>
Try
</ha-button>
</div>`
: nothing}
</div>
</div> </div>
<div class="footer"> <div class="footer">
<ha-button @click=${this._openPipeline}
>Change assistant settings</ha-button
>
<ha-button @click=${this._close} unelevated>Done</ha-button> <ha-button @click=${this._close} unelevated>Done</ha-button>
</div>`; </div>`;
} }
@ -160,6 +180,9 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
...pipeline, ...pipeline,
tts_voice: ev.detail.value, tts_voice: ev.detail.value,
}); });
}
private _testTts() {
this._announce("Hello, how can I help you?"); this._announce("Hello, how can I help you?");
} }
@ -170,8 +193,11 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
await assistSatelliteAnnounce(this.hass, this.assistEntityId, message); await assistSatelliteAnnounce(this.hass, this.assistEntityId, message);
} }
private _changeWakeWord() { private _testWakeWord() {
fireEvent(this, "next-step", { step: STEP.CHANGE_WAKEWORD }); fireEvent(this, "next-step", {
step: STEP.WAKEWORD,
nextStep: STEP.SUCCESS,
});
} }
private async _openPipeline() { private async _openPipeline() {
@ -209,12 +235,28 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
text-align: initial; text-align: initial;
} }
ha-tts-voice-picker { ha-tts-voice-picker {
margin-top: 16px;
display: block; display: block;
} }
.footer { .footer {
margin-top: 24px; margin-top: 24px;
} }
.rows {
gap: 16px;
display: flex;
flex-direction: column;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.row > *:first-child {
flex: 1;
margin-right: 4px;
}
.row ha-button {
width: 82px;
}
`, `,
]; ];
} }

View File

@ -17,6 +17,11 @@ export class HaVoiceAssistantSetupStepUpdate extends LitElement {
protected override willUpdate(changedProperties: PropertyValues): void { protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties); super.willUpdate(changedProperties);
if (!this.updateEntityId) {
this._nextStep();
return;
}
if (changedProperties.has("hass") && this.updateEntityId) { if (changedProperties.has("hass") && this.updateEntityId) {
const oldHass = changedProperties.get("hass") as this["hass"] | undefined; const oldHass = changedProperties.get("hass") as this["hass"] | undefined;
if (oldHass) { if (oldHass) {
@ -32,16 +37,9 @@ export class HaVoiceAssistantSetupStepUpdate extends LitElement {
} }
} }
if (!changedProperties.has("updateEntityId")) { if (changedProperties.has("updateEntityId")) {
return; this._tryUpdate();
} }
if (!this.updateEntityId) {
this._nextStep();
return;
}
this._tryUpdate();
} }
protected override render() { protected override render() {

View File

@ -58,7 +58,7 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
const entityState = this.hass.states[this.assistEntityId]; const entityState = this.hass.states[this.assistEntityId];
if (entityState.state !== "listening_wake_word") { if (entityState.state !== "idle") {
return html`<ha-circular-progress indeterminate></ha-circular-progress>`; return html`<ha-circular-progress indeterminate></ha-circular-progress>`;
} }
@ -80,7 +80,7 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
To make sure the wake word works for you. To make sure the wake word works for you.
</p>`} </p>`}
</div> </div>
<div class="footer"> <div class="footer full-width">
<ha-button @click=${this._changeWakeWord}>Change wake word</ha-button> <ha-button @click=${this._changeWakeWord}>Change wake word</ha-button>
</div>`; </div>`;
} }

View File

@ -5,12 +5,13 @@ import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-circular-progress"; import "../../../components/ha-circular-progress";
import "../../../components/ha-combo-box"; import "../../../components/ha-combo-box";
import { createCloseHeading } from "../../../components/ha-dialog"; import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-markdown"; import "../../../components/ha-markdown";
import "../../../components/ha-password-field";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import "../../../components/ha-button";
import { import {
ApplicationCredential, ApplicationCredential,
ApplicationCredentialsConfig, ApplicationCredentialsConfig,
@ -208,11 +209,10 @@ export class DialogAddApplicationCredential extends LitElement {
)} )}
helperPersistent helperPersistent
></ha-textfield> ></ha-textfield>
<ha-textfield <ha-password-field
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret" "ui.panel.config.application_credentials.editor.client_secret"
)} )}
type="password"
name="clientSecret" name="clientSecret"
.value=${this._clientSecret} .value=${this._clientSecret}
required required
@ -222,7 +222,7 @@ export class DialogAddApplicationCredential extends LitElement {
"ui.panel.config.application_credentials.editor.client_secret_helper" "ui.panel.config.application_credentials.editor.client_secret_helper"
)} )}
helperPersistent helperPersistent
></ha-textfield> ></ha-password-field>
</div> </div>
${this._loading ${this._loading
? html` ? html`

View File

@ -8,13 +8,21 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form"; import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types"; import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-select"; import "../../../../../components/ha-select";
import type { import {
AutomationConfig, flattenTriggers,
Trigger, type AutomationConfig,
TriggerCondition, type Trigger,
type TriggerCondition,
} from "../../../../../data/automation"; } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
const getTriggersIds = (triggers: Trigger[]): string[] => {
const triggerIds = flattenTriggers(triggers)
.map((t) => ("id" in t ? t.id : undefined))
.filter(Boolean) as string[];
return Array.from(new Set(triggerIds));
};
@customElement("ha-automation-condition-trigger") @customElement("ha-automation-condition-trigger")
export class HaTriggerCondition extends LitElement { export class HaTriggerCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -23,7 +31,7 @@ export class HaTriggerCondition extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@state() private _triggers: Trigger[] = []; @state() private _triggerIds: string[] = [];
private _unsub?: UnsubscribeFunc; private _unsub?: UnsubscribeFunc;
@ -35,14 +43,14 @@ export class HaTriggerCondition extends LitElement {
} }
private _schema = memoizeOne( private _schema = memoizeOne(
(triggers: Trigger[]) => (triggerIds: string[]) =>
[ [
{ {
name: "id", name: "id",
selector: { selector: {
select: { select: {
multiple: true, multiple: true,
options: triggers.map((trigger) => trigger.id!), options: triggerIds,
}, },
}, },
required: true, required: true,
@ -65,13 +73,13 @@ export class HaTriggerCondition extends LitElement {
} }
protected render() { protected render() {
if (!this._triggers.length) { if (!this._triggerIds.length) {
return this.hass.localize( return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers" "ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
); );
} }
const schema = this._schema(this._triggers); const schema = this._schema(this._triggerIds);
return html` return html`
<ha-form <ha-form
@ -93,11 +101,8 @@ export class HaTriggerCondition extends LitElement {
); );
private _automationUpdated(config?: AutomationConfig) { private _automationUpdated(config?: AutomationConfig) {
const seenIds = new Set(); this._triggerIds = config?.triggers
this._triggers = config?.trigger ? getTriggersIds(ensureArray(config.triggers))
? ensureArray(config.trigger).filter(
(t) => t.id && (seenIds.has(t.id) ? false : seenIds.add(t.id))
)
: []; : [];
} }
@ -106,12 +111,12 @@ export class HaTriggerCondition extends LitElement {
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (typeof newValue.id === "string") { if (typeof newValue.id === "string") {
if (!this._triggers.some((trigger) => trigger.id === newValue.id)) { if (!this._triggerIds.some((id) => id === newValue.id)) {
newValue.id = ""; newValue.id = "";
} }
} else if (Array.isArray(newValue.id)) { } else if (Array.isArray(newValue.id)) {
newValue.id = newValue.id.filter((id) => newValue.id = newValue.id.filter((_id) =>
this._triggers.some((trigger) => trigger.id === id) this._triggerIds.some((id) => id === _id)
); );
if (!newValue.id.length) { if (!newValue.id.length) {
newValue.id = ""; newValue.id = "";

View File

@ -78,7 +78,7 @@ export class HaManualAutomationEditor extends LitElement {
></ha-icon-button> ></ha-icon-button>
</a> </a>
</div> </div>
${!ensureArray(this.config.trigger)?.length ${!ensureArray(this.config.triggers)?.length
? html`<p> ? html`<p>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.triggers.description" "ui.panel.config.automation.editor.triggers.description"

View File

@ -29,6 +29,7 @@ import { classMap } from "lit/directives/class-map";
import { storage } from "../../../../common/decorators/storage"; import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive"; import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { stopPropagation } from "../../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors"; import { handleStructError } from "../../../../common/structs/handle-errors";
@ -50,7 +51,7 @@ import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config"; import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context"; import { fullEntitiesContext } from "../../../../data/context";
import { EntityRegistryEntry } from "../../../../data/entity_registry"; import { EntityRegistryEntry } from "../../../../data/entity_registry";
import { TRIGGER_ICONS } from "../../../../data/trigger"; import { TRIGGER_ICONS, isTriggerList } from "../../../../data/trigger";
import { import {
showAlertDialog, showAlertDialog,
showConfirmationDialog, showConfirmationDialog,
@ -64,6 +65,7 @@ import "./types/ha-automation-trigger-device";
import "./types/ha-automation-trigger-event"; import "./types/ha-automation-trigger-event";
import "./types/ha-automation-trigger-geo_location"; import "./types/ha-automation-trigger-geo_location";
import "./types/ha-automation-trigger-homeassistant"; import "./types/ha-automation-trigger-homeassistant";
import "./types/ha-automation-trigger-list";
import "./types/ha-automation-trigger-mqtt"; import "./types/ha-automation-trigger-mqtt";
import "./types/ha-automation-trigger-numeric_state"; import "./types/ha-automation-trigger-numeric_state";
import "./types/ha-automation-trigger-persistent_notification"; import "./types/ha-automation-trigger-persistent_notification";
@ -75,7 +77,6 @@ import "./types/ha-automation-trigger-time";
import "./types/ha-automation-trigger-time_pattern"; import "./types/ha-automation-trigger-time_pattern";
import "./types/ha-automation-trigger-webhook"; import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone"; import "./types/ha-automation-trigger-zone";
import { preventDefault } from "../../../../common/dom/prevent_default";
export interface TriggerElement extends LitElement { export interface TriggerElement extends LitElement {
trigger: Trigger; trigger: Trigger;
@ -87,7 +88,7 @@ export const handleChangeEvent = (element: TriggerElement, ev: CustomEvent) => {
if (!name) { if (!name) {
return; return;
} }
const newVal = (ev.target as any)?.value; const newVal = ev.detail?.value || (ev.currentTarget as any)?.value;
if ((element.trigger[name] || "") === newVal) { if ((element.trigger[name] || "") === newVal) {
return; return;
@ -146,15 +147,17 @@ export default class HaAutomationTriggerRow extends LitElement {
protected render() { protected render() {
if (!this.trigger) return nothing; if (!this.trigger) return nothing;
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
const supported = const supported =
customElements.get(`ha-automation-trigger-${this.trigger.trigger}`) !== customElements.get(`ha-automation-trigger-${type}`) !== undefined;
undefined;
const yamlMode = this._yamlMode || !supported; const yamlMode = this._yamlMode || !supported;
const showId = "id" in this.trigger || this._requestShowId; const showId = "id" in this.trigger || this._requestShowId;
return html` return html`
<ha-card outlined> <ha-card outlined>
${this.trigger.enabled === false ${"enabled" in this.trigger && this.trigger.enabled === false
? html` ? html`
<div class="disabled-bar"> <div class="disabled-bar">
${this.hass.localize( ${this.hass.localize(
@ -168,7 +171,7 @@ export default class HaAutomationTriggerRow extends LitElement {
<h3 slot="header"> <h3 slot="header">
<ha-svg-icon <ha-svg-icon
class="trigger-icon" class="trigger-icon"
.path=${TRIGGER_ICONS[this.trigger.trigger]} .path=${TRIGGER_ICONS[type]}
></ha-svg-icon> ></ha-svg-icon>
${describeTrigger(this.trigger, this.hass, this._entityReg)} ${describeTrigger(this.trigger, this.hass, this._entityReg)}
</h3> </h3>
@ -188,14 +191,20 @@ export default class HaAutomationTriggerRow extends LitElement {
.path=${mdiDotsVertical} .path=${mdiDotsVertical}
></ha-icon-button> ></ha-icon-button>
<mwc-list-item graphic="icon" .disabled=${this.disabled}> <mwc-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename" "ui.panel.config.automation.editor.triggers.rename"
)} )}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</mwc-list-item> </mwc-list-item>
<mwc-list-item graphic="icon" .disabled=${this.disabled}> <mwc-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id" "ui.panel.config.automation.editor.triggers.edit_id"
)} )}
@ -274,8 +283,11 @@ export default class HaAutomationTriggerRow extends LitElement {
<li divider role="separator"></li> <li divider role="separator"></li>
<mwc-list-item graphic="icon" .disabled=${this.disabled}> <mwc-list-item
${this.trigger.enabled === false graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${"enabled" in this.trigger && this.trigger.enabled === false
? this.hass.localize( ? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable" "ui.panel.config.automation.editor.actions.enable"
) )
@ -284,7 +296,8 @@ export default class HaAutomationTriggerRow extends LitElement {
)} )}
<ha-svg-icon <ha-svg-icon
slot="graphic" slot="graphic"
.path=${this.trigger.enabled === false .path=${"enabled" in this.trigger &&
this.trigger.enabled === false
? mdiPlayCircleOutline ? mdiPlayCircleOutline
: mdiStopCircleOutline} : mdiStopCircleOutline}
></ha-svg-icon> ></ha-svg-icon>
@ -308,7 +321,8 @@ export default class HaAutomationTriggerRow extends LitElement {
<div <div
class=${classMap({ class=${classMap({
"card-content": true, "card-content": true,
disabled: this.trigger.enabled === false, disabled:
"enabled" in this.trigger && this.trigger.enabled === false,
})} })}
> >
${this._warnings ${this._warnings
@ -336,7 +350,7 @@ export default class HaAutomationTriggerRow extends LitElement {
? html` ? html`
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.triggers.unsupported_platform", "ui.panel.config.automation.editor.triggers.unsupported_platform",
{ platform: this.trigger.trigger } { platform: type }
)} )}
` `
: ""} : ""}
@ -348,7 +362,7 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-yaml-editor> ></ha-yaml-editor>
` `
: html` : html`
${showId ${showId && !isTriggerList(this.trigger)
? html` ? html`
<ha-textfield <ha-textfield
.label=${this.hass.localize( .label=${this.hass.localize(
@ -365,15 +379,12 @@ export default class HaAutomationTriggerRow extends LitElement {
@ui-mode-not-available=${this._handleUiModeNotAvailable} @ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged} @value-changed=${this._onUiChanged}
> >
${dynamicElement( ${dynamicElement(`ha-automation-trigger-${type}`, {
`ha-automation-trigger-${this.trigger.trigger}`, hass: this.hass,
{ trigger: this.trigger,
hass: this.hass, disabled: this.disabled,
trigger: this.trigger, path: this.path,
disabled: this.disabled, })}
path: this.path,
}
)}
</div> </div>
`} `}
</div> </div>
@ -546,6 +557,7 @@ export default class HaAutomationTriggerRow extends LitElement {
} }
private _onDisable() { private _onDisable() {
if (isTriggerList(this.trigger)) return;
const enabled = !(this.trigger.enabled ?? true); const enabled = !(this.trigger.enabled ?? true);
const value = { ...this.trigger, enabled }; const value = { ...this.trigger, enabled };
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
@ -555,7 +567,9 @@ export default class HaAutomationTriggerRow extends LitElement {
} }
private _idChanged(ev: CustomEvent) { private _idChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
const newId = (ev.target as any).value; const newId = (ev.target as any).value;
if (newId === (this.trigger.id ?? "")) { if (newId === (this.trigger.id ?? "")) {
return; return;
} }
@ -583,6 +597,7 @@ export default class HaAutomationTriggerRow extends LitElement {
} }
private _onUiChanged(ev: CustomEvent) { private _onUiChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation(); ev.stopPropagation();
const value = { const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}), ...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
@ -617,6 +632,7 @@ export default class HaAutomationTriggerRow extends LitElement {
} }
private async _renameTrigger(): Promise<void> { private async _renameTrigger(): Promise<void> {
if (isTriggerList(this.trigger)) return;
const alias = await showPromptDialog(this, { const alias = await showPromptDialog(this, {
title: this.hass.localize( title: this.hass.localize(
"ui.panel.config.automation.editor.triggers.change_alias" "ui.panel.config.automation.editor.triggers.change_alias"

View File

@ -18,7 +18,11 @@ import "../../../../components/ha-button";
import "../../../../components/ha-button-menu"; import "../../../../components/ha-button-menu";
import "../../../../components/ha-sortable"; import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import { AutomationClipboard, Trigger } from "../../../../data/automation"; import {
AutomationClipboard,
Trigger,
TriggerList,
} from "../../../../data/automation";
import { HomeAssistant, ItemPath } from "../../../../types"; import { HomeAssistant, ItemPath } from "../../../../types";
import { import {
PASTE_VALUE, PASTE_VALUE,
@ -26,6 +30,7 @@ import {
} from "../show-add-automation-element-dialog"; } from "../show-add-automation-element-dialog";
import "./ha-automation-trigger-row"; import "./ha-automation-trigger-row";
import type HaAutomationTriggerRow from "./ha-automation-trigger-row"; import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
import { isTriggerList } from "../../../../data/trigger";
@customElement("ha-automation-trigger") @customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends LitElement { export default class HaAutomationTrigger extends LitElement {
@ -130,7 +135,11 @@ export default class HaAutomationTrigger extends LitElement {
showAddAutomationElementDialog(this, { showAddAutomationElementDialog(this, {
type: "trigger", type: "trigger",
add: this._addTrigger, add: this._addTrigger,
clipboardItem: this._clipboard?.trigger?.trigger, clipboardItem: !this._clipboard?.trigger
? undefined
: isTriggerList(this._clipboard.trigger)
? "list"
: this._clipboard?.trigger?.trigger,
}); });
} }
@ -139,7 +148,7 @@ export default class HaAutomationTrigger extends LitElement {
if (value === PASTE_VALUE) { if (value === PASTE_VALUE) {
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger)); triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
} else { } else {
const trigger = value as Trigger["trigger"]; const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
const elClass = customElements.get( const elClass = customElements.get(
`ha-automation-trigger-${trigger}` `ha-automation-trigger-${trigger}`
) as CustomElementConstructor & { ) as CustomElementConstructor & {

View File

@ -0,0 +1,54 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import type { TriggerList } from "../../../../../data/automation";
import type { HomeAssistant, ItemPath } from "../../../../../types";
import "../ha-automation-trigger";
import {
handleChangeEvent,
TriggerElement,
} from "../ha-automation-trigger-row";
@customElement("ha-automation-trigger-list")
export class HaTriggerList extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: TriggerList;
@property({ attribute: false }) public path?: ItemPath;
@property({ type: Boolean }) public disabled = false;
public static get defaultConfig(): TriggerList {
return {
triggers: [],
};
}
protected render() {
const triggers = ensureArray(this.trigger.triggers);
return html`
<ha-automation-trigger
.path=${[...(this.path ?? []), "triggers"]}
.triggers=${triggers}
.hass=${this.hass}
.disabled=${this.disabled}
.name=${"triggers"}
@value-changed=${this._valueChanged}
></ha-automation-trigger>
`;
}
private _valueChanged(ev: CustomEvent): void {
handleChangeEvent(this, ev);
}
static styles = css``;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-trigger-list": HaTriggerList;
}
}

View File

@ -99,24 +99,32 @@ export class CloudForgotPassword extends LitElement {
this._requestInProgress = true; this._requestInProgress = true;
try { const doResetPassword = async (username: string) => {
await cloudForgotPassword(this.hass, email); try {
// @ts-ignore await cloudForgotPassword(this.hass, username);
fireEvent(this, "email-changed", { value: email }); // @ts-ignore
this._requestInProgress = false; fireEvent(this, "email-changed", { value: username });
// @ts-ignore this._requestInProgress = false;
fireEvent(this, "cloud-done", { // @ts-ignore
flashMessage: this.hass.localize( fireEvent(this, "cloud-done", {
"ui.panel.config.cloud.forgot_password.check_your_email" flashMessage: this.hass.localize(
), "ui.panel.config.cloud.forgot_password.check_your_email"
}); ),
} catch (err: any) { });
this._requestInProgress = false; } catch (err: any) {
this._error = this._requestInProgress = false;
err && err.body && err.body.message const errCode = err && err.body && err.body.code;
? err.body.message if (errCode === "usernotfound" && username !== username.toLowerCase()) {
: "Unknown error"; await doResetPassword(username.toLowerCase());
} } else {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
};
await doResetPassword(email);
} }
static get styles() { static get styles() {

View File

@ -22,6 +22,7 @@ import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import "../../ha-config-section"; import "../../ha-config-section";
import { setAssistPipelinePreferred } from "../../../../data/assist_pipeline"; import { setAssistPipelinePreferred } from "../../../../data/assist_pipeline";
import "../../../../components/ha-password-field";
@customElement("cloud-login") @customElement("cloud-login")
export class CloudLogin extends LitElement { export class CloudLogin extends LitElement {
@ -142,14 +143,13 @@ export class CloudLogin extends LitElement {
"ui.panel.config.cloud.login.email_error_msg" "ui.panel.config.cloud.login.email_error_msg"
)} )}
></ha-textfield> ></ha-textfield>
<ha-textfield <ha-password-field
id="password" id="password"
name="password" name="password"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.cloud.login.password" "ui.panel.config.cloud.login.password"
)} )}
.value=${this._password || ""} .value=${this._password || ""}
type="password"
autocomplete="current-password" autocomplete="current-password"
required required
minlength="8" minlength="8"
@ -158,7 +158,7 @@ export class CloudLogin extends LitElement {
.validationMessage=${this.hass.localize( .validationMessage=${this.hass.localize(
"ui.panel.config.cloud.login.password_error_msg" "ui.panel.config.cloud.login.password_error_msg"
)} )}
></ha-textfield> ></ha-password-field>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<ha-progress-button <ha-progress-button
@ -227,53 +227,61 @@ export class CloudLogin extends LitElement {
this._requestInProgress = true; this._requestInProgress = true;
try { const doLogin = async (username: string) => {
const result = await cloudLogin(this.hass, email, password); try {
fireEvent(this, "ha-refresh-cloud-status"); const result = await cloudLogin(this.hass, username, password);
this.email = ""; fireEvent(this, "ha-refresh-cloud-status");
this._password = ""; this.email = "";
if (result.cloud_pipeline) { this._password = "";
if ( if (result.cloud_pipeline) {
await showConfirmationDialog(this, { if (
title: this.hass.localize( await showConfirmationDialog(this, {
"ui.panel.config.cloud.login.cloud_pipeline_title" title: this.hass.localize(
), "ui.panel.config.cloud.login.cloud_pipeline_title"
text: this.hass.localize( ),
"ui.panel.config.cloud.login.cloud_pipeline_text" text: this.hass.localize(
), "ui.panel.config.cloud.login.cloud_pipeline_text"
}) ),
) { })
setAssistPipelinePreferred(this.hass, result.cloud_pipeline); ) {
setAssistPipelinePreferred(this.hass, result.cloud_pipeline);
}
}
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.alert_password_change_required"
),
});
navigate("/config/cloud/forgot-password");
return;
}
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doLogin(username.toLowerCase());
return;
} }
}
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.login.alert_password_change_required"
),
});
navigate("/config/cloud/forgot-password");
return;
}
this._password = ""; this._password = "";
this._requestInProgress = false; this._requestInProgress = false;
if (errCode === "UserNotConfirmed") { if (errCode === "UserNotConfirmed") {
this._error = this.hass.localize( this._error = this.hass.localize(
"ui.panel.config.cloud.login.alert_email_confirm_necessary" "ui.panel.config.cloud.login.alert_email_confirm_necessary"
); );
} else { } else {
this._error = this._error =
err && err.body && err.body.message err && err.body && err.body.message
? err.body.message ? err.body.message
: "Unknown error"; : "Unknown error";
}
emailField.focus();
} }
};
emailField.focus(); await doLogin(email);
}
} }
private _handleRegister() { private _handleRegister() {

View File

@ -11,6 +11,7 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import "../../ha-config-section"; import "../../ha-config-section";
import "../../../../components/ha-password-field";
@customElement("cloud-register") @customElement("cloud-register")
export class CloudRegister extends LitElement { export class CloudRegister extends LitElement {
@ -145,14 +146,13 @@ export class CloudRegister extends LitElement {
"ui.panel.config.cloud.register.email_error_msg" "ui.panel.config.cloud.register.email_error_msg"
)} )}
></ha-textfield> ></ha-textfield>
<ha-textfield <ha-password-field
id="password" id="password"
name="password" name="password"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.cloud.register.password" "ui.panel.config.cloud.register.password"
)} )}
.value=${this._password} .value=${this._password}
type="password"
autocomplete="new-password" autocomplete="new-password"
minlength="8" minlength="8"
required required
@ -160,7 +160,7 @@ export class CloudRegister extends LitElement {
validationMessage=${this.hass.localize( validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg" "ui.panel.config.cloud.register.password_error_msg"
)} )}
></ha-textfield> ></ha-password-field>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<ha-progress-button <ha-progress-button
@ -197,9 +197,6 @@ export class CloudRegister extends LitElement {
const emailField = this._emailField; const emailField = this._emailField;
const passwordField = this._passwordField; const passwordField = this._passwordField;
const email = emailField.value;
const password = passwordField.value;
if (!emailField.reportValidity()) { if (!emailField.reportValidity()) {
passwordField.reportValidity(); passwordField.reportValidity();
emailField.focus(); emailField.focus();
@ -211,6 +208,9 @@ export class CloudRegister extends LitElement {
return; return;
} }
const email = emailField.value.toLowerCase();
const password = passwordField.value;
this._requestInProgress = true; this._requestInProgress = true;
try { try {
@ -229,22 +229,31 @@ export class CloudRegister extends LitElement {
private async _handleResendVerifyEmail() { private async _handleResendVerifyEmail() {
const emailField = this._emailField; const emailField = this._emailField;
const email = emailField.value;
if (!emailField.reportValidity()) { if (!emailField.reportValidity()) {
emailField.focus(); emailField.focus();
return; return;
} }
try { const email = emailField.value;
await cloudResendVerification(this.hass, email);
this._verificationEmailSent(email); const doResend = async (username: string) => {
} catch (err: any) { try {
this._error = await cloudResendVerification(this.hass, username);
err && err.body && err.body.message this._verificationEmailSent(username);
? err.body.message } catch (err: any) {
: "Unknown error"; const errCode = err && err.body && err.body.code;
} if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doResend(username.toLowerCase());
} else {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
};
await doResend(email);
} }
private _verificationEmailSent(email: string) { private _verificationEmailSent(email: string) {

View File

@ -17,6 +17,30 @@ import type { DeviceAction } from "../../../ha-config-device-page";
import { showMatterManageFabricsDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-manage-fabrics"; import { showMatterManageFabricsDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-manage-fabrics";
import { navigate } from "../../../../../../common/navigate"; import { navigate } from "../../../../../../common/navigate";
export const getMatterDeviceDefaultActions = (
el: HTMLElement,
hass: HomeAssistant,
device: DeviceRegistryEntry
): DeviceAction[] => {
if (device.via_device_id !== null) {
// only show device actions for top level nodes (so not bridged)
return [];
}
const actions: DeviceAction[] = [];
actions.push({
label: hass.localize("ui.panel.config.matter.device_actions.ping_device"),
icon: mdiChatQuestion,
action: () =>
showMatterPingNodeDialog(el, {
device_id: device.id,
}),
});
return actions;
};
export const getMatterDeviceActions = async ( export const getMatterDeviceActions = async (
el: HTMLElement, el: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
@ -75,14 +99,5 @@ export const getMatterDeviceActions = async (
}); });
} }
actions.push({
label: hass.localize("ui.panel.config.matter.device_actions.ping_device"),
icon: mdiChatQuestion,
action: () =>
showMatterPingNodeDialog(el, {
device_id: device.id,
}),
});
return actions; return actions;
}; };

View File

@ -1119,12 +1119,17 @@ export class HaConfigDevicePage extends LitElement {
const matter = await import( const matter = await import(
"./device-detail/integration-elements/matter/device-actions" "./device-detail/integration-elements/matter/device-actions"
); );
const actions = await matter.getMatterDeviceActions( const defaultActions = matter.getMatterDeviceDefaultActions(
this, this,
this.hass, this.hass,
device device
); );
deviceActions.push(...actions); deviceActions.push(...defaultActions);
// load matter device actions async to avoid an UI with 0 actions when the matter integration needs very long to get node diagnostics
matter.getMatterDeviceActions(this, this.hass, device).then((actions) => {
this._deviceActions = [...actions, ...(this._deviceActions || [])];
});
} }
this._deviceActions = deviceActions; this._deviceActions = deviceActions;

View File

@ -14,6 +14,11 @@ import "../../../components/ha-circular-progress";
import "../../../components/ha-expansion-panel"; import "../../../components/ha-expansion-panel";
import "../../../components/ha-formfield"; import "../../../components/ha-formfield";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-password-field";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import { extractApiErrorMessage } from "../../../data/hassio/common"; import { extractApiErrorMessage } from "../../../data/hassio/common";
import { import {
AccessPoints, AccessPoints,
@ -29,10 +34,6 @@ import {
} from "../../../dialogs/generic/show-dialog-box"; } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { showIPDetailDialog } from "./show-ip-detail-dialog"; import { showIPDetailDialog } from "./show-ip-detail-dialog";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
const IP_VERSIONS = ["ipv4", "ipv6"]; const IP_VERSIONS = ["ipv4", "ipv6"];
@ -214,8 +215,7 @@ export class HassioNetwork extends LitElement {
${this._wifiConfiguration.auth === "wpa-psk" || ${this._wifiConfiguration.auth === "wpa-psk" ||
this._wifiConfiguration.auth === "wep" this._wifiConfiguration.auth === "wep"
? html` ? html`
<ha-textfield <ha-password-field
type="password"
id="psk" id="psk"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.network.supervisor.wifi_password" "ui.panel.config.network.supervisor.wifi_password"
@ -223,7 +223,7 @@ export class HassioNetwork extends LitElement {
.version=${"wifi"} .version=${"wifi"}
@change=${this._handleInputValueChangedWifi} @change=${this._handleInputValueChangedWifi}
> >
</ha-textfield> </ha-password-field>
` `
: ""} : ""}
` `

View File

@ -29,6 +29,7 @@ import {
import { haStyleDialog } from "../../../resources/styles"; import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant, ValueChangedEvent } from "../../../types"; import { HomeAssistant, ValueChangedEvent } from "../../../types";
import { AddUserDialogParams } from "./show-dialog-add-user"; import { AddUserDialogParams } from "./show-dialog-add-user";
import "../../../components/ha-password-field";
@customElement("dialog-add-user") @customElement("dialog-add-user")
export class DialogAddUser extends LitElement { export class DialogAddUser extends LitElement {
@ -87,6 +88,7 @@ export class DialogAddUser extends LitElement {
if (!this._params) { if (!this._params) {
return nothing; return nothing;
} }
return html` return html`
<ha-dialog <ha-dialog
open open
@ -130,34 +132,32 @@ export class DialogAddUser extends LitElement {
dialogInitialFocus dialogInitialFocus
></ha-textfield> ></ha-textfield>
<ha-textfield <ha-password-field
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.users.add_user.password" "ui.panel.config.users.add_user.password"
)} )}
type="password"
name="password" name="password"
.value=${this._password} .value=${this._password}
required required
@input=${this._handleValueChanged} @input=${this._handleValueChanged}
.validationMessage=${this.hass.localize("ui.common.error_required")} .validationMessage=${this.hass.localize("ui.common.error_required")}
></ha-textfield> ></ha-password-field>
<ha-textfield <ha-password-field
label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.users.add_user.password_confirm" "ui.panel.config.users.add_user.password_confirm"
)} )}
name="passwordConfirm" name="passwordConfirm"
.value=${this._passwordConfirm} .value=${this._passwordConfirm}
@input=${this._handleValueChanged} @input=${this._handleValueChanged}
required required
type="password"
.invalid=${this._password !== "" && .invalid=${this._password !== "" &&
this._passwordConfirm !== "" && this._passwordConfirm !== "" &&
this._passwordConfirm !== this._password} this._passwordConfirm !== this._password}
.validationMessage=${this.hass.localize( .errorMessage=${this.hass.localize(
"ui.panel.config.users.add_user.password_not_match" "ui.panel.config.users.add_user.password_not_match"
)} )}
></ha-textfield> ></ha-password-field>
<ha-settings-row> <ha-settings-row>
<span slot="heading"> <span slot="heading">
${this.hass.localize( ${this.hass.localize(
@ -311,7 +311,8 @@ export class DialogAddUser extends LitElement {
display: flex; display: flex;
padding: 8px 0; padding: 8px 0;
} }
ha-textfield { ha-textfield,
ha-password-field {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
} }

View File

@ -26,7 +26,7 @@ const FIX_ISSUES_ORDER = {
no_state: 0, no_state: 0,
entity_no_longer_recorded: 1, entity_no_longer_recorded: 1,
entity_not_recorded: 1, entity_not_recorded: 1,
unsupported_state_class: 2, state_class_removed: 2,
units_changed: 3, units_changed: 3,
}; };
@ -273,11 +273,9 @@ class HaPanelDevStatistics extends SubscribeMixin(LitElement) {
); );
if ( if (
result && result &&
[ ["no_state", "entity_no_longer_recorded", "state_class_removed"].includes(
"no_state", issue.type
"entity_no_longer_recorded", )
"unsupported_state_class",
].includes(issue.type)
) { ) {
this._deletedStatistics.add(issue.data.statistic_id); this._deletedStatistics.add(issue.data.statistic_id);
} }

View File

@ -103,31 +103,30 @@ export const fixStatisticsIssue = async (
await clearStatistics(hass, [issue.data.statistic_id]); await clearStatistics(hass, [issue.data.statistic_id]);
}, },
}); });
case "unsupported_state_class": case "state_class_removed":
return showConfirmationDialog(element, { return showConfirmationDialog(element, {
title: localize( title: localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.title" "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.title"
), ),
text: html`${localize( text: html`${localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.info_text_1", "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.info_text_1",
{ {
name: getStatisticLabel(hass, issue.data.statistic_id, undefined), name: getStatisticLabel(hass, issue.data.statistic_id, undefined),
statistic_id: issue.data.statistic_id, statistic_id: issue.data.statistic_id,
state_class: issue.data.state_class,
} }
)}<br /><br /> )}<br /><br />
${localize( ${localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.info_text_2" "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.info_text_2"
)} )}
<ul> <ul>
<li> <li>
${localize( ${localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.info_text_3" "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.info_text_3"
)} )}
</li> </li>
<li> <li>
${localize( ${localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.info_text_4" "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.info_text_4"
)} )}
<a <a
href="https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics" href="https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics"
@ -135,18 +134,18 @@ export const fixStatisticsIssue = async (
rel="noreferrer noopener" rel="noreferrer noopener"
> >
${localize( ${localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.info_text_4_link" "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.info_text_4_link"
)}</a )}</a
> >
</li> </li>
<li> <li>
${localize( ${localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.info_text_5" "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.info_text_5"
)} )}
</li> </li>
</ul> </ul>
${localize( ${localize(
"ui.panel.developer-tools.tabs.statistics.fix_issue.unsupported_state_class.info_text_6", "ui.panel.developer-tools.tabs.statistics.fix_issue.state_class_removed.info_text_6",
{ statistic_id: issue.data.statistic_id } { statistic_id: issue.data.statistic_id }
)}`, )}`,
confirmText: localize("ui.common.delete"), confirmText: localize("ui.common.delete"),

View File

@ -1,248 +0,0 @@
import { HassEntity } from "home-assistant-js-websocket";
import {
CSSResultGroup,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../../common/color/compute-color";
import {
hsv2rgb,
rgb2hex,
rgb2hsv,
} from "../../../../common/color/convert-color";
import { MediaQueriesListener } from "../../../../common/dom/media_query";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { stateActive } from "../../../../common/entity/state_active";
import { stateColorCss } from "../../../../common/entity/state_color";
import "../../../../components/ha-card";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-state-icon";
import { ActionHandlerEvent } from "../../../../data/lovelace/action_handler";
import "../../../../state-display/state-display";
import { HomeAssistant } from "../../../../types";
import { actionHandler } from "../../common/directives/action-handler-directive";
import { handleAction } from "../../common/handle-action";
import { hasAction } from "../../common/has-action";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../../common/validate-condition";
import { DEFAULT_CONFIG } from "../../editor/heading-entity/hui-heading-entity-editor";
import type { HeadingEntityConfig } from "../types";
@customElement("hui-heading-entity")
export class HuiHeadingEntity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: HeadingEntityConfig | string;
@property({ type: Boolean }) public preview = false;
private _listeners: MediaQueriesListener[] = [];
private _handleAction(ev: ActionHandlerEvent) {
const config: HeadingEntityConfig = {
tap_action: {
action: "none",
},
...this._config(this.config),
};
handleAction(this, this.hass!, config, ev.detail.action!);
}
private _config = memoizeOne(
(configOrString: HeadingEntityConfig | string): HeadingEntityConfig => {
const config =
typeof configOrString === "string"
? { entity: configOrString }
: configOrString;
return {
...DEFAULT_CONFIG,
tap_action: {
action: "none",
},
...config,
};
}
);
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
protected update(changedProps: PropertyValues<typeof this>): void {
super.update(changedProps);
if (changedProps.has("hass") || changedProps.has("preview")) {
this._updateVisibility();
}
}
private _updateVisibility(forceVisible?: boolean) {
const config = this._config(this.config);
const visible =
forceVisible ||
this.preview ||
!config.visibility ||
checkConditionsMet(config.visibility, this.hass);
this.toggleAttribute("hidden", !visible);
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
const config = this._config(this.config);
if (!config?.visibility) {
return;
}
const conditions = config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
}
);
}
private _computeStateColor = memoizeOne(
(entity: HassEntity, color?: string) => {
if (!color || color === "none") {
return undefined;
}
if (color === "state") {
// Use light color if the light support rgb
if (
computeDomain(entity.entity_id) === "light" &&
entity.attributes.rgb_color
) {
const hsvColor = rgb2hsv(entity.attributes.rgb_color);
// Modify the real rgb color for better contrast
if (hsvColor[1] < 0.4) {
// Special case for very light color (e.g: white)
if (hsvColor[1] < 0.1) {
hsvColor[2] = 225;
} else {
hsvColor[1] = 0.4;
}
}
return rgb2hex(hsv2rgb(hsvColor));
}
// Fallback to state color
return stateColorCss(entity);
}
if (color) {
// Use custom color if active
return stateActive(entity) ? computeCssColor(color) : undefined;
}
return color;
}
);
protected render() {
const config = this._config(this.config);
const stateObj = this.hass!.states[config.entity];
if (!stateObj) {
return nothing;
}
const color = this._computeStateColor(stateObj, config.color);
const actionable = hasAction(config.tap_action);
const style = {
"--color": color,
};
return html`
<div
class="entity"
@action=${this._handleAction}
.actionHandler=${actionHandler()}
role=${ifDefined(actionable ? "button" : undefined)}
tabindex=${ifDefined(actionable ? "0" : undefined)}
style=${styleMap(style)}
>
${config.show_icon
? html`
<ha-state-icon
.hass=${this.hass}
.icon=${config.icon}
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing}
${config.show_state
? html`
<state-display
.hass=${this.hass}
.stateObj=${stateObj}
.content=${config.state_content}
></state-display>
`
: nothing}
</div>
`;
}
static get styles(): CSSResultGroup {
return css`
[role="button"] {
cursor: pointer;
}
.entity {
display: flex;
flex-direction: row;
white-space: nowrap;
align-items: center;
gap: 3px;
color: var(--secondary-text-color);
font-family: Roboto;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: 0.1px;
--mdc-icon-size: 14px;
--state-inactive-color: initial;
}
.entity ha-state-icon {
--ha-icon-display: block;
color: var(--color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-heading-entity": HuiHeadingEntity;
}
}

View File

@ -11,14 +11,25 @@ import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action"; import { hasAction } from "../common/has-action";
import "../heading-badges/hui-heading-badge";
import type { import type {
LovelaceCard, LovelaceCard,
LovelaceCardEditor, LovelaceCardEditor,
LovelaceLayoutOptions, LovelaceLayoutOptions,
} from "../types"; } from "../types";
import "./heading/hui-heading-entity";
import type { HeadingCardConfig } from "./types"; import type { HeadingCardConfig } from "./types";
export const migrateHeadingCardConfig = (
config: HeadingCardConfig
): HeadingCardConfig => {
const newConfig = { ...config };
if (newConfig.entities) {
newConfig.badges = [...(newConfig.badges || []), ...newConfig.entities];
delete newConfig.entities;
}
return newConfig;
};
@customElement("hui-heading-card") @customElement("hui-heading-card")
export class HuiHeadingCard extends LitElement implements LovelaceCard { export class HuiHeadingCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> { public static async getConfigElement(): Promise<LovelaceCardEditor> {
@ -45,7 +56,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
tap_action: { tap_action: {
action: "none", action: "none",
}, },
...config, ...migrateHeadingCardConfig(config),
}; };
} }
@ -73,6 +84,8 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
const style = this._config.heading_style || "title"; const style = this._config.heading_style || "title";
const badges = this._config.badges;
return html` return html`
<ha-card> <ha-card>
<div class="container"> <div class="container">
@ -91,17 +104,17 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
: nothing} : nothing}
${actionable ? html`<ha-icon-next></ha-icon-next>` : nothing} ${actionable ? html`<ha-icon-next></ha-icon-next>` : nothing}
</div> </div>
${this._config.entities?.length ${badges?.length
? html` ? html`
<div class="entities"> <div class="badges">
${this._config.entities.map( ${badges.map(
(config) => html` (config) => html`
<hui-heading-entity <hui-heading-badge
.config=${config} .config=${config}
.hass=${this.hass} .hass=${this.hass}
.preview=${this.preview} .preview=${this.preview}
> >
</hui-heading-entity> </hui-heading-badge>
` `
)} )}
</div> </div>
@ -150,7 +163,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
.container .content:not(:has(p)) { .container .content:not(:has(p)) {
min-width: fit-content; min-width: fit-content;
} }
.container .entities { .container .badges {
flex: 0 0; flex: 0 0;
} }
.content { .content {
@ -158,12 +171,12 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: var(--primary-text-color); color: var(--ha-heading-card-title-color, var(--primary-text-color));
font-size: 16px; font-size: var(--ha-heading-card-title-font-size, 16px);
font-weight: 500; font-weight: var(--ha-heading-card-title-font-weight, 400);
line-height: 24px; line-height: var(--ha-heading-card-title-line-height, 24px);
letter-spacing: 0.1px; letter-spacing: 0.1px;
--mdc-icon-size: 16px; --mdc-icon-size: 18px;
} }
.content ha-icon, .content ha-icon,
.content ha-icon-next { .content ha-icon-next {
@ -181,12 +194,15 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
min-width: 0; min-width: 0;
} }
.content.subtitle { .content.subtitle {
color: var(--secondary-text-color); color: var(
font-size: 14px; --ha-heading-card-subtitle-color,
font-weight: 500; var(--secondary-text-color)
line-height: 20px; );
font-size: var(--ha-heading-card-subtitle-font-size, 14px);
font-weight: var(--ha-heading-card-subtitle-font-weight, 500);
line-height: var(--ha-heading-card-subtitle-line-height, 20px);
} }
.entities { .badges {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;

View File

@ -16,6 +16,7 @@ import {
LovelaceRowConfig, LovelaceRowConfig,
} from "../entity-rows/types"; } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
export type AlarmPanelCardConfigState = export type AlarmPanelCardConfigState =
| "arm_away" | "arm_away"
@ -503,21 +504,12 @@ export interface TileCardConfig extends LovelaceCardConfig {
features?: LovelaceCardFeatureConfig[]; features?: LovelaceCardFeatureConfig[];
} }
export interface HeadingEntityConfig {
entity: string;
state_content?: string | string[];
icon?: string;
show_state?: boolean;
show_icon?: boolean;
color?: string;
tap_action?: ActionConfig;
visibility?: Condition[];
}
export interface HeadingCardConfig extends LovelaceCardConfig { export interface HeadingCardConfig extends LovelaceCardConfig {
heading_style?: "title" | "subtitle"; heading_style?: "title" | "subtitle";
heading?: string; heading?: string;
icon?: string; icon?: string;
tap_action?: ActionConfig; tap_action?: ActionConfig;
entities?: (string | HeadingEntityConfig)[]; badges?: LovelaceHeadingBadgeConfig[];
/** @deprecated Use `badges` instead */
entities?: LovelaceHeadingBadgeConfig[];
} }

View File

@ -16,6 +16,7 @@ import type { ErrorCardConfig } from "../cards/types";
import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types";
import { LovelaceRow, LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceRow, LovelaceRowConfig } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
import { import {
LovelaceBadge, LovelaceBadge,
LovelaceBadgeConstructor, LovelaceBadgeConstructor,
@ -26,6 +27,8 @@ import {
LovelaceElementConstructor, LovelaceElementConstructor,
LovelaceHeaderFooter, LovelaceHeaderFooter,
LovelaceHeaderFooterConstructor, LovelaceHeaderFooterConstructor,
LovelaceHeadingBadge,
LovelaceHeadingBadgeConstructor,
LovelaceRowConstructor, LovelaceRowConstructor,
} from "../types"; } from "../types";
@ -72,6 +75,11 @@ interface CreateElementConfigTypes {
element: LovelaceSectionElement; element: LovelaceSectionElement;
constructor: unknown; constructor: unknown;
}; };
"heading-badge": {
config: LovelaceHeadingBadgeConfig;
element: LovelaceHeadingBadge;
constructor: LovelaceHeadingBadgeConstructor;
};
} }
export const createErrorCardElement = (config: ErrorCardConfig) => { export const createErrorCardElement = (config: ErrorCardConfig) => {
@ -102,6 +110,20 @@ export const createErrorBadgeElement = (config: ErrorCardConfig) => {
return el; return el;
}; };
export const createErrorHeadingBadgeElement = (config: ErrorCardConfig) => {
const el = document.createElement("hui-error-heading-badge");
if (customElements.get("hui-error-heading-badge")) {
el.setConfig(config);
} else {
import("../heading-badges/hui-error-heading-badge");
customElements.whenDefined("hui-error-heading-badge").then(() => {
customElements.upgrade(el);
el.setConfig(config);
});
}
return el;
};
export const createErrorCardConfig = (error, origConfig) => ({ export const createErrorCardConfig = (error, origConfig) => ({
type: "error", type: "error",
error, error,
@ -114,6 +136,12 @@ export const createErrorBadgeConfig = (error, origConfig) => ({
origConfig, origConfig,
}); });
export const createErrorHeadingBadgeConfig = (error, origConfig) => ({
type: "error",
error,
origConfig,
});
const _createElement = <T extends keyof CreateElementConfigTypes>( const _createElement = <T extends keyof CreateElementConfigTypes>(
tag: string, tag: string,
config: CreateElementConfigTypes[T]["config"] config: CreateElementConfigTypes[T]["config"]
@ -134,6 +162,11 @@ const _createErrorElement = <T extends keyof CreateElementConfigTypes>(
if (tagSuffix === "badge") { if (tagSuffix === "badge") {
return createErrorBadgeElement(createErrorBadgeConfig(error, config)); return createErrorBadgeElement(createErrorBadgeConfig(error, config));
} }
if (tagSuffix === "heading-badge") {
return createErrorHeadingBadgeElement(
createErrorHeadingBadgeConfig(error, config)
);
}
return createErrorCardElement(createErrorCardConfig(error, config)); return createErrorCardElement(createErrorCardConfig(error, config));
}; };

View File

@ -0,0 +1,22 @@
import "../heading-badges/hui-entity-heading-badge";
import {
createLovelaceElement,
getLovelaceElementClass,
} from "./create-element-base";
import { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
export const createHeadingBadgeElement = (config: LovelaceHeadingBadgeConfig) =>
createLovelaceElement(
"heading-badge",
config,
ALWAYS_LOADED_TYPES,
undefined,
undefined,
"entity"
);
export const getHeadingBadgeElementClass = (type: string) =>
getLovelaceElementClass(type, "heading-badge", ALWAYS_LOADED_TYPES);

View File

@ -14,23 +14,21 @@ import "../../../../components/ha-list-item";
import "../../../../components/ha-sortable"; import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { LovelaceHeadingBadgeConfig } from "../../heading-badges/types";
type EntityConfig = {
entity: string;
};
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
"edit-entity": { index: number }; "edit-heading-badge": { index: number };
"heading-badges-changed": { badges: LovelaceHeadingBadgeConfig[] };
} }
} }
@customElement("hui-entities-editor") @customElement("hui-heading-badges-editor")
export class HuiEntitiesEditor extends LitElement { export class HuiHeadingBadgesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public entities?: EntityConfig[]; public badges?: LovelaceHeadingBadgeConfig[];
@query(".add-container", true) private _addContainer?: HTMLDivElement; @query(".add-container", true) private _addContainer?: HTMLDivElement;
@ -40,14 +38,30 @@ export class HuiEntitiesEditor extends LitElement {
private _opened = false; private _opened = false;
private _entitiesKeys = new WeakMap<EntityConfig, string>(); private _badgesKeys = new WeakMap<LovelaceHeadingBadgeConfig, string>();
private _getKey(entity: EntityConfig) { private _getKey(badge: LovelaceHeadingBadgeConfig) {
if (!this._entitiesKeys.has(entity)) { if (!this._badgesKeys.has(badge)) {
this._entitiesKeys.set(entity, Math.random().toString()); this._badgesKeys.set(badge, Math.random().toString());
} }
return this._entitiesKeys.get(entity)!; return this._badgesKeys.get(badge)!;
}
private _computeBadgeLabel(badge: LovelaceHeadingBadgeConfig) {
const type = badge.type ?? "entity";
if (type === "entity") {
const entityId = "entity" in badge ? (badge.entity as string) : undefined;
const stateObj = entityId ? this.hass.states[entityId] : undefined;
return (
(stateObj && stateObj.attributes.friendly_name) ||
entityId ||
type ||
"Unknown badge"
);
}
return type;
} }
protected render() { protected render() {
@ -56,46 +70,35 @@ export class HuiEntitiesEditor extends LitElement {
} }
return html` return html`
${this.entities ${this.badges
? html` ? html`
<ha-sortable <ha-sortable
handle-selector=".handle" handle-selector=".handle"
@item-moved=${this._entityMoved} @item-moved=${this._badgeMoved}
> >
<div class="entities"> <div class="entities">
${repeat( ${repeat(
this.entities, this.badges,
(entityConf) => this._getKey(entityConf), (badge) => this._getKey(badge),
(entityConf, index) => { (badge, index) => {
const editable = true; const label = this._computeBadgeLabel(badge);
const entityId = entityConf.entity;
const stateObj = this.hass.states[entityId];
const name = stateObj
? stateObj.attributes.friendly_name
: undefined;
return html` return html`
<div class="entity"> <div class="badge">
<div class="handle"> <div class="handle">
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon> <ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div> </div>
<div class="entity-content"> <div class="badge-content">
<span>${name || entityId}</span> <span>${label}</span>
</div> </div>
${editable <ha-icon-button
? html` .label=${this.hass!.localize(
<ha-icon-button `ui.panel.lovelace.editor.entities.edit`
.label=${this.hass!.localize( )}
`ui.panel.lovelace.editor.entities.edit` .path=${mdiPencil}
)} class="edit-icon"
.path=${mdiPencil} .index=${index}
class="edit-icon" @click=${this._editBadge}
.index=${index} ></ha-icon-button>
@click=${this._editEntity}
.disabled=${!editable}
></ha-icon-button>
`
: nothing}
<ha-icon-button <ha-icon-button
.label=${this.hass!.localize( .label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.remove` `ui.panel.lovelace.editor.entities.remove`
@ -186,34 +189,37 @@ export class HuiEntitiesEditor extends LitElement {
if (!ev.detail.value) { if (!ev.detail.value) {
return; return;
} }
const newEntity: EntityConfig = { entity: ev.detail.value }; const newEntity: LovelaceHeadingBadgeConfig = {
const newEntities = (this.entities || []).concat(newEntity); type: "entity",
fireEvent(this, "entities-changed", { entities: newEntities }); entity: ev.detail.value,
};
const newBadges = (this.badges || []).concat(newEntity);
fireEvent(this, "heading-badges-changed", { badges: newBadges });
} }
private _entityMoved(ev: CustomEvent): void { private _badgeMoved(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail; const { oldIndex, newIndex } = ev.detail;
const newEntities = (this.entities || []).concat(); const newBadges = (this.badges || []).concat();
newEntities.splice(newIndex, 0, newEntities.splice(oldIndex, 1)[0]); newBadges.splice(newIndex, 0, newBadges.splice(oldIndex, 1)[0]);
fireEvent(this, "entities-changed", { entities: newEntities }); fireEvent(this, "heading-badges-changed", { badges: newBadges });
} }
private _removeEntity(ev: CustomEvent): void { private _removeEntity(ev: CustomEvent): void {
const index = (ev.currentTarget as any).index; const index = (ev.currentTarget as any).index;
const newEntities = (this.entities || []).concat(); const newBadges = (this.badges || []).concat();
newEntities.splice(index, 1); newBadges.splice(index, 1);
fireEvent(this, "entities-changed", { entities: newEntities }); fireEvent(this, "heading-badges-changed", { badges: newBadges });
} }
private _editEntity(ev: CustomEvent): void { private _editBadge(ev: CustomEvent): void {
const index = (ev.currentTarget as any).index; const index = (ev.currentTarget as any).index;
fireEvent(this, "edit-entity", { fireEvent(this, "edit-heading-badge", {
index, index,
}); });
} }
@ -227,11 +233,11 @@ export class HuiEntitiesEditor extends LitElement {
ha-button { ha-button {
margin-top: 8px; margin-top: 8px;
} }
.entity { .badge {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.entity .handle { .badge .handle {
cursor: move; /* fallback if grab cursor is unsupported */ cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab; cursor: grab;
padding-right: 8px; padding-right: 8px;
@ -239,11 +245,11 @@ export class HuiEntitiesEditor extends LitElement {
padding-inline-start: initial; padding-inline-start: initial;
direction: var(--direction); direction: var(--direction);
} }
.entity .handle > * { .badge .handle > * {
pointer-events: none; pointer-events: none;
} }
.entity-content { .badge-content {
height: 60px; height: 60px;
font-size: 16px; font-size: 16px;
display: flex; display: flex;
@ -252,7 +258,7 @@ export class HuiEntitiesEditor extends LitElement {
flex-grow: 1; flex-grow: 1;
} }
.entity-content div { .badge-content div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -291,6 +297,6 @@ export class HuiEntitiesEditor extends LitElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"hui-entities-editor": HuiEntitiesEditor; "hui-heading-badges-editor": HuiHeadingBadgesEditor;
} }
} }

View File

@ -22,15 +22,19 @@ import type {
} from "../../../../components/ha-form/types"; } from "../../../../components/ha-form/types";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig, HeadingEntityConfig } from "../../cards/types"; import { migrateHeadingCardConfig } from "../../cards/hui-heading-card";
import type { HeadingCardConfig } from "../../cards/types";
import { UiAction } from "../../components/hui-action-editor"; import { UiAction } from "../../components/hui-action-editor";
import {
EntityHeadingBadgeConfig,
LovelaceHeadingBadgeConfig,
} from "../../heading-badges/types";
import type { LovelaceCardEditor } from "../../types"; import type { LovelaceCardEditor } from "../../types";
import { processEditorEntities } from "../process-editor-entities";
import { actionConfigStruct } from "../structs/action-struct"; import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { configElementStyle } from "./config-elements-style";
import "./hui-entities-editor";
import { EditSubElementEvent } from "../types"; import { EditSubElementEvent } from "../types";
import { configElementStyle } from "./config-elements-style";
import "./hui-heading-badges-editor";
const actions: UiAction[] = ["navigate", "url", "perform-action", "none"]; const actions: UiAction[] = ["navigate", "url", "perform-action", "none"];
@ -41,7 +45,7 @@ const cardConfigStruct = assign(
heading: optional(string()), heading: optional(string()),
icon: optional(string()), icon: optional(string()),
tap_action: optional(actionConfigStruct), tap_action: optional(actionConfigStruct),
entities: optional(array(any())), badges: optional(array(any())),
}) })
); );
@ -55,8 +59,8 @@ export class HuiHeadingCardEditor
@state() private _config?: HeadingCardConfig; @state() private _config?: HeadingCardConfig;
public setConfig(config: HeadingCardConfig): void { public setConfig(config: HeadingCardConfig): void {
assert(config, cardConfigStruct); this._config = migrateHeadingCardConfig(config);
this._config = config; assert(this._config, cardConfigStruct);
} }
private _schema = memoizeOne( private _schema = memoizeOne(
@ -103,8 +107,9 @@ export class HuiHeadingCardEditor
] as const satisfies readonly HaFormSchema[] ] as const satisfies readonly HaFormSchema[]
); );
private _entities = memoizeOne((entities: HeadingCardConfig["entities"]) => private _badges = memoizeOne(
processEditorEntities(entities || []) (badges: HeadingCardConfig["badges"]): LovelaceHeadingBadgeConfig[] =>
badges || []
); );
protected render() { protected render() {
@ -138,19 +143,19 @@ export class HuiHeadingCardEditor
)} )}
</h3> </h3>
<div class="content"> <div class="content">
<hui-entities-editor <hui-heading-badges-editor
.hass=${this.hass} .hass=${this.hass}
.entities=${this._entities(this._config!.entities)} .badges=${this._badges(this._config!.badges)}
@entities-changed=${this._entitiesChanged} @heading-badges-changed=${this._badgesChanged}
@edit-entity=${this._editEntity} @edit-heading-badge=${this._editBadge}
> >
</hui-entities-editor> </hui-heading-badges-editor>
</div> </div>
</ha-expansion-panel> </ha-expansion-panel>
`; `;
} }
private _entitiesChanged(ev: CustomEvent): void { private _badgesChanged(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
if (!this._config || !this.hass) { if (!this._config || !this.hass) {
return; return;
@ -158,7 +163,7 @@ export class HuiHeadingCardEditor
const config = { const config = {
...this._config, ...this._config,
entities: ev.detail.entities as HeadingEntityConfig[], badges: ev.detail.badges as LovelaceHeadingBadgeConfig[],
}; };
fireEvent(this, "config-changed", { config }); fireEvent(this, "config-changed", { config });
@ -175,22 +180,22 @@ export class HuiHeadingCardEditor
fireEvent(this, "config-changed", { config }); fireEvent(this, "config-changed", { config });
} }
private _editEntity(ev: HASSDomEvent<{ index: number }>): void { private _editBadge(ev: HASSDomEvent<{ index: number }>): void {
ev.stopPropagation(); ev.stopPropagation();
const index = ev.detail.index; const index = ev.detail.index;
const config = this._config!.entities![index!]; const config = this._badges(this._config!.badges)[index];
fireEvent(this, "edit-sub-element", { fireEvent(this, "edit-sub-element", {
config: config, config: config,
saveConfig: (newConfig) => this._updateEntity(index, newConfig), saveConfig: (newConfig) => this._updateBadge(index, newConfig),
type: "heading-entity", type: "heading-badge",
} as EditSubElementEvent<HeadingEntityConfig>); } as EditSubElementEvent<EntityHeadingBadgeConfig>);
} }
private _updateEntity(index: number, entity: HeadingEntityConfig) { private _updateBadge(index: number, entity: EntityHeadingBadgeConfig) {
const entities = this._config!.entities!.concat(); const badges = this._config!.badges!.concat();
entities[index] = entity; badges[index] = entity;
const config = { ...this._config!, entities }; const config = { ...this._config!, badges };
fireEvent(this, "config-changed", { fireEvent(this, "config-changed", {
config: config, config: config,
}); });

View File

@ -21,19 +21,21 @@ import type {
SchemaUnion, SchemaUnion,
} from "../../../../components/ha-form/types"; } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { HeadingEntityConfig } from "../../cards/types";
import { Condition } from "../../common/validate-condition"; import { Condition } from "../../common/validate-condition";
import { EntityHeadingBadgeConfig } from "../../heading-badges/types";
import type { LovelaceGenericElementEditor } from "../../types"; import type { LovelaceGenericElementEditor } from "../../types";
import "../conditions/ha-card-conditions-editor"; import "../conditions/ha-card-conditions-editor";
import { configElementStyle } from "../config-elements/config-elements-style"; import { configElementStyle } from "../config-elements/config-elements-style";
import { actionConfigStruct } from "../structs/action-struct"; import { actionConfigStruct } from "../structs/action-struct";
export const DEFAULT_CONFIG: Partial<HeadingEntityConfig> = { export const DEFAULT_CONFIG: Partial<EntityHeadingBadgeConfig> = {
type: "entity",
show_state: true, show_state: true,
show_icon: true, show_icon: true,
}; };
const entityConfigStruct = object({ const entityConfigStruct = object({
type: optional(string()),
entity: string(), entity: string(),
icon: optional(string()), icon: optional(string()),
state_content: optional(union([string(), array(string())])), state_content: optional(union([string(), array(string())])),
@ -44,7 +46,7 @@ const entityConfigStruct = object({
visibility: optional(array(any())), visibility: optional(array(any())),
}); });
type FormData = HeadingEntityConfig & { type FormData = EntityHeadingBadgeConfig & {
displayed_elements?: string[]; displayed_elements?: string[];
}; };
@ -57,9 +59,9 @@ export class HuiHeadingEntityEditor
@property({ type: Boolean }) public preview = false; @property({ type: Boolean }) public preview = false;
@state() private _config?: HeadingEntityConfig; @state() private _config?: EntityHeadingBadgeConfig;
public setConfig(config: HeadingEntityConfig): void { public setConfig(config: EntityHeadingBadgeConfig): void {
assert(config, entityConfigStruct); assert(config, entityConfigStruct);
this._config = { this._config = {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
@ -150,12 +152,14 @@ export class HuiHeadingEntityEditor
] as const satisfies readonly HaFormSchema[] ] as const satisfies readonly HaFormSchema[]
); );
private _displayedElements = memoizeOne((config: HeadingEntityConfig) => { private _displayedElements = memoizeOne(
const elements: string[] = []; (config: EntityHeadingBadgeConfig) => {
if (config.show_state) elements.push("state"); const elements: string[] = [];
if (config.show_icon) elements.push("icon"); if (config.show_state) elements.push("state");
return elements; if (config.show_icon) elements.push("icon");
}); return elements;
}
);
protected render() { protected render() {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
@ -228,7 +232,7 @@ export class HuiHeadingEntityEditor
const conditions = ev.detail.value as Condition[]; const conditions = ev.detail.value as Condition[];
const newConfig: HeadingEntityConfig = { const newConfig: EntityHeadingBadgeConfig = {
...this._config, ...this._config,
visibility: conditions, visibility: conditions,
}; };

View File

@ -0,0 +1,42 @@
import { customElement } from "lit/decorators";
import { getHeadingBadgeElementClass } from "../../create-element/create-heading-badge-element";
import type { EntityHeadingBadgeConfig } from "../../heading-badges/types";
import { LovelaceConfigForm, LovelaceHeadingBadgeEditor } from "../../types";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
@customElement("hui-heading-badge-element-editor")
export class HuiHeadingEntityElementEditor extends HuiTypedElementEditor<EntityHeadingBadgeConfig> {
protected get configElementType(): string | undefined {
return this.value?.type || "entity";
}
protected async getConfigElement(): Promise<
LovelaceHeadingBadgeEditor | undefined
> {
const elClass = await getHeadingBadgeElementClass(this.configElementType!);
// Check if a GUI editor exists
if (elClass && elClass.getConfigElement) {
return elClass.getConfigElement();
}
return undefined;
}
protected async getConfigForm(): Promise<LovelaceConfigForm | undefined> {
const elClass = await getHeadingBadgeElementClass(this.configElementType!);
// Check if a schema exists
if (elClass && elClass.getConfigForm) {
return elClass.getConfigForm();
}
return undefined;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-heading-badge-element-editor": HuiHeadingEntityElementEditor;
}
}

View File

@ -1,20 +0,0 @@
import { customElement } from "lit/decorators";
import { HeadingEntityConfig } from "../../cards/types";
import { HuiElementEditor } from "../hui-element-editor";
import type { HuiHeadingEntityEditor } from "./hui-heading-entity-editor";
@customElement("hui-heading-entity-element-editor")
export class HuiHeadingEntityElementEditor extends HuiElementEditor<HeadingEntityConfig> {
protected async getConfigElement(): Promise<
HuiHeadingEntityEditor | undefined
> {
await import("./hui-heading-entity-editor");
return document.createElement("hui-heading-entity-editor");
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-heading-entity-element-editor": HuiHeadingEntityElementEditor;
}
}

View File

@ -15,7 +15,7 @@ import type { HomeAssistant } from "../../../types";
import "./entity-row-editor/hui-row-element-editor"; import "./entity-row-editor/hui-row-element-editor";
import "./feature-editor/hui-card-feature-element-editor"; import "./feature-editor/hui-card-feature-element-editor";
import "./header-footer-editor/hui-header-footer-element-editor"; import "./header-footer-editor/hui-header-footer-element-editor";
import "./heading-entity/hui-heading-entity-element-editor"; import "./heading-badge-editor/hui-heading-badge-element-editor";
import type { HuiElementEditor } from "./hui-element-editor"; import type { HuiElementEditor } from "./hui-element-editor";
import "./picture-element-editor/hui-picture-element-element-editor"; import "./picture-element-editor/hui-picture-element-element-editor";
import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types"; import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types";
@ -132,16 +132,16 @@ export class HuiSubElementEditor extends LitElement {
@GUImode-changed=${this._handleGUIModeChanged} @GUImode-changed=${this._handleGUIModeChanged}
></hui-card-feature-element-editor> ></hui-card-feature-element-editor>
`; `;
case "heading-entity": case "heading-badge":
return html` return html`
<hui-heading-entity-element-editor <hui-heading-badge-element-editor
class="editor" class="editor"
.hass=${this.hass} .hass=${this.hass}
.value=${this.config.elementConfig} .value=${this.config.elementConfig}
.context=${this.config.context} .context=${this.config.context}
@config-changed=${this._handleConfigChanged} @config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged} @GUImode-changed=${this._handleGUIModeChanged}
></hui-heading-entity-element-editor> ></hui-heading-badge-element-editor>
`; `;
default: default:
return nothing; return nothing;

View File

@ -9,7 +9,7 @@ import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceCardFeatureConfig } from "../card-features/types"; import { LovelaceCardFeatureConfig } from "../card-features/types";
import { LovelaceElementConfig } from "../elements/types"; import { LovelaceElementConfig } from "../elements/types";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge"; import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { HeadingEntityConfig } from "../cards/types"; import { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
export interface YamlChangedEvent extends Event { export interface YamlChangedEvent extends Event {
detail: { detail: {
@ -97,10 +97,10 @@ export interface SubElementEditorConfig {
| LovelaceHeaderFooterConfig | LovelaceHeaderFooterConfig
| LovelaceCardFeatureConfig | LovelaceCardFeatureConfig
| LovelaceElementConfig | LovelaceElementConfig
| HeadingEntityConfig; | LovelaceHeadingBadgeConfig;
saveElementConfig?: (elementConfig: any) => void; saveElementConfig?: (elementConfig: any) => void;
context?: any; context?: any;
type: "header" | "footer" | "row" | "feature" | "element" | "heading-entity"; type: "header" | "footer" | "row" | "feature" | "element" | "heading-badge";
} }
export interface EditSubElementEvent<T = any, C = any> { export interface EditSubElementEvent<T = any, C = any> {

View File

@ -379,7 +379,19 @@ export class HuiDialogEditView extends LitElement {
}; };
if (viewConf.type === SECTION_VIEW_LAYOUT && !viewConf.sections?.length) { if (viewConf.type === SECTION_VIEW_LAYOUT && !viewConf.sections?.length) {
viewConf.sections = [{ type: "grid", cards: [] }]; viewConf.sections = [
{
type: "grid",
cards: [
{
type: "heading",
heading: this.hass!.localize(
"ui.panel.lovelace.editor.section.default_section_title"
),
},
],
},
];
} else if (!viewConf.cards?.length) { } else if (!viewConf.cards?.length) {
viewConf.cards = []; viewConf.cards = [];
} }

View File

@ -0,0 +1,177 @@
import { mdiAlertCircle } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-heading-badge";
import "../../../components/ha-state-icon";
import { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
import { DEFAULT_CONFIG } from "../editor/heading-badge-editor/hui-entity-heading-badge-editor";
import { LovelaceHeadingBadge, LovelaceHeadingBadgeEditor } from "../types";
import { EntityHeadingBadgeConfig } from "./types";
@customElement("hui-entity-heading-badge")
export class HuiEntityHeadingBadge
extends LitElement
implements LovelaceHeadingBadge
{
public static async getConfigElement(): Promise<LovelaceHeadingBadgeEditor> {
await import(
"../editor/heading-badge-editor/hui-entity-heading-badge-editor"
);
return document.createElement("hui-heading-entity-editor");
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EntityHeadingBadgeConfig;
@property({ type: Boolean }) public preview = false;
public setConfig(config): void {
this._config = {
...DEFAULT_CONFIG,
tap_action: {
action: "none",
},
...config,
};
}
private _handleAction(ev: ActionHandlerEvent) {
const config: EntityHeadingBadgeConfig = {
tap_action: {
action: "none",
},
...this._config!,
};
handleAction(this, this.hass!, config, ev.detail.action!);
}
private _computeStateColor = memoizeOne(
(entity: HassEntity, color?: string) => {
if (!color || color === "none") {
return undefined;
}
if (color === "state") {
// Use light color if the light support rgb
if (
computeDomain(entity.entity_id) === "light" &&
entity.attributes.rgb_color
) {
const hsvColor = rgb2hsv(entity.attributes.rgb_color);
// Modify the real rgb color for better contrast
if (hsvColor[1] < 0.4) {
// Special case for very light color (e.g: white)
if (hsvColor[1] < 0.1) {
hsvColor[2] = 225;
} else {
hsvColor[1] = 0.4;
}
}
return rgb2hex(hsv2rgb(hsvColor));
}
// Fallback to state color
return stateColorCss(entity);
}
if (color) {
// Use custom color if active
return stateActive(entity) ? computeCssColor(color) : undefined;
}
return color;
}
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const config = this._config;
const entityId = config.entity;
const stateObj = this.hass!.states[entityId];
if (!stateObj) {
return html`
<ha-heading-badge class="error" .title=${entityId}>
<ha-svg-icon
slot="icon"
.hass=${this.hass}
.path=${mdiAlertCircle}
></ha-svg-icon>
-
</ha-heading-badge>
`;
}
const color = this._computeStateColor(stateObj, config.color);
const style = {
"--icon-color": color,
};
return html`
<ha-heading-badge
.type=${hasAction(config.tap_action) ? "button" : "text"}
@action=${this._handleAction}
.actionHandler=${actionHandler()}
style=${styleMap(style)}
>
${config.show_icon
? html`
<ha-state-icon
slot="icon"
.hass=${this.hass}
.icon=${config.icon}
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing}
${config.show_state
? html`
<state-display
.hass=${this.hass}
.stateObj=${stateObj}
.content=${config.state_content}
></state-display>
`
: nothing}
</ha-heading-badge>
`;
}
static get styles(): CSSResultGroup {
return css`
[role="button"] {
cursor: pointer;
}
ha-heading-badge {
--state-inactive-color: initial;
}
ha-heading-badge.error {
--icon-color: var(--red-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-entity-heading-badge": HuiEntityHeadingBadge;
}
}

View File

@ -0,0 +1,94 @@
import { mdiAlertCircle } from "@mdi/js";
import { dump } from "js-yaml";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../components/ha-badge";
import "../../../components/ha-svg-icon";
import { HomeAssistant } from "../../../types";
import { showAlertDialog } from "../custom-card-helpers";
import { LovelaceBadge } from "../types";
import { ErrorBadgeConfig } from "./types";
export const createErrorHeadingBadgeElement = (config) => {
const el = document.createElement("hui-error-heading-badge");
el.setConfig(config);
return el;
};
export const createErrorHeadingBadgeConfig = (error) => ({
type: "error",
error,
});
@customElement("hui-error-heading-badge")
export class HuiErrorHeadingBadge extends LitElement implements LovelaceBadge {
public hass?: HomeAssistant;
@state() private _config?: ErrorBadgeConfig;
public setConfig(config: ErrorBadgeConfig): void {
this._config = config;
}
private _viewDetail() {
let dumped: string | undefined;
if (this._config!.origConfig) {
try {
dumped = dump(this._config!.origConfig);
} catch (err: any) {
dumped = `[Error dumping ${this._config!.origConfig}]`;
}
}
showAlertDialog(this, {
title: this._config?.error,
warning: true,
text: dumped ? html`<pre>${dumped}</pre>` : "",
});
}
protected render() {
if (!this._config) {
return nothing;
}
return html`
<ha-heading-badge
class="error"
@click=${this._viewDetail}
type="button"
.title=${this._config.error}
>
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
<span class="content">${this._config.error}</span>
</ha-heading-badge>
`;
}
static get styles(): CSSResultGroup {
return css`
ha-heading-badge {
--icon-color: var(--error-color);
color: var(--error-color);
}
.content {
max-width: 70px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
pre {
font-family: var(--code-font-family, monospace);
white-space: break-spaces;
user-select: text;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-error-heading-badge": HuiErrorHeadingBadge;
}
}

View File

@ -0,0 +1,202 @@
import { PropertyValues, ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
import type { LovelaceHeadingBadge } from "../types";
import { LovelaceHeadingBadgeConfig } from "./types";
declare global {
interface HASSDomEvents {
"heading-badge-visibility-changed": { value: boolean };
"heading-badge-updated": undefined;
}
}
@customElement("hui-heading-badge")
export class HuiHeadingBadge extends ReactiveElement {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig;
@property({ attribute: false }) public hass?: HomeAssistant;
private _elementConfig?: LovelaceHeadingBadgeConfig;
public load() {
if (!this.config) {
throw new Error("Cannot build heading badge without config");
}
this._loadElement(this.config);
}
private _element?: LovelaceHeadingBadge;
private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() {
return this;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
private _updateElement(config: LovelaceHeadingBadgeConfig) {
if (!this._element) {
return;
}
this._element.setConfig(config);
this._elementConfig = config;
fireEvent(this, "heading-badge-updated");
}
private _loadElement(config: LovelaceHeadingBadgeConfig) {
this._element = createHeadingBadgeElement(config);
this._elementConfig = config;
if (this.hass) {
this._element.hass = this.hass;
}
this._element.addEventListener(
"ll-upgrade",
(ev: Event) => {
ev.stopPropagation();
if (this.hass) {
this._element!.hass = this.hass;
}
fireEvent(this, "heading-badge-updated");
},
{ once: true }
);
this._element.addEventListener(
"ll-rebuild",
(ev: Event) => {
ev.stopPropagation();
this._loadElement(config);
fireEvent(this, "heading-badge-updated");
},
{ once: true }
);
while (this.lastChild) {
this.removeChild(this.lastChild);
}
this._updateVisibility();
}
protected willUpdate(changedProps: PropertyValues<typeof this>): void {
super.willUpdate(changedProps);
if (!this._element) {
this.load();
}
}
protected update(changedProps: PropertyValues<typeof this>) {
super.update(changedProps);
if (this._element) {
if (changedProps.has("config")) {
const elementConfig = this._elementConfig;
if (this.config !== elementConfig && this.config) {
const typeChanged = this.config?.type !== elementConfig?.type;
if (typeChanged) {
this._loadElement(this.config);
} else {
this._updateElement(this.config);
}
}
}
if (changedProps.has("hass")) {
try {
if (this.hass) {
this._element.hass = this.hass;
}
} catch (e: any) {
this._element = undefined;
this._elementConfig = undefined;
}
}
}
if (changedProps.has("hass") || changedProps.has("preview")) {
this._updateVisibility();
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this.config?.visibility) {
return;
}
const conditions = this.config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
this.config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
}
);
}
private _updateVisibility(forceVisible?: boolean) {
if (!this._element || !this.hass) {
return;
}
if (this._element.hidden) {
this._setElementVisibility(false);
return;
}
const visible =
forceVisible ||
this.preview ||
!this.config?.visibility ||
checkConditionsMet(this.config.visibility, this.hass);
this._setElementVisibility(visible);
}
private _setElementVisibility(visible: boolean) {
if (!this._element) return;
if (this.hidden !== !visible) {
this.style.setProperty("display", visible ? "" : "none");
this.toggleAttribute("hidden", !visible);
fireEvent(this, "heading-badge-visibility-changed", { value: visible });
}
if (!visible && this._element.parentElement) {
this.removeChild(this._element);
} else if (visible && !this._element.parentElement) {
this.appendChild(this._element);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-heading-badge": HuiHeadingBadge;
}
}

View File

@ -0,0 +1,25 @@
import { ActionConfig } from "../../../data/lovelace/config/action";
import { Condition } from "../common/validate-condition";
export type LovelaceHeadingBadgeConfig = {
type?: string;
[key: string]: any;
visibility?: Condition[];
};
export interface ErrorBadgeConfig extends LovelaceHeadingBadgeConfig {
type: string;
error: string;
origConfig: LovelaceHeadingBadgeConfig;
}
export interface EntityHeadingBadgeConfig extends LovelaceHeadingBadgeConfig {
type?: "entity";
entity: string;
state_content?: string | string[];
icon?: string;
show_state?: boolean;
show_icon?: boolean;
color?: string;
tap_action?: ActionConfig;
}

View File

@ -13,6 +13,7 @@ import { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types";
import { LovelaceHeaderFooterConfig } from "./header-footer/types"; import { LovelaceHeaderFooterConfig } from "./header-footer/types";
import { LovelaceCardFeatureConfig } from "./card-features/types"; import { LovelaceCardFeatureConfig } from "./card-features/types";
import { LovelaceElement, LovelaceElementConfig } from "./elements/types"; import { LovelaceElement, LovelaceElementConfig } from "./elements/types";
import { LovelaceHeadingBadgeConfig } from "./heading-badges/types";
declare global { declare global {
// eslint-disable-next-line // eslint-disable-next-line
@ -178,3 +179,27 @@ export interface LovelaceCardFeatureEditor
extends LovelaceGenericElementEditor { extends LovelaceGenericElementEditor {
setConfig(config: LovelaceCardFeatureConfig): void; setConfig(config: LovelaceCardFeatureConfig): void;
} }
export interface LovelaceHeadingBadge extends HTMLElement {
hass?: HomeAssistant;
preview?: boolean;
setConfig(config: LovelaceHeadingBadgeConfig);
}
export interface LovelaceHeadingBadgeConstructor
extends Constructor<LovelaceHeadingBadge> {
getStubConfig?: (
hass: HomeAssistant,
stateObj?: HassEntity
) => LovelaceHeadingBadgeConfig;
getConfigElement?: () => LovelaceHeadingBadgeEditor;
getConfigForm?: () => {
schema: HaFormSchema[];
assertConfig?: (config: LovelaceCardConfig) => void;
};
}
export interface LovelaceHeadingBadgeEditor
extends LovelaceGenericElementEditor {
setConfig(config: LovelaceHeadingBadgeConfig): void;
}

View File

@ -249,7 +249,9 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
cards: [ cards: [
{ {
type: "heading", type: "heading",
heading: "New Section", heading: this.hass!.localize(
"ui.panel.lovelace.editor.section.default_section_title"
),
}, },
], ],
}); });

View File

@ -11,6 +11,7 @@ import { customElement, property, state } from "lit/decorators";
import "../../components/ha-card"; import "../../components/ha-card";
import "../../components/ha-circular-progress"; import "../../components/ha-circular-progress";
import "../../components/ha-textfield"; import "../../components/ha-textfield";
import "../../components/ha-password-field";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../../components/ha-alert"; import "../../components/ha-alert";
@ -52,47 +53,44 @@ class HaChangePasswordCard extends LitElement {
? html`<ha-alert alert-type="success">${this._statusMsg}</ha-alert>` ? html`<ha-alert alert-type="success">${this._statusMsg}</ha-alert>`
: ""} : ""}
<ha-textfield <ha-password-field
id="currentPassword" id="currentPassword"
name="currentPassword" name="currentPassword"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.profile.change_password.current_password" "ui.panel.profile.change_password.current_password"
)} )}
type="password"
autocomplete="current-password" autocomplete="current-password"
.value=${this._currentPassword} .value=${this._currentPassword}
@input=${this._currentPasswordChanged} @input=${this._currentPasswordChanged}
@change=${this._currentPasswordChanged} @change=${this._currentPasswordChanged}
required required
></ha-textfield> ></ha-password-field>
${this._currentPassword ${this._currentPassword
? html`<ha-textfield ? html`<ha-password-field
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.profile.change_password.new_password" "ui.panel.profile.change_password.new_password"
)} )}
name="password" name="password"
type="password"
autocomplete="new-password" autocomplete="new-password"
.value=${this._password} .value=${this._password}
@input=${this._newPasswordChanged} @input=${this._newPasswordChanged}
@change=${this._newPasswordChanged} @change=${this._newPasswordChanged}
required required
auto-validate auto-validate
></ha-textfield> ></ha-password-field>
<ha-textfield <ha-password-field
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.profile.change_password.confirm_new_password" "ui.panel.profile.change_password.confirm_new_password"
)} )}
name="passwordConfirm" name="passwordConfirm"
type="password"
autocomplete="new-password" autocomplete="new-password"
.value=${this._passwordConfirm} .value=${this._passwordConfirm}
@input=${this._newPasswordConfirmChanged} @input=${this._newPasswordConfirmChanged}
@change=${this._newPasswordConfirmChanged} @change=${this._newPasswordConfirmChanged}
required required
auto-validate auto-validate
></ha-textfield>` ></ha-password-field>`
: ""} : ""}
</div> </div>

View File

@ -277,8 +277,17 @@ export const haSyntaxHighlighting = syntaxHighlighting(haHighlightStyle);
// A folding service for indent-based languages such as YAML. // A folding service for indent-based languages such as YAML.
export const foldingOnIndent = foldService.of((state, from, to) => { export const foldingOnIndent = foldService.of((state, from, to) => {
const line = state.doc.lineAt(from); const line = state.doc.lineAt(from);
// empty lines continue their indentation from surrounding lines
if (!line.length || !line.text.trim().length) {
return null;
}
let onlyEmptyNext = true;
const lineCount = state.doc.lines; const lineCount = state.doc.lines;
const indent = line.text.search(/\S|$/); // Indent level of the first line const indent = line.text.search(/\S|$/); // Indent level of the first line
let foldStart = from; // Start of the fold let foldStart = from; // Start of the fold
let foldEnd = to; // End of the fold let foldEnd = to; // End of the fold
@ -291,7 +300,15 @@ export const foldingOnIndent = foldService.of((state, from, to) => {
const nextIndent = nextLine.text.search(/\S|$/); // Indent level of the next line const nextIndent = nextLine.text.search(/\S|$/); // Indent level of the next line
// If the next line is on a deeper indent level, add it to the fold // If the next line is on a deeper indent level, add it to the fold
if (nextIndent > indent) { // empty lines continue their indentation from surrounding lines
if (
!nextLine.length ||
!nextLine.text.trim().length ||
nextIndent > indent
) {
if (onlyEmptyNext) {
onlyEmptyNext = nextLine.text.trim().length === 0;
}
// include this line in the fold and continue // include this line in the fold and continue
foldEnd = nextLine.to; foldEnd = nextLine.to;
} else { } else {
@ -301,7 +318,10 @@ export const foldingOnIndent = foldService.of((state, from, to) => {
} }
// Don't create fold if it's a single line // Don't create fold if it's a single line
if (state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number) { if (
onlyEmptyNext ||
state.doc.lineAt(foldStart).number === state.doc.lineAt(foldEnd).number
) {
return null; return null;
} }

View File

@ -3093,6 +3093,13 @@
"picker": "When someone (or something) enters or leaves a zone.", "picker": "When someone (or something) enters or leaves a zone.",
"full": "When {entity} {event, select, \n enter {enters}\n leave {leaves} other {} \n} {zone} {numberOfZones, plural,\n one {zone} \n other {zones}\n}" "full": "When {entity} {event, select, \n enter {enters}\n leave {leaves} other {} \n} {zone} {numberOfZones, plural,\n one {zone} \n other {zones}\n}"
} }
},
"list": {
"label": "List",
"description": {
"no_trigger": "When any trigger matches",
"full": "When any of {count} {count, plural,\n one {trigger}\n other {triggers}\n} triggers"
}
} }
} }
}, },
@ -5669,7 +5676,8 @@
"section": { "section": {
"add_badge": "Add badge", "add_badge": "Add badge",
"add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]", "add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]",
"create_section": "Create section" "create_section": "Create section",
"default_section_title": "New section"
}, },
"delete_section": { "delete_section": {
"title": "Delete section", "title": "Delete section",
@ -6402,7 +6410,7 @@
"row": "Entity row editor", "row": "Entity row editor",
"feature": "Feature editor", "feature": "Feature editor",
"element": "Element editor", "element": "Element editor",
"heading-entity": "Entity editor", "heading-badge": "Heading badge editor",
"element_type": "{type} element editor" "element_type": "{type} element editor"
} }
} }
@ -6949,7 +6957,7 @@
"no_issue": "No issue", "no_issue": "No issue",
"issues": { "issues": {
"units_changed": "The unit of this entity changed from ''{metadata_unit}'' to ''{state_unit}''.", "units_changed": "The unit of this entity changed from ''{metadata_unit}'' to ''{state_unit}''.",
"unsupported_state_class": "The state class ''{state_class}'' of this entity is not supported.", "state_class_removed": "This entity no longer has a state class",
"entity_not_recorded": "This entity is excluded from being recorded.", "entity_not_recorded": "This entity is excluded from being recorded.",
"entity_no_longer_recorded": "This entity is no longer being recorded.", "entity_no_longer_recorded": "This entity is no longer being recorded.",
"no_state": "There is no state available for this entity." "no_state": "There is no state available for this entity."
@ -6978,14 +6986,14 @@
"info_text_3_link": "See the recorder documentation for more information.", "info_text_3_link": "See the recorder documentation for more information.",
"info_text_4": "If you no longer wish to keep the long term statistics recorded in the past, you may delete them now." "info_text_4": "If you no longer wish to keep the long term statistics recorded in the past, you may delete them now."
}, },
"unsupported_state_class": { "state_class_removed": {
"title": "Unsupported state class", "title": "The entity no longer has a state class",
"info_text_1": "The state class of ''{name}'' ({statistic_id}), ''{state_class}'', is not supported.", "info_text_1": "We have generated statistics for ''{name}'' ({statistic_id}) in the past, but it no longer has a state class, therefore, we cannot track long term statistics for it anymore.",
"info_text_2": "Statistics cannot be generated until this entity has a supported state class.", "info_text_2": "Statistics cannot be generated until this entity has a supported state class.",
"info_text_3": "If this state class was provided by an integration, this is a bug. Please report an issue.", "info_text_3": "If the state class was previously provided by an integration, this might be a bug. Please report an issue.",
"info_text_4": "If you have set this state class yourself, please correct it.", "info_text_4": "If you previously set the state class yourself, please correct it.",
"info_text_4_link": "The different state classes and when to use which can be found in the developer documentation.", "info_text_4_link": "The different state classes and when to use which can be found in the developer documentation.",
"info_text_5": "If the state class has permanently changed, you may want to delete the long term statistics of it from your database.", "info_text_5": "If the state class has permanently been removed, you may want to delete the long term statistics of it from your database.",
"info_text_6": "Do you want to permanently delete the long term statistics of {statistic_id} from your database?" "info_text_6": "Do you want to permanently delete the long term statistics of {statistic_id} from your database?"
}, },
"units_changed": { "units_changed": {