Compare commits

...

21 Commits

Author SHA1 Message Date
Aidan Timson
46d48bf1af Format 2026-01-08 12:40:34 +00:00
Aidan Timson
572dea2b0c Add 2026-01-06 15:43:08 +00:00
Aidan Timson
e34db36098 Improve 2026-01-06 15:29:30 +00:00
Aidan Timson
3a6109e147 Type 2026-01-06 15:07:29 +00:00
Aidan Timson
ce1e89e716 Setup 2026-01-06 15:07:05 +00:00
Aidan Timson
75f9baf6ef Match 2026-01-06 15:02:21 +00:00
Aidan Timson
d4138dced9 Setup analog clock 2026-01-06 12:36:36 +00:00
Aidan Timson
3dc34cb1cc Add date to digital clock 2026-01-06 12:11:50 +00:00
Aidan Timson
858eeb7960 Setup 2026-01-06 12:11:32 +00:00
Simon Lamon
f22f6b74db Remove used from energy usage header (#28775)
* Remove used

* Update src/translations/en.json

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 11:37:27 +00:00
Aidan Timson
913c4ae24e Remove duplicate custom items, remove "no matching ..." when allow-custom-value set (#28801)
* Remove duplicate custom items, allow default from picker

* Memoize

* Memoize

* Memoize func

* Don't show no matching item when custom value is allowed

* Remove no items found label now unused

* Cleanup unused translations

* Restore used value

* Remove no items found label now unused

* Remove redundant comment

* Remove searchFn

* Ensure custom value isnt identical

* Fix duplicated value

* Fix duplicated value

* Use additional items for entity state content

* Fix duplicate values

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-01-06 11:16:54 +01:00
Matthias Alphart
4b7b5fa21a Replace unload event handler for custom panels with pagehide (#28781)
* Replace `unload` event handler for custom panels

* Handle restore from bfcache

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 09:07:52 +00:00
Norbert Rittel
bf6887541b Capitalize counter button labels (#28814)
Capitalized counter button labels
2026-01-06 08:47:51 +02:00
karwosts
26da9f3a37 Fix statistic-graph-card cutoff w/ energy date picker (#28810)
* Fix statistics-graph energy-date mode end-time with 5min statistics

* don't modify date/hour for 5minute graph

* suggestedMax use period instead of days

* go back to string types
2026-01-05 17:28:15 +02:00
Aidan Timson
d48520efdf Add option for any state and show translated label for entity state values (#28803)
* Add option for any state

* Use translated labels for value
2026-01-05 16:55:23 +02:00
Aidan Timson
d462356122 Reapply "Migrate dialog-device-registry-detail to ha-wa-dialog (#27668)" (#28804)
* Reapply "Migrate dialog-device-registry-detail to ha-wa-dialog (#27668)" (#27716)

This reverts commit 5f75fc5bcb.

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-05 11:51:12 +00:00
Copilot
9a5cdb0a99 Display template targets with neutral badge instead of "Unknown area" error (#28799)
* Initial plan

* Add template target display with neutral badge

- Import mdiCodeBraces icon and isTemplate function
- Check if target ID is a template before checking if it exists
- Display grey {} icon with "Template" text for templated targets
- Add "template" translation key to target_summary
- Prevents misleading red "Unknown area" badge for template targets

Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
2026-01-05 12:44:58 +01:00
Paul Bottein
eaf012d5ff Show close button when zwave firmware update is finished (#28805) 2026-01-05 13:42:55 +02:00
Paul Bottein
19934dad72 Remove custom value for unknown icon in icon picker (#28800) 2026-01-05 10:57:09 +00:00
Paul Bottein
6194f73442 Use regular item for bottom padding in combobox (#28798) 2026-01-05 11:40:54 +01:00
Paul Bottein
dbc880fe35 Add warning about running tsc with file arguments (#28797)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:08:21 +00:00
19 changed files with 500 additions and 249 deletions

View File

@@ -22,11 +22,13 @@ You are an assistant helping with development of the Home Assistant frontend. Th
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
yarn test # Vitest
script/develop # Development server
```
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
### Component Prefixes
- `ha-` - Home Assistant components

View File

@@ -18,10 +18,7 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import {
NO_ITEMS_AVAILABLE_ID,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
@@ -184,18 +181,17 @@ export class HaEntityNamePicker extends LitElement {
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.rowRenderer=${rowRenderer}
.searchFn=${this._searchFn}
.notFoundLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.no_match"
)}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="container">
<ha-sortable
@@ -316,10 +312,7 @@ export class HaEntityNamePicker extends LitElement {
return undefined;
}
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this.entityId);
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
@@ -336,49 +329,27 @@ export class HaEntityNamePicker extends LitElement {
);
// When editing an existing text item, include it in the base items
if (currentItem?.type === "text" && currentItem.text && !searchString) {
if (currentItem?.type === "text" && currentItem.text) {
filteredItems.push(this._customNameOption(currentItem.text));
}
return filteredItems;
};
private _getAdditionalItems = (
searchString?: string
private _searchFn = (
searchString: string,
filteredItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
if (!searchString) {
return [];
}
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentId =
currentItem?.type === "text" && currentItem.text
? this._customNameOption(currentItem.text).id
: undefined;
// Don't add if it's the same as the current item being edited
if (
currentItem?.type === "text" &&
currentItem.text &&
currentItem.text === searchString
) {
return [];
}
// Always return custom name option when there's a search string
// This prevents "No matching items found" from showing
return [this._customNameOption(searchString)];
};
private _searchFn = (
search: string,
filteredItems: PickerComboBoxItem[],
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
// Remove NO_ITEMS_AVAILABLE_ID if we have additional items (custom name option)
// This prevents "No matching items found" from showing when custom values are allowed
const hasAdditionalItems = this._getAdditionalItems(search).length > 0;
if (hasAdditionalItems) {
return filteredItems.filter(
(item) => typeof item !== "string" || item !== NO_ITEMS_AVAILABLE_ID
);
// Remove custom name option if search string is present to avoid duplicates
if (searchString && currentId) {
return filteredItems.filter((item) => item.id !== currentId);
}
return filteredItems;
};

View File

@@ -19,7 +19,10 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import {
NO_ITEMS_AVAILABLE_ID,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import "../ha-sortable";
const HIDDEN_ATTRIBUTES = [
@@ -199,11 +202,7 @@ export class HaStateContentPicker extends LitElement {
.value=${this._getPickerValue()}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
)}
.searchFn=${this._searchFn}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
@@ -328,7 +327,7 @@ export class HaStateContentPicker extends LitElement {
(text: string): PickerComboBoxItem => ({
id: text,
primary: this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
"ui.components.entity.entity-state-content-picker.custom_attribute"
),
secondary: `"${text}"`,
search_labels: {
@@ -340,10 +339,7 @@ export class HaStateContentPicker extends LitElement {
})
);
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
private _getFilteredItems = (): PickerComboBoxItem[] => {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
@@ -358,11 +354,7 @@ export class HaStateContentPicker extends LitElement {
);
// When editing an existing custom value, include it in the base items
if (
currentValue &&
!items.find((item) => item.id === currentValue) &&
!searchString
) {
if (currentValue && !items.find((item) => item.id === currentValue)) {
filteredItems.push(this._customValueOption(currentValue));
}
@@ -372,33 +364,34 @@ export class HaStateContentPicker extends LitElement {
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (!searchString) {
return [];
}
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
// Don't add if it's the same as the current item being edited
if (currentValue && currentValue === searchString) {
return [];
}
// Check if the search string matches an existing item
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(this.entityId, stateObj, this.allowName);
const existingItem = items.find((item) => item.id === searchString);
// Only return custom value option if it doesn't match an existing item
if (!existingItem) {
// If the search string does not match with the id of any of the items,
// offer to add it as a custom attribute
const existingItem = items.find((item) => item.id === searchString);
if (searchString && !existingItem) {
return [this._customValueOption(searchString)];
}
return [];
};
private _searchFn = (
search: string,
filteredItems: PickerComboBoxItem[],
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
if (!search) {
return filteredItems;
}
// Always exclude NO_ITEMS_AVAILABLE_ID (since custom values are allowed) and currentValue (the custom value being edited)
return filteredItems.filter((item) => item.id !== NO_ITEMS_AVAILABLE_ID);
};
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;

View File

@@ -7,6 +7,7 @@ import { getStates } from "../../common/entity/get_states";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@customElement("ha-entity-state-picker")
export class HaEntityStatePicker extends LitElement {
@@ -108,6 +109,12 @@ export class HaEntityStatePicker extends LitElement {
this.extraOptions
);
private _valueRenderer: PickerValueRenderer = (value: string) => {
const items = this._getFilteredItems();
const item = items.find((option) => option.id === value);
return html`<span slot="headline">${item?.primary ?? value}</span>`;
};
protected render() {
if (!this.hass) {
return nothing;
@@ -125,6 +132,7 @@ export class HaEntityStatePicker extends LitElement {
.helper=${this.helper}
.value=${this.value}
.getItems=${this._getFilteredItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-picker.add_custom_state"

View File

@@ -124,9 +124,6 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
@@ -173,20 +170,6 @@ export class HaIconPicker extends LitElement {
}
}
// Allow preview for custom icon not in list
if (rankedItems.length === 0) {
rankedItems.push({
item: {
id: filter,
primary: filter,
icon: filter,
search_labels: { keyword: filter },
sorting_label: filter,
},
rank: 0,
});
}
return rankedItems
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);

View File

@@ -55,6 +55,7 @@ export interface PickerComboBoxItem {
}
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
@@ -108,7 +109,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
) => PickerComboBoxItem[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -150,7 +151,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: (PickerComboBoxItem | string)[] = [];
@state() private _items: PickerComboBoxItem[] = [];
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
@@ -160,7 +161,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _valuePinned = true;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _allItems: PickerComboBoxItem[] = [];
private _selectedItemIndex = -1;
@@ -278,8 +279,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._sectionTitle = this.sectionTitleFunction({
firstIndex: ev.first,
lastIndex: ev.last,
firstItem: firstItem as PickerComboBoxItem | string,
secondItem: secondItem as PickerComboBoxItem | string,
firstItem: firstItem as PickerComboBoxItem,
secondItem: secondItem as PickerComboBoxItem,
itemsCount: this._virtualizerElement.items.length,
});
}
@@ -323,28 +324,28 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
if (!items.length) {
items.push(NO_ITEMS_AVAILABLE_ID);
if (!items.length && !this.allowCustomValue) {
items.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
items.push({ id: PADDING_ID, primary: "" }); // padding for safe area inset
}
return items;
};
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
private _renderItem = (item: PickerComboBoxItem, index: number) => {
if (!item) {
return nothing;
}
if (item === "padding") {
if (item.id === PADDING_ID) {
return html`<div class="bottom-padding"></div>`;
}
if (item === NO_ITEMS_AVAILABLE_ID) {
if (item.id === NO_ITEMS_AVAILABLE_ID) {
return html`
<div class="combo-box-row">
<ha-combo-box-item type="text" compact>
@@ -419,21 +420,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const index = this._fuseIndex(
this._allItems as PickerComboBoxItem[],
this.searchKeys
);
const index = this._fuseIndex(this._allItems, this.searchKeys);
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems as PickerComboBoxItem[],
this._allItems,
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
) as (PickerComboBoxItem | string)[];
);
if (!filteredItems.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
if (!filteredItems.length && !this.allowCustomValue) {
filteredItems.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
const additionalItems = this._getAdditionalItems(searchString);
@@ -442,8 +440,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems as PickerComboBoxItem[],
this._allItems as PickerComboBoxItem[]
filteredItems,
this._allItems
);
}
@@ -459,7 +457,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
this._items = filteredItems as PickerComboBoxItem[];
this._items = filteredItems;
}
this._selectedItemIndex = -1;

View File

@@ -1,10 +1,12 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { StateSelector } from "../../data/selector";
import { extractFromTarget } from "../../data/target";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../entity/ha-entity-state-picker";
import "../entity/ha-entity-states-picker";
@@ -32,6 +34,21 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
@state() private _entityIds?: string | string[];
private _convertExtraOptions = memoizeOne(
(
extraOptions?: { label: string; value: any }[]
): PickerComboBoxItem[] | undefined => {
if (!extraOptions) {
return undefined;
}
return extraOptions.map((option) => ({
id: option.value,
primary: option.label,
sorting_label: option.label,
}));
}
);
willUpdate(changedProps) {
if (changedProps.has("selector") || changedProps.has("context")) {
this._resolveEntityIds(
@@ -45,6 +62,9 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
}
protected render() {
const extraOptions = this._convertExtraOptions(
this.selector.state?.extra_options
);
if (this.selector.state?.multiple) {
return html`
<ha-entity-states-picker
@@ -52,7 +72,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
.extraOptions=${extraOptions}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -69,7 +89,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
.extraOptions=${extraOptions}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}

View File

@@ -16,6 +16,8 @@ export interface RecorderInfo {
export type StatisticType = "change" | "state" | "sum" | "min" | "max" | "mean";
export type StatisticPeriod = "5minute" | "hour" | "day" | "week" | "month";
export type Statistics = Record<string, StatisticValue[]>;
export interface StatisticValue {
@@ -174,7 +176,7 @@ export const fetchStatistics = (
startTime: Date,
endTime?: Date,
statistic_ids?: string[],
period: "5minute" | "hour" | "day" | "week" | "month" = "hour",
period: StatisticPeriod = "hour",
units?: StatisticsUnitConfiguration,
types?: StatisticsTypes
) =>

View File

@@ -28,6 +28,7 @@ window.loadES5Adapter = () => {
};
let panelEl: HTMLElement | undefined;
let initialized = false;
function setProperties(properties) {
if (!panelEl) {
@@ -128,13 +129,23 @@ function initialize(
});
}
document.addEventListener(
"DOMContentLoaded",
() => window.parent.customPanel!.registerIframe(initialize, setProperties),
{ once: true }
);
function handleReady() {
if (initialized) return;
initialized = true;
window.parent.customPanel!.registerIframe(initialize, setProperties);
}
window.addEventListener("unload", () => {
// Initial load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", handleReady, { once: true });
} else {
handleReady();
}
window.addEventListener("pageshow", handleReady);
window.addEventListener("pagehide", () => {
initialized = false;
// allow disconnected callback to fire
while (document.body.lastChild) {
document.body.removeChild(document.body.lastChild);

View File

@@ -1,10 +1,16 @@
import { consume } from "@lit/context";
import { mdiAlert, mdiFormatListBulleted, mdiShape } from "@mdi/js";
import {
mdiAlert,
mdiCodeBraces,
mdiFormatListBulleted,
mdiShape,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../../../common/array/ensure-array";
import { transform } from "../../../../common/decorators/transform";
import { isTemplate } from "../../../../common/string/has-template";
import "../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../data/config_entries";
import {
@@ -167,6 +173,16 @@ export class HaAutomationRowTargets extends LitElement {
);
}
// Check if the target is a template
if (isTemplate(targetId)) {
return this._renderTargetBadge(
html`<ha-svg-icon .path=${mdiCodeBraces}></ha-svg-icon>`,
this.localize(
"ui.panel.config.automation.editor.target_summary.template"
)
);
}
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
return this._renderTargetBadge(

View File

@@ -5,8 +5,9 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import "../../../../components/ha-alert";
import "../../../../components/ha-area-picker";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-labels-picker";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
@@ -19,6 +20,8 @@ import type { DeviceRegistryDetailDialogParams } from "./show-dialog-device-regi
class DialogDeviceRegistryDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _nameByUser!: string;
@state() private _error?: string;
@@ -42,10 +45,15 @@ class DialogDeviceRegistryDetail extends LitElement {
this._areaId = this._params.device.area_id || "";
this._labels = this._params.device.labels || [];
this._disabledBy = this._params.device.disabled_by;
this._open = true;
await this.updateComplete;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -57,10 +65,12 @@ class DialogDeviceRegistryDetail extends LitElement {
}
const device = this._params.device;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${computeDeviceNameDisplay(device, this.hass)}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${computeDeviceNameDisplay(device, this.hass)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div>
${this._error
@@ -68,6 +78,7 @@ class DialogDeviceRegistryDetail extends LitElement {
: ""}
<div class="form">
<ha-textfield
autofocus
.value=${this._nameByUser}
@input=${this._nameChanged}
.label=${this.hass.localize(
@@ -75,7 +86,6 @@ class DialogDeviceRegistryDetail extends LitElement {
)}
.placeholder=${device.name || ""}
.disabled=${this._submitting}
dialogInitialFocus
></ha-textfield>
<ha-area-picker
.hass=${this.hass}
@@ -131,22 +141,25 @@ class DialogDeviceRegistryDetail extends LitElement {
</div>
</div>
</div>
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}

View File

@@ -302,10 +302,11 @@ class DialogZWaveJSUpdateFirmwareNode extends LitElement {
</div>
${this._updateFinishedMessage!.success
? html`<p>
${this.hass.localize(
`ui.panel.config.zwave_js.update_firmware.finished_status.done${localizationKeySuffix}`
)}
</p>`
${this.hass.localize(
`ui.panel.config.zwave_js.update_firmware.finished_status.done${localizationKeySuffix}`
)}
</p>
${closeButton}`
: html`<p>
${this.hass.localize(
"ui.panel.config.zwave_js.update_firmware.finished_status.try_again"

View File

@@ -2,6 +2,8 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
@@ -26,6 +28,11 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const INTERVAL = 1000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -40,27 +47,32 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
@state() private _year = "";
@state() private _month = "";
@state() private _day = "";
private _tickInterval?: undefined | number;
private _currentDate = new Date();
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeDateTime();
if (this.config?.date && this.config.date !== "none") {
this._startTick();
}
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeOffsets();
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
this._stopTick();
}
protected updated(changedProps: PropertyValues) {
@@ -72,30 +84,78 @@ export class HuiClockCardAnalog extends LitElement {
}
}
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
}
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._computeDateTime();
}
};
private _computeOffsets() {
private _initDate() {
if (!this.config || !this.hass) {
return;
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
...(this.config.date && this.config.date !== "none"
? this.config.date === "day"
? {
day: "numeric",
}
: this.config.date === "day-month"
? {
month: "short",
day: "numeric",
}
: this.config.date === "day-month-long"
? {
month: "long",
day: "numeric",
}
: {
year: "numeric",
month: this.config.date === "long" ? "long" : "short",
day: "numeric",
}
: {}),
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeDateTime();
}
private _startTick() {
this._tick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
}
private _stopTick() {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = undefined;
}
}
private _updateDate() {
this._currentDate = new Date();
}
private _computeDateTime() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
this._updateDate();
const parts = this._dateTimeFormat.formatToParts(this._currentDate);
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -103,7 +163,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const ms = this._currentDate.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -111,18 +171,36 @@ export class HuiClockCardAnalog extends LitElement {
this._secondOffsetSec = secondsWithMs;
this._minuteOffsetSec = minute * 60 + secondsWithMs;
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
// Also update date parts if date is shown
if (this.config?.date && this.config.date !== "none") {
this._year = parts.find((p) => p.type === "year")?.value ?? "";
this._month = parts.find((p) => p.type === "month")?.value ?? "";
this._day = parts.find((p) => p.type === "day")?.value ?? "";
}
}
private _tick() {
this._computeDateTime();
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
};
});
render() {
if (!this.config) return nothing;
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const { sizeClass, isNumbers, isRoman, isUpright } = this._computeClock(
this.config
);
const indicator = (number?: number) => html`
<div
@@ -163,14 +241,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -178,28 +256,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -208,14 +288,27 @@ export class HuiClockCardAnalog extends LitElement {
`
)
: nothing}
${this.config?.date && this.config.date !== "none"
? html`<div class="date-parts ${sizeClass}">
<span class="date-part day-month"
>${this._day} ${this._month}</span
>
<span class="date-part year">${this._year}</span>
</div>`
: nothing}
<div class="center-dot"></div>
<div
class="hand hour"
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${this.config.show_seconds
? html`<div
@@ -224,11 +317,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s`,
})}
></div>`
: nothing}
</div>
@@ -407,6 +502,41 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date-parts {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: grid;
align-items: center;
grid-template-areas:
"day-month"
"year";
direction: ltr;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
}
.date-parts.size-medium {
font-size: var(--ha-font-size-l);
}
.date-parts.size-large {
font-size: var(--ha-font-size-xl);
}
.date-part.day-month {
grid-area: day-month;
}
.date-part.year {
grid-area: year;
}
`;
}

View File

@@ -24,6 +24,8 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _initDate() {
@@ -39,6 +41,27 @@ export class HuiClockCardDigital extends LitElement {
const h12 = useAmPm(locale);
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
...(this.config.date && this.config.date !== "none"
? this.config.date === "day"
? {
day: "numeric",
}
: this.config.date === "day-month"
? {
month: "short",
day: "numeric",
}
: this.config.date === "day-month-long"
? {
month: "long",
day: "numeric",
}
: {
year: "numeric",
month: this.config.date === "long" ? "long" : "short",
day: "numeric",
}
: {}),
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
@@ -93,6 +116,16 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._date = this.config?.date
? [
parts.find((part) => part.type === "day")?.value,
parts.find((part) => part.type === "month")?.value,
parts.find((part) => part.type === "year")?.value,
]
.filter(Boolean)
.join(" ")
: undefined;
}
render() {
@@ -113,6 +146,9 @@ export class HuiClockCardDigital extends LitElement {
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
</div>
${this.config.date && this.config.date !== "none"
? html`<div class="date ${sizeClass}">${this._date}</div>`
: nothing}
`;
}
@@ -188,6 +224,20 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}

View File

@@ -30,35 +30,33 @@ import {
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
export function getSuggestedMax(
dayDifference: number,
end: Date,
detailedDailyData = false
): number {
export function getSuggestedMax(period: StatisticPeriod, end: Date): number {
let suggestedMax = new Date(end);
if (period === "5minute") {
return suggestedMax.getTime();
}
suggestedMax.setMinutes(0, 0, 0);
if (period === "hour") {
return suggestedMax.getTime();
}
// Sometimes around DST we get a time of 0:59 instead of 23:59 as expected.
// Correct for this when showing days/months so we don't get an extra day.
if (dayDifference > 2 && suggestedMax.getHours() === 0) {
if (suggestedMax.getHours() === 0) {
suggestedMax = subHours(suggestedMax, 1);
}
if (!detailedDailyData) {
suggestedMax.setMinutes(0, 0, 0);
}
if (dayDifference > 35) {
suggestedMax.setDate(1);
}
if (dayDifference > 2) {
suggestedMax.setHours(0);
suggestedMax.setHours(0);
if (period === "day" || period === "week") {
return suggestedMax.getTime();
}
// period === month
suggestedMax.setDate(1);
return suggestedMax.getTime();
}
export function getSuggestedPeriod(
dayDifference: number
): "month" | "day" | "hour" {
export function getSuggestedPeriod(dayDifference: number): StatisticPeriod {
return dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
}
@@ -96,7 +94,10 @@ export function getCommonOptions(
xAxis: {
type: "time",
min: start,
max: getSuggestedMax(dayDifference, end, detailedDailyData),
max: getSuggestedMax(
detailedDailyData ? "5minute" : getSuggestedPeriod(dayDifference),
end
),
},
yAxis: {
type: "value",

View File

@@ -334,10 +334,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.maxYAxis=${this._config.max_y_axis}
.startTime=${this._energyStart}
.endTime=${this._energyEnd && this._energyStart
? getSuggestedMax(
differenceInDays(this._energyEnd, this._energyStart),
this._energyEnd
)
? getSuggestedMax(this._period!, this._energyEnd)
: undefined}
.fitYData=${this._config.fit_y_data || false}
.hideLegend=${this._config.hide_legend || false}

View File

@@ -417,6 +417,7 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date?: "none" | "short" | "long" | "day" | "day-month" | "day-month-long";
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";

View File

@@ -39,6 +39,19 @@ const cardConfigStruct = assign(
time_zone: optional(enums(Object.keys(timezones))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date: optional(
defaulted(
union([
literal("none"),
literal("short"),
literal("long"),
literal("day"),
literal("day-month"),
literal("day-month-long"),
]),
literal("none")
)
),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
@@ -93,7 +106,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,6 +132,27 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date",
selector: {
select: {
mode: "dropdown",
options: [
"none",
"short",
"long",
"day",
"day-month",
"day-month-long",
].map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.card.clock.dates.${value}`
),
})),
},
},
},
...(clockStyle === "digital"
? ([
{
@@ -260,13 +294,14 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
private _data = memoizeOne((config: ClockCardConfig) => ({
clock_style: "digital",
clock_size: "small",
time_zone: "auto",
time_format: "auto",
show_seconds: false,
no_background: false,
date: "none",
// Analog clock options
border: false,
ticks: "hour",
@@ -290,8 +325,9 @@ export class HuiClockCardEditor
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config)
.clock_style as ClockCardConfig["clock_style"],
this._data(this._config).ticks as ClockCardConfig["ticks"],
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
@@ -367,6 +403,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -392,6 +432,10 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`

View File

@@ -137,9 +137,9 @@
},
"counter": {
"actions": {
"increment": "increment",
"decrement": "decrement",
"reset": "reset"
"increment": "Increment",
"decrement": "Decrement",
"reset": "Reset"
}
},
"cover": {
@@ -672,8 +672,8 @@
"device_missing": "No related device"
},
"add": "Add",
"custom_name": "Custom name",
"no_match": "No entities found"
"search": "Search or enter custom name",
"custom_name": "Custom name"
},
"entity-attribute-picker": {
"attribute": "Attribute",
@@ -685,7 +685,7 @@
},
"entity-state-content-picker": {
"add": "Add",
"custom_state": "Custom state"
"custom_attribute": "Custom attribute"
}
},
"target-picker": {
@@ -772,9 +772,6 @@
"no_match": "No languages found for {term}",
"no_languages": "No languages available"
},
"icon-picker": {
"no_match": "No matching icons found"
},
"tts-picker": {
"tts": "Text-to-speech",
"none": "None"
@@ -4119,7 +4116,8 @@
"targets": "{count} {count, plural,\n one {target}\n other {targets}\n}",
"invalid": "Invalid target",
"all_entities": "All entities",
"none_entities": "No entities"
"none_entities": "No entities",
"template": "Template"
},
"triggers": {
"name": "Triggers",
@@ -7345,7 +7343,7 @@
"energy_usage_graph": {
"total_consumed": "Total consumed {num} kWh",
"total_returned": "Total returned {num} kWh",
"total_usage": "{num} kWh used",
"total_usage": "+{num} kWh",
"combined_from_grid": "Combined from grid",
"consumed_solar": "Consumed solar",
"consumed_battery": "Consumed battery"
@@ -8213,6 +8211,18 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Whether to show the date on the clock. Can also be a custom date format."
},
"dates": {
"none": "No date",
"short": "Short",
"long": "Long",
"day": "Day only",
"day-month": "Day and month",
"day-month-long": "Day and month (long)"
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",