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"],
},
{ 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 = {

View File

@ -8,6 +8,9 @@ import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
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 { 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";
@ -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 "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
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[] }[] = [
{
@ -116,6 +120,10 @@ const SCHEMAS: { name: string; triggers: Trigger[] }[] = [
},
],
},
{
name: "Trigger list",
triggers: [{ ...HaTriggerList.defaultConfig }],
},
];
@customElement("demo-automation-editor-trigger")
@ -135,6 +143,9 @@ export class DemoAutomationEditorTrigger extends LitElement {
mockDeviceRegistry(hass);
mockAreaRegistry(hass);
mockHassioSupervisor(hass);
mockConfig(hass);
mockTags(hass);
mockAuth(hass);
}
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-formfield";
import "../../../src/components/ha-textfield";
import "../../../src/components/ha-password-field";
import "../../../src/components/ha-radio";
import type { HaRadio } from "../../../src/components/ha-radio";
import {
@ -261,23 +262,21 @@ export class SupervisorBackupContent extends LitElement {
: ""}
${this.backupHasPassword
? html`
<ha-textfield
<ha-password-field
.label=${this._localize("password")}
type="password"
name="backupPassword"
.value=${this.backupPassword}
@change=${this._handleTextValueChanged}
>
</ha-textfield>
</ha-password-field>
${!this.backup
? html`<ha-textfield
? html`<ha-password-field
.label=${this._localize("confirm_password")}
type="password"
name="confirmBackupPassword"
.value=${this.confirmBackupPassword}
@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-expansion-panel";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-textfield";
import "../../../../src/components/ha-header-bar";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-password-field";
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 {
AccessPoints,
@ -34,7 +36,6 @@ import { HassDialog } from "../../../../src/dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../src/resources/styles";
import type { HomeAssistant } from "../../../../src/types";
import { HassioNetworkDialogParams } from "./show-dialog-network";
import type { HaTextField } from "../../../../src/components/ha-textfield";
const IP_VERSIONS = ["ipv4", "ipv6"];
@ -246,9 +247,8 @@ export class DialogHassioNetwork
${this._wifiConfiguration.auth === "wpa-psk" ||
this._wifiConfiguration.auth === "wep"
? html`
<ha-textfield
<ha-password-field
class="flex-auto"
type="password"
id="psk"
.label=${this.supervisor.localize(
"dialog.network.wifi_password"
@ -256,7 +256,7 @@ export class DialogHassioNetwork
version="wifi"
@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]
name = "home-assistant-frontend"
version = "20240926.0"
version = "20240927.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"

View File

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

View File

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

View File

@ -163,6 +163,7 @@ export class HaForm extends LitElement implements HaFormElement {
localize: this.hass?.localize,
computeLabel: this.computeLabel,
computeHelper: this.computeHelper,
localizeValue: this.localizeValue,
context: this._generateContext(item),
...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")
export class HaTextField extends TextFieldBase {
@property({ type: Boolean }) public invalid = false;
@property({ type: Boolean }) public invalid?: boolean;
@property({ attribute: "error-message" }) public errorMessage?: string;
@ -28,14 +28,24 @@ export class HaTextField extends TextFieldBase {
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (
(changedProperties.has("invalid") &&
(this.invalid || changedProperties.get("invalid") !== undefined)) ||
changedProperties.has("invalid") ||
changedProperties.has("errorMessage")
) {
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 (this.autocomplete) {

View File

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

View File

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

View File

@ -22,6 +22,7 @@ import {
formatListWithAnds,
formatListWithOrs,
} from "../common/string/format-list";
import { isTriggerList } from "./trigger";
const triggerTranslationBaseKey =
"ui.panel.config.automation.editor.triggers.type";
@ -98,6 +99,20 @@ const tryDescribeTrigger = (
entityRegistry: EntityRegistryEntry[],
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) {
return trigger.alias;
}

View File

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

View File

@ -5,6 +5,7 @@ import {
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiMapClock,
mdiMapMarker,
@ -21,7 +22,7 @@ import {
} from "@mdi/js";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { AutomationElementGroup } from "./automation";
import { AutomationElementGroup, Trigger, TriggerList } from "./automation";
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
@ -41,6 +42,7 @@ export const TRIGGER_ICONS = {
webhook: mdiWebhook,
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
};
export const TRIGGER_GROUPS: AutomationElementGroup = {
@ -65,3 +67,6 @@ export const TRIGGER_GROUPS: AutomationElementGroup = {
},
},
} 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;
text-align: center;
min-height: 300px;
max-width: 500px;
display: flex;
flex-direction: column;
justify-content: space-between;
@ -21,16 +20,27 @@ export const AssistantSetupStyles = [
}
.content img {
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 {
width: 100%;
display: flex;
width: 100%;
flex-direction: row;
justify-content: flex-end;
}
.footer.full-width {
flex-direction: column;
}
.footer ha-button {
.footer.full-width ha-button {
width: 100%;
}
.footer.side-by-side {
justify-content: space-between;
}
`,
];

View File

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

View File

@ -42,24 +42,7 @@ export class HaVoiceAssistantSetupStepAddons extends LitElement {
powerful device to run. If you device is not powerful enough, Home
Assistant cloud might be a better option.
</p>
<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>
<h3>Raspberry Pi 4:</h3>
<h3>Raspberry Pi 4</h3>
<div class="messages-container rpi">
<div class="message user ${this._showThird ? "show" : ""}">
${!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>`
: nothing}
</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 class="footer">
<div class="footer side-by-side">
<ha-button @click=${this._goToCloud}
>Try Home Assistant Cloud</ha-button
>
<a
href=${documentationUrl(
this.hass,
@ -85,19 +88,14 @@ export class HaVoiceAssistantSetupStepAddons extends LitElement {
)}
target="_blank"
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>`;
}
private _close() {
fireEvent(this, "closed");
private _goToCloud() {
fireEvent(this, "next-step", { step: STEP.CLOUD });
}
private _skip() {

View File

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

View File

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

View File

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

View File

@ -10,16 +10,24 @@ export class HaVoiceAssistantSetupStepCloud extends LitElement {
protected override render() {
return html`<div class="content">
<img src="/static/icons/casita/loving.png" />
<h1>Home Assistant Cloud</h1>
<img src="/static/images/logo_nabu_casa.png" />
<h1>Supercharge your assistant with Home Assistant Cloud</h1>
<p class="secondary">
With Home Assistant Cloud, you get the best results for your voice
assistant, sign up for a free trial now.
Speed up and take the load off your system by running your
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>
</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}
><ha-button>Start your free trial</ha-button></a
><ha-button unelevated>Try 1 month for free</ha-button></a
>
</div>`;
}

View File

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

View File

@ -1,9 +1,9 @@
import { mdiCog, mdiMicrophone, mdiPlay } from "@mdi/js";
import { css, html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import "../../components/ha-md-list-item";
import "../../components/ha-select";
import "../../components/ha-tts-voice-picker";
import {
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() {
const pipelineEntity = this.assistConfiguration
? this.hass.states[this.assistConfiguration.pipeline_entity_id]
: undefined;
return html`<div class="content">
<img src="/static/icons/casita/loving.png" />
<h1>Ready to assist!</h1>
<p class="secondary">
Make your assistant more personal by customizing shizzle to the
manizzle
Your device is all ready to go! If you want to tweak some more
settings, you can change that below.
</p>
<ha-md-list-item
interactive
type="button"
@click=${this._changeWakeWord}
>
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}
<div class="rows">
<div class="row">
<ha-select
.label=${"Wake word"}
@closed=${stopPropagation}
></ha-tts-voice-picker>`
: nothing}
fixedMenuPosition
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 class="footer">
<ha-button @click=${this._openPipeline}
>Change assistant settings</ha-button
>
<ha-button @click=${this._close} unelevated>Done</ha-button>
</div>`;
}
@ -160,6 +180,9 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
...pipeline,
tts_voice: ev.detail.value,
});
}
private _testTts() {
this._announce("Hello, how can I help you?");
}
@ -170,8 +193,11 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
await assistSatelliteAnnounce(this.hass, this.assistEntityId, message);
}
private _changeWakeWord() {
fireEvent(this, "next-step", { step: STEP.CHANGE_WAKEWORD });
private _testWakeWord() {
fireEvent(this, "next-step", {
step: STEP.WAKEWORD,
nextStep: STEP.SUCCESS,
});
}
private async _openPipeline() {
@ -209,12 +235,28 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
text-align: initial;
}
ha-tts-voice-picker {
margin-top: 16px;
display: block;
}
.footer {
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 {
super.willUpdate(changedProperties);
if (!this.updateEntityId) {
this._nextStep();
return;
}
if (changedProperties.has("hass") && this.updateEntityId) {
const oldHass = changedProperties.get("hass") as this["hass"] | undefined;
if (oldHass) {
@ -32,16 +37,9 @@ export class HaVoiceAssistantSetupStepUpdate extends LitElement {
}
}
if (!changedProperties.has("updateEntityId")) {
return;
if (changedProperties.has("updateEntityId")) {
this._tryUpdate();
}
if (!this.updateEntityId) {
this._nextStep();
return;
}
this._tryUpdate();
}
protected override render() {

View File

@ -58,7 +58,7 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
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>`;
}
@ -80,7 +80,7 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
To make sure the wake word works for you.
</p>`}
</div>
<div class="footer">
<div class="footer full-width">
<ha-button @click=${this._changeWakeWord}>Change wake word</ha-button>
</div>`;
}

View File

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

View File

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

View File

@ -78,7 +78,7 @@ export class HaManualAutomationEditor extends LitElement {
></ha-icon-button>
</a>
</div>
${!ensureArray(this.config.trigger)?.length
${!ensureArray(this.config.triggers)?.length
? html`<p>
${this.hass.localize(
"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 { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
import { handleStructError } from "../../../../common/structs/handle-errors";
@ -50,7 +51,7 @@ import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import { EntityRegistryEntry } from "../../../../data/entity_registry";
import { TRIGGER_ICONS } from "../../../../data/trigger";
import { TRIGGER_ICONS, isTriggerList } from "../../../../data/trigger";
import {
showAlertDialog,
showConfirmationDialog,
@ -64,6 +65,7 @@ import "./types/ha-automation-trigger-device";
import "./types/ha-automation-trigger-event";
import "./types/ha-automation-trigger-geo_location";
import "./types/ha-automation-trigger-homeassistant";
import "./types/ha-automation-trigger-list";
import "./types/ha-automation-trigger-mqtt";
import "./types/ha-automation-trigger-numeric_state";
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-webhook";
import "./types/ha-automation-trigger-zone";
import { preventDefault } from "../../../../common/dom/prevent_default";
export interface TriggerElement extends LitElement {
trigger: Trigger;
@ -87,7 +88,7 @@ export const handleChangeEvent = (element: TriggerElement, ev: CustomEvent) => {
if (!name) {
return;
}
const newVal = (ev.target as any)?.value;
const newVal = ev.detail?.value || (ev.currentTarget as any)?.value;
if ((element.trigger[name] || "") === newVal) {
return;
@ -146,15 +147,17 @@ export default class HaAutomationTriggerRow extends LitElement {
protected render() {
if (!this.trigger) return nothing;
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
const supported =
customElements.get(`ha-automation-trigger-${this.trigger.trigger}`) !==
undefined;
customElements.get(`ha-automation-trigger-${type}`) !== undefined;
const yamlMode = this._yamlMode || !supported;
const showId = "id" in this.trigger || this._requestShowId;
return html`
<ha-card outlined>
${this.trigger.enabled === false
${"enabled" in this.trigger && this.trigger.enabled === false
? html`
<div class="disabled-bar">
${this.hass.localize(
@ -168,7 +171,7 @@ export default class HaAutomationTriggerRow extends LitElement {
<h3 slot="header">
<ha-svg-icon
class="trigger-icon"
.path=${TRIGGER_ICONS[this.trigger.trigger]}
.path=${TRIGGER_ICONS[type]}
></ha-svg-icon>
${describeTrigger(this.trigger, this.hass, this._entityReg)}
</h3>
@ -188,14 +191,20 @@ export default class HaAutomationTriggerRow extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item graphic="icon" .disabled=${this.disabled}>
<mwc-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</mwc-list-item>
<mwc-list-item graphic="icon" .disabled=${this.disabled}>
<mwc-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
@ -274,8 +283,11 @@ export default class HaAutomationTriggerRow extends LitElement {
<li divider role="separator"></li>
<mwc-list-item graphic="icon" .disabled=${this.disabled}>
${this.trigger.enabled === false
<mwc-list-item
graphic="icon"
.disabled=${this.disabled || type === "list"}
>
${"enabled" in this.trigger && this.trigger.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
@ -284,7 +296,8 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
<ha-svg-icon
slot="graphic"
.path=${this.trigger.enabled === false
.path=${"enabled" in this.trigger &&
this.trigger.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
@ -308,7 +321,8 @@ export default class HaAutomationTriggerRow extends LitElement {
<div
class=${classMap({
"card-content": true,
disabled: this.trigger.enabled === false,
disabled:
"enabled" in this.trigger && this.trigger.enabled === false,
})}
>
${this._warnings
@ -336,7 +350,7 @@ export default class HaAutomationTriggerRow extends LitElement {
? html`
${this.hass.localize(
"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>
`
: html`
${showId
${showId && !isTriggerList(this.trigger)
? html`
<ha-textfield
.label=${this.hass.localize(
@ -365,15 +379,12 @@ export default class HaAutomationTriggerRow extends LitElement {
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@value-changed=${this._onUiChanged}
>
${dynamicElement(
`ha-automation-trigger-${this.trigger.trigger}`,
{
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
path: this.path,
}
)}
${dynamicElement(`ha-automation-trigger-${type}`, {
hass: this.hass,
trigger: this.trigger,
disabled: this.disabled,
path: this.path,
})}
</div>
`}
</div>
@ -546,6 +557,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _onDisable() {
if (isTriggerList(this.trigger)) return;
const enabled = !(this.trigger.enabled ?? true);
const value = { ...this.trigger, enabled };
fireEvent(this, "value-changed", { value });
@ -555,7 +567,9 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _idChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
const newId = (ev.target as any).value;
if (newId === (this.trigger.id ?? "")) {
return;
}
@ -583,6 +597,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private _onUiChanged(ev: CustomEvent) {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
@ -617,6 +632,7 @@ export default class HaAutomationTriggerRow extends LitElement {
}
private async _renameTrigger(): Promise<void> {
if (isTriggerList(this.trigger)) return;
const alias = await showPromptDialog(this, {
title: this.hass.localize(
"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-sortable";
import "../../../../components/ha-svg-icon";
import { AutomationClipboard, Trigger } from "../../../../data/automation";
import {
AutomationClipboard,
Trigger,
TriggerList,
} from "../../../../data/automation";
import { HomeAssistant, ItemPath } from "../../../../types";
import {
PASTE_VALUE,
@ -26,6 +30,7 @@ import {
} from "../show-add-automation-element-dialog";
import "./ha-automation-trigger-row";
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
import { isTriggerList } from "../../../../data/trigger";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends LitElement {
@ -130,7 +135,11 @@ export default class HaAutomationTrigger extends LitElement {
showAddAutomationElementDialog(this, {
type: "trigger",
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) {
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
} else {
const trigger = value as Trigger["trigger"];
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
const elClass = customElements.get(
`ha-automation-trigger-${trigger}`
) 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;
try {
await cloudForgotPassword(this.hass, email);
// @ts-ignore
fireEvent(this, "email-changed", { value: email });
this._requestInProgress = false;
// @ts-ignore
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.forgot_password.check_your_email"
),
});
} catch (err: any) {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
const doResetPassword = async (username: string) => {
try {
await cloudForgotPassword(this.hass, username);
// @ts-ignore
fireEvent(this, "email-changed", { value: username });
this._requestInProgress = false;
// @ts-ignore
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.forgot_password.check_your_email"
),
});
} catch (err: any) {
this._requestInProgress = false;
const errCode = err && err.body && err.body.code;
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doResetPassword(username.toLowerCase());
} else {
this._error =
err && err.body && err.body.message
? err.body.message
: "Unknown error";
}
}
};
await doResetPassword(email);
}
static get styles() {

View File

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

View File

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

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 { 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 (
el: HTMLElement,
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;
};

View File

@ -1119,12 +1119,17 @@ export class HaConfigDevicePage extends LitElement {
const matter = await import(
"./device-detail/integration-elements/matter/device-actions"
);
const actions = await matter.getMatterDeviceActions(
const defaultActions = matter.getMatterDeviceDefaultActions(
this,
this.hass,
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;

View File

@ -14,6 +14,11 @@ import "../../../components/ha-circular-progress";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-formfield";
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 {
AccessPoints,
@ -29,10 +34,6 @@ import {
} from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
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"];
@ -214,8 +215,7 @@ export class HassioNetwork extends LitElement {
${this._wifiConfiguration.auth === "wpa-psk" ||
this._wifiConfiguration.auth === "wep"
? html`
<ha-textfield
type="password"
<ha-password-field
id="psk"
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.wifi_password"
@ -223,7 +223,7 @@ export class HassioNetwork extends LitElement {
.version=${"wifi"}
@change=${this._handleInputValueChangedWifi}
>
</ha-textfield>
</ha-password-field>
`
: ""}
`

View File

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

View File

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

View File

@ -103,31 +103,30 @@ export const fixStatisticsIssue = async (
await clearStatistics(hass, [issue.data.statistic_id]);
},
});
case "unsupported_state_class":
case "state_class_removed":
return showConfirmationDialog(element, {
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(
"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),
statistic_id: issue.data.statistic_id,
state_class: issue.data.state_class,
}
)}<br /><br />
${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>
<li>
${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>
${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
href="https://developers.home-assistant.io/docs/core/entity/sensor/#long-term-statistics"
@ -135,18 +134,18 @@ export const fixStatisticsIssue = async (
rel="noreferrer noopener"
>
${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
>
</li>
<li>
${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>
</ul>
${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 }
)}`,
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 { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
import "../heading-badges/hui-heading-badge";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceLayoutOptions,
} from "../types";
import "./heading/hui-heading-entity";
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")
export class HuiHeadingCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@ -45,7 +56,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
tap_action: {
action: "none",
},
...config,
...migrateHeadingCardConfig(config),
};
}
@ -73,6 +84,8 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
const style = this._config.heading_style || "title";
const badges = this._config.badges;
return html`
<ha-card>
<div class="container">
@ -91,17 +104,17 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
: nothing}
${actionable ? html`<ha-icon-next></ha-icon-next>` : nothing}
</div>
${this._config.entities?.length
${badges?.length
? html`
<div class="entities">
${this._config.entities.map(
<div class="badges">
${badges.map(
(config) => html`
<hui-heading-entity
<hui-heading-badge
.config=${config}
.hass=${this.hass}
.preview=${this.preview}
>
</hui-heading-entity>
</hui-heading-badge>
`
)}
</div>
@ -150,7 +163,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
.container .content:not(:has(p)) {
min-width: fit-content;
}
.container .entities {
.container .badges {
flex: 0 0;
}
.content {
@ -158,12 +171,12 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
flex-direction: row;
align-items: center;
gap: 8px;
color: var(--primary-text-color);
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: var(--ha-heading-card-title-color, var(--primary-text-color));
font-size: var(--ha-heading-card-title-font-size, 16px);
font-weight: var(--ha-heading-card-title-font-weight, 400);
line-height: var(--ha-heading-card-title-line-height, 24px);
letter-spacing: 0.1px;
--mdc-icon-size: 16px;
--mdc-icon-size: 18px;
}
.content ha-icon,
.content ha-icon-next {
@ -181,12 +194,15 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
min-width: 0;
}
.content.subtitle {
color: var(--secondary-text-color);
font-size: 14px;
font-weight: 500;
line-height: 20px;
color: var(
--ha-heading-card-subtitle-color,
var(--secondary-text-color)
);
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;
flex-direction: row;
align-items: center;

View File

@ -16,6 +16,7 @@ import {
LovelaceRowConfig,
} from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
export type AlarmPanelCardConfigState =
| "arm_away"
@ -503,21 +504,12 @@ export interface TileCardConfig extends LovelaceCardConfig {
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 {
heading_style?: "title" | "subtitle";
heading?: string;
icon?: string;
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 { LovelaceRow, LovelaceRowConfig } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
import {
LovelaceBadge,
LovelaceBadgeConstructor,
@ -26,6 +27,8 @@ import {
LovelaceElementConstructor,
LovelaceHeaderFooter,
LovelaceHeaderFooterConstructor,
LovelaceHeadingBadge,
LovelaceHeadingBadgeConstructor,
LovelaceRowConstructor,
} from "../types";
@ -72,6 +75,11 @@ interface CreateElementConfigTypes {
element: LovelaceSectionElement;
constructor: unknown;
};
"heading-badge": {
config: LovelaceHeadingBadgeConfig;
element: LovelaceHeadingBadge;
constructor: LovelaceHeadingBadgeConstructor;
};
}
export const createErrorCardElement = (config: ErrorCardConfig) => {
@ -102,6 +110,20 @@ export const createErrorBadgeElement = (config: ErrorCardConfig) => {
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) => ({
type: "error",
error,
@ -114,6 +136,12 @@ export const createErrorBadgeConfig = (error, origConfig) => ({
origConfig,
});
export const createErrorHeadingBadgeConfig = (error, origConfig) => ({
type: "error",
error,
origConfig,
});
const _createElement = <T extends keyof CreateElementConfigTypes>(
tag: string,
config: CreateElementConfigTypes[T]["config"]
@ -134,6 +162,11 @@ const _createErrorElement = <T extends keyof CreateElementConfigTypes>(
if (tagSuffix === "badge") {
return createErrorBadgeElement(createErrorBadgeConfig(error, config));
}
if (tagSuffix === "heading-badge") {
return createErrorHeadingBadgeElement(
createErrorHeadingBadgeConfig(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-svg-icon";
import { HomeAssistant } from "../../../../types";
type EntityConfig = {
entity: string;
};
import { LovelaceHeadingBadgeConfig } from "../../heading-badges/types";
declare global {
interface HASSDomEvents {
"edit-entity": { index: number };
"edit-heading-badge": { index: number };
"heading-badges-changed": { badges: LovelaceHeadingBadgeConfig[] };
}
}
@customElement("hui-entities-editor")
export class HuiEntitiesEditor extends LitElement {
@customElement("hui-heading-badges-editor")
export class HuiHeadingBadgesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public entities?: EntityConfig[];
public badges?: LovelaceHeadingBadgeConfig[];
@query(".add-container", true) private _addContainer?: HTMLDivElement;
@ -40,14 +38,30 @@ export class HuiEntitiesEditor extends LitElement {
private _opened = false;
private _entitiesKeys = new WeakMap<EntityConfig, string>();
private _badgesKeys = new WeakMap<LovelaceHeadingBadgeConfig, string>();
private _getKey(entity: EntityConfig) {
if (!this._entitiesKeys.has(entity)) {
this._entitiesKeys.set(entity, Math.random().toString());
private _getKey(badge: LovelaceHeadingBadgeConfig) {
if (!this._badgesKeys.has(badge)) {
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() {
@ -56,46 +70,35 @@ export class HuiEntitiesEditor extends LitElement {
}
return html`
${this.entities
${this.badges
? html`
<ha-sortable
handle-selector=".handle"
@item-moved=${this._entityMoved}
@item-moved=${this._badgeMoved}
>
<div class="entities">
${repeat(
this.entities,
(entityConf) => this._getKey(entityConf),
(entityConf, index) => {
const editable = true;
const entityId = entityConf.entity;
const stateObj = this.hass.states[entityId];
const name = stateObj
? stateObj.attributes.friendly_name
: undefined;
this.badges,
(badge) => this._getKey(badge),
(badge, index) => {
const label = this._computeBadgeLabel(badge);
return html`
<div class="entity">
<div class="badge">
<div class="handle">
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div>
<div class="entity-content">
<span>${name || entityId}</span>
<div class="badge-content">
<span>${label}</span>
</div>
${editable
? html`
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.edit`
)}
.path=${mdiPencil}
class="edit-icon"
.index=${index}
@click=${this._editEntity}
.disabled=${!editable}
></ha-icon-button>
`
: nothing}
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.edit`
)}
.path=${mdiPencil}
class="edit-icon"
.index=${index}
@click=${this._editBadge}
></ha-icon-button>
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.remove`
@ -186,34 +189,37 @@ export class HuiEntitiesEditor extends LitElement {
if (!ev.detail.value) {
return;
}
const newEntity: EntityConfig = { entity: ev.detail.value };
const newEntities = (this.entities || []).concat(newEntity);
fireEvent(this, "entities-changed", { entities: newEntities });
const newEntity: LovelaceHeadingBadgeConfig = {
type: "entity",
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();
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 {
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;
fireEvent(this, "edit-entity", {
fireEvent(this, "edit-heading-badge", {
index,
});
}
@ -227,11 +233,11 @@ export class HuiEntitiesEditor extends LitElement {
ha-button {
margin-top: 8px;
}
.entity {
.badge {
display: flex;
align-items: center;
}
.entity .handle {
.badge .handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
padding-right: 8px;
@ -239,11 +245,11 @@ export class HuiEntitiesEditor extends LitElement {
padding-inline-start: initial;
direction: var(--direction);
}
.entity .handle > * {
.badge .handle > * {
pointer-events: none;
}
.entity-content {
.badge-content {
height: 60px;
font-size: 16px;
display: flex;
@ -252,7 +258,7 @@ export class HuiEntitiesEditor extends LitElement {
flex-grow: 1;
}
.entity-content div {
.badge-content div {
display: flex;
flex-direction: column;
}
@ -291,6 +297,6 @@ export class HuiEntitiesEditor extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"hui-entities-editor": HuiEntitiesEditor;
"hui-heading-badges-editor": HuiHeadingBadgesEditor;
}
}

View File

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

View File

@ -21,19 +21,21 @@ import type {
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HeadingEntityConfig } from "../../cards/types";
import { Condition } from "../../common/validate-condition";
import { EntityHeadingBadgeConfig } from "../../heading-badges/types";
import type { LovelaceGenericElementEditor } from "../../types";
import "../conditions/ha-card-conditions-editor";
import { configElementStyle } from "../config-elements/config-elements-style";
import { actionConfigStruct } from "../structs/action-struct";
export const DEFAULT_CONFIG: Partial<HeadingEntityConfig> = {
export const DEFAULT_CONFIG: Partial<EntityHeadingBadgeConfig> = {
type: "entity",
show_state: true,
show_icon: true,
};
const entityConfigStruct = object({
type: optional(string()),
entity: string(),
icon: optional(string()),
state_content: optional(union([string(), array(string())])),
@ -44,7 +46,7 @@ const entityConfigStruct = object({
visibility: optional(array(any())),
});
type FormData = HeadingEntityConfig & {
type FormData = EntityHeadingBadgeConfig & {
displayed_elements?: string[];
};
@ -57,9 +59,9 @@ export class HuiHeadingEntityEditor
@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);
this._config = {
...DEFAULT_CONFIG,
@ -150,12 +152,14 @@ export class HuiHeadingEntityEditor
] as const satisfies readonly HaFormSchema[]
);
private _displayedElements = memoizeOne((config: HeadingEntityConfig) => {
const elements: string[] = [];
if (config.show_state) elements.push("state");
if (config.show_icon) elements.push("icon");
return elements;
});
private _displayedElements = memoizeOne(
(config: EntityHeadingBadgeConfig) => {
const elements: string[] = [];
if (config.show_state) elements.push("state");
if (config.show_icon) elements.push("icon");
return elements;
}
);
protected render() {
if (!this.hass || !this._config) {
@ -228,7 +232,7 @@ export class HuiHeadingEntityEditor
const conditions = ev.detail.value as Condition[];
const newConfig: HeadingEntityConfig = {
const newConfig: EntityHeadingBadgeConfig = {
...this._config,
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 "./feature-editor/hui-card-feature-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 "./picture-element-editor/hui-picture-element-element-editor";
import type { GUIModeChangedEvent, SubElementEditorConfig } from "./types";
@ -132,16 +132,16 @@ export class HuiSubElementEditor extends LitElement {
@GUImode-changed=${this._handleGUIModeChanged}
></hui-card-feature-element-editor>
`;
case "heading-entity":
case "heading-badge":
return html`
<hui-heading-entity-element-editor
<hui-heading-badge-element-editor
class="editor"
.hass=${this.hass}
.value=${this.config.elementConfig}
.context=${this.config.context}
@config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged}
></hui-heading-entity-element-editor>
></hui-heading-badge-element-editor>
`;
default:
return nothing;

View File

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

View File

@ -379,7 +379,19 @@ export class HuiDialogEditView extends LitElement {
};
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) {
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 { LovelaceCardFeatureConfig } from "./card-features/types";
import { LovelaceElement, LovelaceElementConfig } from "./elements/types";
import { LovelaceHeadingBadgeConfig } from "./heading-badges/types";
declare global {
// eslint-disable-next-line
@ -178,3 +179,27 @@ export interface LovelaceCardFeatureEditor
extends LovelaceGenericElementEditor {
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: [
{
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-circular-progress";
import "../../components/ha-textfield";
import "../../components/ha-password-field";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../../components/ha-alert";
@ -52,47 +53,44 @@ class HaChangePasswordCard extends LitElement {
? html`<ha-alert alert-type="success">${this._statusMsg}</ha-alert>`
: ""}
<ha-textfield
<ha-password-field
id="currentPassword"
name="currentPassword"
.label=${this.hass.localize(
"ui.panel.profile.change_password.current_password"
)}
type="password"
autocomplete="current-password"
.value=${this._currentPassword}
@input=${this._currentPasswordChanged}
@change=${this._currentPasswordChanged}
required
></ha-textfield>
></ha-password-field>
${this._currentPassword
? html`<ha-textfield
? html`<ha-password-field
.label=${this.hass.localize(
"ui.panel.profile.change_password.new_password"
)}
name="password"
type="password"
autocomplete="new-password"
.value=${this._password}
@input=${this._newPasswordChanged}
@change=${this._newPasswordChanged}
required
auto-validate
></ha-textfield>
<ha-textfield
></ha-password-field>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.profile.change_password.confirm_new_password"
)}
name="passwordConfirm"
type="password"
autocomplete="new-password"
.value=${this._passwordConfirm}
@input=${this._newPasswordConfirmChanged}
@change=${this._newPasswordConfirmChanged}
required
auto-validate
></ha-textfield>`
></ha-password-field>`
: ""}
</div>

View File

@ -277,8 +277,17 @@ export const haSyntaxHighlighting = syntaxHighlighting(haHighlightStyle);
// A folding service for indent-based languages such as YAML.
export const foldingOnIndent = foldService.of((state, from, to) => {
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 indent = line.text.search(/\S|$/); // Indent level of the first line
let foldStart = from; // Start 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
// 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
foldEnd = nextLine.to;
} else {
@ -301,7 +318,10 @@ export const foldingOnIndent = foldService.of((state, from, to) => {
}
// 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;
}

View File

@ -3093,6 +3093,13 @@
"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}"
}
},
"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": {
"add_badge": "Add badge",
"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": {
"title": "Delete section",
@ -6402,7 +6410,7 @@
"row": "Entity row editor",
"feature": "Feature editor",
"element": "Element editor",
"heading-entity": "Entity editor",
"heading-badge": "Heading badge editor",
"element_type": "{type} element editor"
}
}
@ -6949,7 +6957,7 @@
"no_issue": "No issue",
"issues": {
"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_no_longer_recorded": "This entity is no longer being recorded.",
"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_4": "If you no longer wish to keep the long term statistics recorded in the past, you may delete them now."
},
"unsupported_state_class": {
"title": "Unsupported state class",
"info_text_1": "The state class of ''{name}'' ({statistic_id}), ''{state_class}'', is not supported.",
"state_class_removed": {
"title": "The entity no longer has a state class",
"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_3": "If this state class was provided by an integration, this is a bug. Please report an issue.",
"info_text_4": "If you have set this state class yourself, please correct it.",
"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 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_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?"
},
"units_changed": {