Compare commits

..

17 Commits

Author SHA1 Message Date
Aidan Timson
45ccdefc01 Scale small based on date length 2026-02-09 12:01:04 +00:00
Aidan Timson
12f5679dd1 Sizing (CSS Impl) 2026-02-09 12:01:04 +00:00
Aidan Timson
5483acbce4 Sizing (JS Impl) 2026-02-09 12:01:04 +00:00
Aidan Timson
4c02b5ea9e Format 2026-02-09 12:01:04 +00:00
Aidan Timson
e0d8a52fe8 Add 2026-02-09 12:01:04 +00:00
Aidan Timson
f4f69535c8 Improve 2026-02-09 12:01:04 +00:00
Aidan Timson
454a6f911c Type 2026-02-09 12:01:04 +00:00
Aidan Timson
b62cc8e24e Setup 2026-02-09 12:01:04 +00:00
Aidan Timson
266942665f Match 2026-02-09 12:01:04 +00:00
Aidan Timson
264a1edc8c Setup analog clock 2026-02-09 12:01:04 +00:00
Aidan Timson
2959324aab Add date to digital clock 2026-02-09 12:01:04 +00:00
Aidan Timson
e91acda0a4 Setup 2026-02-09 12:01:04 +00:00
Aidan Timson
2baa044db5 Migrate profile dialogs to wa, refactor LL access token dialog (#29440)
* Migrate profile dialog(s) to wa

* Make sure code is entered before submit is allowed

* Refactor dialog

* Remove unused params

* Pass existing names and validate name is not already used

* Reduce cleanup on show

* Make QR image larger

* max width

* Fix

* Remove extra event fire

* Make params required

* cleanup

* Cleanup

* Fix

* Fix
2026-02-09 12:54:02 +01:00
Wendelin
3d04046bcc Migrate automation picker row to ha-dropdown (#29428)
* Update @home-assistant/webawesome to version 3.2.1 and refactor ha-dropdown integration in automation picker

* review

* revert wa update

* Update @home-assistant/webawesome to version 3.0.0-ha.2 in yarn.lock
2026-02-09 12:35:49 +01:00
Tom Carpenter
8e860cb17d Improve energy dashboard monthly/this-quarter chart time axes (#29435)
* Add splitNumber option to monthly ECharts

When there are a small number of bars (<=3) for monthly data, set the splitNumber parameter to force the date x-axis to show whole months.

* Add axis tick fomratting for short months

This ensures that the month format is consistent between 2/3 month and longer ranges.

* Avoid calling getSuggestedMax twice

* Fix another case of power chart cutting off last hour of data

The previous fix only solved the problem for 5-minute data, not hourly or daily. This should solve the issue regardless, and allows the energy chart to have other line-based plots in the future.

* Update other uses of getSuggestedMax()

* Fix statistics-chart Last Period Rendering

1. When appending the "current state" value, if the current time intersects with the final period, we can end up with the chart folding back on itself. This is fixed by ensuring for the final period we push the earlier of the statistic end time and the display end time (which is in turn limited to now).

2. Always close off the last data point at the chart end time. Otherwise for line charts, the final period doesn't get rendered.

* Remove unused monthStyle formatter.

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Rename getSuggestedMax function parameter in energy chart

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Document magic numbers in montly energy chart

* Make padding a constant for clarity.
* Explain the purpose of splitNumber.

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-09 13:05:42 +02:00
Petar Petrov
c41d7ff923 Fix history-graph card rendering stale data point on left edge (#29499)
When HistoryStream.processMessage() prunes expired history and preserves
the last expired state as a boundary marker, it updates lu (last_updated)
but not lc (last_changed). Chart components use lc preferentially, so
when lc is present the boundary point gets plotted at the original stale
timestamp far to the left of the visible window. Delete lc from the
boundary state so the chart uses the corrected lu timestamp.
2026-02-09 10:13:06 +01:00
Matthias de Baat
c22fc1021a Counter gravity effect on the Matter icon (#29459) 2026-02-09 10:05:06 +01:00
15 changed files with 935 additions and 345 deletions

View File

@@ -572,6 +572,7 @@ export class StatisticsChart extends LitElement {
let firstSum: number | null | undefined = null;
stats.forEach((stat) => {
const startDate = new Date(stat.start);
const endDate = new Date(stat.end);
if (prevDate === startDate) {
return;
}
@@ -601,10 +602,25 @@ export class StatisticsChart extends LitElement {
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(startDate, new Date(stat.end), dataValues);
pushData(
startDate,
endDate.getTime() < endTime.getTime() ? endDate : endTime,
dataValues
);
}
});
// Close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push(
this._transformDataValue([lastEndTime, ...lastValues[i]!])
);
});
}
// Append current state if viewing recent data
const now = new Date();
// allow 10m of leeway for "now", because stats are 5 minute aggregated
@@ -619,16 +635,6 @@ export class StatisticsChart extends LitElement {
isFinite(currentValue) &&
!this._hiddenStats.has(statistic_id)
) {
// First, close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push(
this._transformDataValue([lastEndTime, ...lastValues[i]!])
);
});
}
// Then push the current state at now
statTypes.forEach((type, i) => {
const val: (number | null)[] = [];

View File

@@ -1,3 +1,4 @@
import type WaButton from "@home-assistant/webawesome/dist/components/button/button";
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
@@ -22,11 +23,37 @@ export type HaDropdownSelectEvent<T = string> = CustomEvent<{
*
*/
@customElement("ha-dropdown")
// @ts-ignore Allow to set an alternative anchor element
export class HaDropdown extends Dropdown {
@property({ attribute: false }) dropdownTag = "ha-dropdown";
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
public get anchorElement(): HTMLButtonElement | WaButton | undefined {
// @ts-ignore Allow to set an anchor element on popup
return this.popup?.anchor as HTMLButtonElement | WaButton | undefined;
}
public set anchorElement(element: HTMLButtonElement | WaButton | undefined) {
// @ts-ignore Allow to get the current anchor element from popup
if (!this.popup) {
return;
}
// @ts-ignore Allow to get the current anchor element from popup
this.popup.anchor = element;
}
/** Get the slotted trigger button, a <wa-button> or <button> element */
// @ts-ignore Override parent method to be able to use alternative anchor
// eslint-disable-next-line @typescript-eslint/naming-convention
private override getTrigger(): HTMLButtonElement | WaButton | null {
if (this.anchorElement) {
return this.anchorElement;
}
// @ts-ignore fallback to default trigger slot if no anchorElement is set
return super.getTrigger();
}
static get styles(): CSSResultGroup {
return [
Dropdown.styles,

View File

@@ -47,6 +47,10 @@ import type {
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-dropdown";
import type {
HaDropdown,
HaDropdownSelectEvent,
} from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-filter-blueprints";
@@ -57,10 +61,6 @@ import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-menu";
import type { HaMdMenu } from "../../../components/ha-md-menu";
import "../../../components/ha-md-menu-item";
import type { HaMdMenuItem } from "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
@@ -84,9 +84,9 @@ import { fullEntitiesContext } from "../../../data/context";
import type { DataTableFilters } from "../../../data/data_table_filters";
import {
deserializeFilters,
serializeFilters,
isFilterUsed,
isRelatedItemsFilterUsed,
serializeFilters,
} from "../../../data/data_table_filters";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type {
@@ -111,16 +111,16 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route, ServiceCallResponse } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity";
import {
getEntityIdHiddenTableColumn,
getAreaTableColumn,
getCategoryTableColumn,
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import {
getAreaTableColumn,
getCategoryTableColumn,
getEntityIdHiddenTableColumn,
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
@@ -129,7 +129,6 @@ import {
} from "../voice-assistants/expose/assistants-table-column";
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
type AutomationItem = AutomationEntity & {
name: string;
@@ -223,7 +222,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@query("#overflow-menu") private _overflowMenu!: HaMdMenu;
@query("#overflow-menu") private _overflowMenu!: HaDropdown;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
@@ -233,6 +232,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
return getAvailableAssistants(this.cloudStatus, this.hass);
}
private _openingOverflow = false;
private _automations = memoizeOne(
(
automations: AutomationEntity[],
@@ -371,16 +372,27 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
);
private _showOverflowMenu = (ev) => {
if (
this._overflowMenu.open &&
ev.target === this._overflowMenu.anchorElement
) {
this._overflowMenu.close();
if (this._overflowMenu.anchorElement === ev.target) {
this._overflowMenu.anchorElement = undefined;
return;
}
this._overflowAutomation = ev.target.automation;
this._openingOverflow = true;
this._overflowMenu.anchorElement = ev.target;
this._overflowMenu.show();
this._overflowAutomation = ev.target.automation;
this._overflowMenu.open = true;
};
private _overflowMenuOpened = () => {
this._openingOverflow = false;
};
private _overflowMenuClosed = () => {
// changing the anchorElement triggers a close event, ignore it
if (this._openingOverflow) {
return;
}
this._overflowMenu.anchorElement = undefined;
};
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@@ -697,74 +709,58 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
<ha-md-menu id="overflow-menu" positioning="fixed">
<ha-md-menu-item .clickAction=${this._showInfo}>
<ha-svg-icon
.path=${mdiInformationOutline}
slot="start"
></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.automation.editor.show_info")}
</div>
</ha-md-menu-item>
<ha-dropdown
id="overflow-menu"
@wa-select=${this._handleOverflowAction}
@wa-after-show=${this._overflowMenuOpened}
@wa-after-hide=${this._overflowMenuClosed}
>
<ha-dropdown-item value="show_info">
<ha-svg-icon .path=${mdiInformationOutline} slot="icon"></ha-svg-icon>
${this.hass.localize("ui.panel.config.automation.editor.show_info")}
</ha-dropdown-item>
<ha-md-menu-item .clickAction=${this._showSettings}>
<ha-svg-icon .path=${mdiCog} slot="start"></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.show_settings"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._editCategory}>
<ha-svg-icon .path=${mdiTag} slot="start"></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.automation.picker.${this._overflowAutomation?.category ? "edit_category" : "assign_category"}`
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._runActions}>
<ha-svg-icon .path=${mdiPlay} slot="start"></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.automation.editor.run")}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._showTrace}>
<ha-svg-icon .path=${mdiTransitConnection} slot="start"></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.editor.show_trace"
)}
</div>
</ha-md-menu-item>
<ha-dropdown-item value="show_settings">
<ha-svg-icon .path=${mdiCog} slot="icon"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.automation.picker.show_settings"
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_category">
<ha-svg-icon .path=${mdiTag} slot="icon"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.automation.picker.${this._overflowAutomation?.category ? "edit_category" : "assign_category"}`
)}
</ha-dropdown-item>
<ha-dropdown-item value="run_actions">
<ha-svg-icon .path=${mdiPlay} slot="icon"></ha-svg-icon>
${this.hass.localize("ui.panel.config.automation.editor.run")}
</ha-dropdown-item>
<ha-dropdown-item value="show_trace">
<ha-svg-icon .path=${mdiTransitConnection} slot="icon"></ha-svg-icon>
${this.hass.localize("ui.panel.config.automation.editor.show_trace")}
</ha-dropdown-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._duplicate}>
<ha-svg-icon .path=${mdiContentDuplicate} slot="start"></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.automation.picker.duplicate")}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._toggle}>
<ha-dropdown-item value="duplicate">
<ha-svg-icon .path=${mdiContentDuplicate} slot="icon"></ha-svg-icon>
${this.hass.localize("ui.panel.config.automation.picker.duplicate")}
</ha-dropdown-item>
<ha-dropdown-item value="toggle">
<ha-svg-icon
.path=${this._overflowAutomation?.state === "off"
? mdiToggleSwitch
: mdiToggleSwitchOffOutline}
slot="start"
slot="icon"
></ha-svg-icon>
<div slot="headline">
${this._overflowAutomation?.state === "off"
? this.hass.localize("ui.panel.config.automation.editor.enable")
: this.hass.localize("ui.panel.config.automation.editor.disable")}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._deleteConfirm} class="warning">
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
<div slot="headline">
${this.hass.localize("ui.panel.config.automation.picker.delete")}
</div>
</ha-md-menu-item>
</ha-md-menu>
${this._overflowAutomation?.state === "off"
? this.hass.localize("ui.panel.config.automation.editor.enable")
: this.hass.localize("ui.panel.config.automation.editor.disable")}
</ha-dropdown-item>
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon .path=${mdiDelete} slot="icon"></ha-svg-icon>
${this.hass.localize("ui.panel.config.automation.picker.delete")}
</ha-dropdown-item>
</ha-dropdown>
`;
}
@@ -901,33 +897,59 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this._applyFilters();
}
private _showInfo = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
fireEvent(this, "hass-more-info", { entityId: automation.entity_id });
private _handleOverflowAction = (ev: HaDropdownSelectEvent) => {
const action = ev.detail.item.value;
if (!action || !this._overflowAutomation) {
return;
}
switch (action) {
case "show_info":
this._showInfo(this._overflowAutomation);
break;
case "show_settings":
this._showSettings(this._overflowAutomation);
break;
case "edit_category":
this._editCategory(this._overflowAutomation);
break;
case "run_actions":
this._runActions(this._overflowAutomation);
break;
case "show_trace":
this._showTrace(this._overflowAutomation);
break;
case "toggle":
this._toggle(this._overflowAutomation);
break;
case "delete":
this._deleteConfirm(this._overflowAutomation);
break;
case "duplicate":
this._duplicate(this._overflowAutomation);
break;
}
};
private _showSettings = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _showInfo = (automation: AutomationItem) => {
fireEvent(this, "hass-more-info", {
entityId: automation.entity_id,
});
};
private _showSettings = (automation: AutomationItem) => {
fireEvent(this, "hass-more-info", {
entityId: automation.entity_id,
view: "settings",
});
};
private _runActions = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _runActions = (automation: AutomationItem) => {
triggerAutomationActions(this.hass, automation.entity_id);
};
private _editCategory = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _editCategory = (automation: AutomationItem) => {
const entityReg = this._entityReg.find(
(reg) => reg.entity_id === automation.entity_id
);
@@ -948,10 +970,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
});
};
private _showTrace = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _showTrace = (automation: AutomationItem) => {
if (!automation.attributes.id) {
showAlertDialog(this, {
text: this.hass.localize(
@@ -965,20 +984,14 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
);
};
private _toggle = async (item: HaMdMenuItem): Promise<void> => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _toggle = async (automation: AutomationItem): Promise<void> => {
const service = automation.state === "off" ? "turn_on" : "turn_off";
await this.hass.callService("automation", service, {
entity_id: automation.entity_id,
});
};
private _deleteConfirm = async (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _deleteConfirm = async (automation: AutomationItem) => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.picker.delete_confirm_title"
@@ -994,9 +1007,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
});
};
private async _delete(automation) {
private async _delete(automation: AutomationItem) {
try {
await deleteAutomation(this.hass, automation.attributes.id);
await deleteAutomation(this.hass, automation.attributes.id!);
this._selected = this._selected.filter(
(entityId) => entityId !== automation.entity_id
);
@@ -1015,14 +1028,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
}
}
private _duplicate = async (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _duplicate = async (automation: AutomationItem) => {
try {
const config = await fetchAutomationFileConfig(
this.hass,
automation.attributes.id
automation.attributes.id!
);
duplicateAutomation(config);
} catch (err: any) {

View File

@@ -118,6 +118,7 @@ export const configSections: Record<string, PageNavigation[]> = {
path: "/config/matter",
iconPath:
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
iconViewBox: "0 1 24 24",
iconColor: "#2458B3",
component: "matter",
translationKey: "matter",

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,38 @@ 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("_");
const isLongDate =
config.date === "day-month-long" || config.date === "long";
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
isLongDate,
};
});
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, isLongDate } =
this._computeClock(this.config);
const indicator = (number?: number) => html`
<div
@@ -163,14 +243,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 +258,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 +290,33 @@ export class HuiClockCardAnalog extends LitElement {
`
)
: nothing}
${this.config?.date && this.config.date !== "none"
? html`<div
class=${classMap({
"date-parts": true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
<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 +325,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>
@@ -270,6 +373,14 @@ export class HuiClockCardAnalog extends LitElement {
box-sizing: border-box;
}
/* Modern browsers: Use container queries for responsive font sizing */
@supports (container-type: inline-size) {
.dial {
container-type: inline-size;
container-name: clock;
}
}
.dial-border {
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-circle);
@@ -407,6 +518,78 @@ 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;
max-width: 87%;
overflow: hidden;
white-space: nowrap;
}
/* Modern browsers: Use container queries for responsive font sizing */
@supports (container-type: inline-size) {
/* Small clock with long date: reduce to xs */
@container clock (max-width: 139px) {
.date-parts.long-date {
font-size: var(--ha-font-size-xs);
}
}
/* Medium clock: scale up */
@container clock (min-width: 140px) {
.date-parts {
font-size: var(--ha-font-size-l);
}
}
/* Large clock: scale up more */
@container clock (min-width: 200px) {
.date-parts {
font-size: var(--ha-font-size-xl);
}
}
}
/* Legacy browsers: Use existing size classes */
@supports not (container-type: inline-size) {
/* Small clock (no size class) with long date */
.date-parts.long-date:not(.size-medium):not(.size-large) {
font-size: var(--ha-font-size-xs);
}
.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;
overflow: hidden;
}
.date-part.year {
grid-area: year;
overflow: hidden;
}
`;
}

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

@@ -1,8 +1,9 @@
import type { HassConfig } from "home-assistant-js-websocket";
import {
differenceInMonths,
subHours,
differenceInDays,
differenceInMonths,
differenceInCalendarMonths,
differenceInYears,
startOfYear,
addMilliseconds,
@@ -12,6 +13,7 @@ import {
addHours,
startOfDay,
addDays,
subDays,
} from "date-fns";
import type {
BarSeriesOption,
@@ -33,10 +35,22 @@ import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getSuggestedPeriod } from "../../../../../data/energy";
export function getSuggestedMax(period: StatisticPeriod, end: Date): Date {
// Number of days of padding when showing time axis in months
const MONTH_TIME_AXIS_PADDING = 5;
export function getSuggestedMax(
period: StatisticPeriod,
end: Date,
noRounding: boolean
): Date {
// Maximum period depends on whether plotting a line chart or discrete bars.
// - For line charts we must be plotting all the way to end of a given period,
// otherwise we cut off the last period of data.
// - For bar charts we need to round down to the start of the final bars period
// to avoid unnecessary padding of the chart.
let suggestedMax = new Date(end);
if (period === "5minute") {
if (noRounding || period === "5minute") {
return suggestedMax;
}
suggestedMax.setMinutes(0, 0, 0);
@@ -82,17 +96,44 @@ export function getCommonOptions(
detailedDailyData = false
): ECOption {
const suggestedPeriod = getSuggestedPeriod(start, end, detailedDailyData);
const suggestedMax = getSuggestedMax(suggestedPeriod, end, detailedDailyData);
const compare = compareStart !== undefined && compareEnd !== undefined;
const showCompareYear =
compare && start.getFullYear() !== compareStart.getFullYear();
const options: ECOption = {
const monthTimeAxis: ECOption = {
xAxis: {
type: "time",
min: subDays(start, MONTH_TIME_AXIS_PADDING),
max: addDays(suggestedMax, MONTH_TIME_AXIS_PADDING),
axisLabel: {
formatter: {
year: "{yearStyle|{MMMM} {yyyy}}",
month: "{MMMM}",
},
rich: {
yearStyle: {
fontWeight: "bold",
},
},
},
// For shorter month ranges, force splitting to ensure time axis renders
// as whole month intervals. Limit the number of forced ticks to 6 months
// (so a max calendar difference of 5) to reduce clutter.
splitNumber: Math.min(differenceInCalendarMonths(end, start), 5),
},
};
const normalTimeAxis: ECOption = {
xAxis: {
type: "time",
min: start,
max: getSuggestedMax(suggestedPeriod, end),
max: suggestedMax,
},
};
const options: ECOption = {
...(suggestedPeriod === "month" ? monthTimeAxis : normalTimeAxis),
yAxis: {
type: "value",
name: unit,

View File

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

View File

@@ -435,6 +435,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"
? ([
{
@@ -241,12 +275,13 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
private _data = memoizeOne((config: ClockCardConfig) => ({
clock_style: "digital",
clock_size: "small",
time_format: "auto",
show_seconds: false,
no_background: false,
date: "none",
// Analog clock options
border: false,
ticks: "hour",
@@ -270,8 +305,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}
@@ -344,6 +380,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`
@@ -369,6 +409,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

@@ -1,9 +1,12 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-wa-dialog";
import "../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../components/ha-form/types";
import "../../components/ha-markdown";
import "../../components/ha-spinner";
import { autocompleteLoginFields } from "../../data/auth";
@@ -28,7 +31,7 @@ class HaMfaModuleSetupFlow extends LitElement {
@state() private _loading = false;
@state() private _opened = false;
@state() private _open = false;
@state() private _stepData: any = {};
@@ -39,7 +42,7 @@ class HaMfaModuleSetupFlow extends LitElement {
public showDialog({ continueFlowId, mfaModuleId, dialogClosedCallback }) {
this._instance = instance++;
this._dialogClosedCallback = dialogClosedCallback;
this._opened = true;
this._open = true;
const fetchStep = continueFlowId
? this.hass.callWS({
@@ -61,22 +64,29 @@ class HaMfaModuleSetupFlow extends LitElement {
}
public closeDialog() {
// Closed dialog by clicking on the overlay
this._open = false;
}
private _dialogClosed() {
if (this._step) {
this._flowDone();
return;
}
this._opened = false;
this._resetDialogState();
}
protected render() {
if (!this._opened) {
if (this._instance === undefined) {
return nothing;
}
return html`
<ha-dialog
open
.heading=${this._computeStepTitle()}
@closed=${this.closeDialog}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
prevent-scrim-close
header-title=${this._computeStepTitle()}
@closed=${this._dialogClosed}
>
<div>
${this._errorMessage
@@ -115,6 +125,7 @@ class HaMfaModuleSetupFlow extends LitElement {
)}
></ha-markdown>
<ha-form
autofocus
.hass=${this.hass}
.data=${this._stepData}
.schema=${autocompleteLoginFields(
@@ -127,31 +138,33 @@ class HaMfaModuleSetupFlow extends LitElement {
></ha-form>`
: ""}`}
</div>
<ha-button
slot="primaryAction"
@click=${this.closeDialog}
appearance=${["abort", "create_entry"].includes(
this._step?.type || ""
)
? "accent"
: "plain"}
>${this.hass.localize(
["abort", "create_entry"].includes(this._step?.type || "")
? "ui.panel.profile.mfa_setup.close"
: "ui.common.cancel"
)}</ha-button
>
${this._step?.type === "form"
? html`<ha-button
slot="primaryAction"
.disabled=${this._loading}
@click=${this._submitStep}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.submit"
)}</ha-button
>`
: nothing}
</ha-dialog>
<ha-dialog-footer slot="footer">
<ha-button
slot=${this._step?.type === "form"
? "secondaryAction"
: "primaryAction"}
appearance=${ifDefined(
this._step?.type === "form" ? "plain" : undefined
)}
@click=${this.closeDialog}
>${this.hass.localize(
["abort", "create_entry"].includes(this._step?.type || "")
? "ui.panel.profile.mfa_setup.close"
: "ui.common.cancel"
)}</ha-button
>
${this._step?.type === "form"
? html`<ha-button
slot="primaryAction"
.disabled=${this._isSubmitDisabled()}
@click=${this._submitStep}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.submit"
)}</ha-button
>`
: nothing}
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -162,9 +175,6 @@ class HaMfaModuleSetupFlow extends LitElement {
.error {
color: red;
}
ha-dialog {
max-width: 500px;
}
ha-markdown {
--markdown-svg-background-color: white;
--markdown-svg-color: black;
@@ -177,9 +187,17 @@ class HaMfaModuleSetupFlow extends LitElement {
ha-markdown-element p {
text-align: center;
}
ha-markdown-element svg {
display: block;
margin: 0 auto;
}
ha-markdown-element code {
background-color: transparent;
}
ha-form {
display: block;
margin-top: var(--ha-space-4);
}
ha-markdown-element > *:last-child {
margin-bottom: revert;
}
@@ -206,6 +224,10 @@ class HaMfaModuleSetupFlow extends LitElement {
}
private _submitStep() {
if (this._isSubmitDisabled()) {
return;
}
this._loading = true;
this._errorMessage = undefined;
@@ -234,6 +256,62 @@ class HaMfaModuleSetupFlow extends LitElement {
);
}
private _isSubmitDisabled() {
return this._loading || this._hasMissingRequiredFields();
}
private _hasMissingRequiredFields(
schema: readonly HaFormSchema[] = this._step?.type === "form"
? this._step.data_schema
: []
): boolean {
for (const field of schema) {
if ("schema" in field) {
if (this._hasMissingRequiredFields(field.schema)) {
return true;
}
continue;
}
if (!field.required) {
continue;
}
if (
field.default !== undefined ||
field.description?.suggested_value !== undefined
) {
continue;
}
if (this._isEmptyValue(this._stepData[field.name])) {
return true;
}
}
return false;
}
private _isEmptyValue(value: unknown): boolean {
if (value === undefined || value === null) {
return true;
}
if (typeof value === "string") {
return value.trim() === "";
}
if (Array.isArray(value)) {
return value.length === 0;
}
if (typeof value === "object") {
return Object.keys(value as Record<string, unknown>).length === 0;
}
return false;
}
private _processStep(step) {
if (!step.errors) step.errors = {};
this._step = step;
@@ -251,12 +329,15 @@ class HaMfaModuleSetupFlow extends LitElement {
this._dialogClosedCallback!({
flowFinished,
});
this._resetDialogState();
}
private _resetDialogState() {
this._errorMessage = undefined;
this._step = undefined;
this._stepData = {};
this._dialogClosedCallback = undefined;
this.closeDialog();
this._instance = undefined;
}
private _computeStepTitle() {

View File

@@ -1,17 +1,18 @@
import { mdiContentCopy } from "@mdi/js";
import { mdiContentCopy, mdiQrcode } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { createCloseHeading } from "../../components/ha-dialog";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { withViewTransition } from "../../common/util/view-transition";
import "../../components/ha-alert";
import "../../components/ha-textfield";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import { haStyleDialog } from "../../resources/styles";
import "../../components/ha-dialog-footer";
import "../../components/ha-svg-icon";
import "../../components/ha-wa-dialog";
import type { HomeAssistant } from "../../types";
import type { LongLivedAccessTokenDialogParams } from "./show-long-lived-access-token-dialog";
import type { HaTextField } from "../../components/ha-textfield";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { showToast } from "../../util/toast";
const QR_LOGO_URL = "/static/icons/favicon-192x192.png";
@@ -20,81 +21,221 @@ const QR_LOGO_URL = "/static/icons/favicon-192x192.png";
export class HaLongLivedAccessTokenDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LongLivedAccessTokenDialogParams;
@state() private _qrCode?: TemplateResult;
@state() private _open = false;
@state() private _renderDialog = false;
@state() private _name = "";
@state() private _token?: string;
private _createdCallback!: () => void;
private _existingNames = new Set<string>();
@state() private _loading = false;
@state() private _errorMessage?: string;
public showDialog(params: LongLivedAccessTokenDialogParams): void {
this._params = params;
this._createdCallback = params.createdCallback;
this._existingNames = new Set(
params.existingNames.map((name) => this._normalizeName(name))
);
this._renderDialog = true;
this._open = true;
}
public closeDialog() {
this._params = undefined;
this._open = false;
}
private _dialogClosed() {
this._open = false;
this._renderDialog = false;
this._name = "";
this._token = undefined;
this._existingNames = new Set();
this._errorMessage = undefined;
this._loading = false;
this._qrCode = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._params.token) {
if (!this._renderDialog) {
return nothing;
}
return html`
<ha-dialog
open
hideActions
.heading=${createCloseHeading(this.hass, this._params.name)}
@closed=${this.closeDialog}
>
<div>
<ha-textfield
dialogInitialFocus
.value=${this._params.token}
.label=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.prompt_copy_token"
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._token
? this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.created_title",
{ name: this._name }
)
: this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.create"
)}
type="text"
iconTrailing
readOnly
>
<ha-icon-button
@click=${this._copyToken}
slot="trailingIcon"
.path=${mdiContentCopy}
></ha-icon-button>
</ha-textfield>
<div id="qr">
${this._qrCode
? this._qrCode
: html`
<ha-button
appearance="plain"
size="small"
@click=${this._generateQR}
>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.generate_qr_code"
)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div class="content">
${this._errorMessage
? html`<ha-alert alert-type="error"
>${this._errorMessage}</ha-alert
>`
: nothing}
${this._token
? html`
<p>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.prompt_copy_token"
)}
</p>
<div class="token-row">
<ha-textfield
autofocus
.value=${this._token}
type="text"
readOnly
></ha-textfield>
<ha-button appearance="plain" @click=${this._copyToken}>
<ha-svg-icon
slot="start"
.path=${mdiContentCopy}
></ha-svg-icon>
${this.hass.localize("ui.common.copy")}
</ha-button>
`}
</div>
</div>
<div id="qr">
${this._qrCode
? this._qrCode
: html`
<ha-button
appearance="plain"
@click=${this._generateQR}
>
<ha-svg-icon
slot="start"
.path=${mdiQrcode}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.generate_qr_code"
)}
</ha-button>
`}
</div>
`
: html`
<ha-textfield
autofocus
.value=${this._name}
.label=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.name"
)}
.invalid=${this._hasDuplicateName()}
.errorMessage=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.name_exists"
)}
required
@input=${this._nameChanged}
></ha-textfield>
`}
</div>
</ha-dialog>
<ha-dialog-footer slot="footer">
${this._token
? nothing
: html`<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>`}
${!this._token
? html`<ha-button
slot="primaryAction"
.disabled=${this._isCreateDisabled()}
@click=${this._createToken}
>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.create"
)}
</ha-button>`
: html`<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>`}
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
private async _copyToken(ev): Promise<void> {
const textField = ev.target.parentElement as HaTextField;
await copyToClipboard(textField.value);
private _nameChanged(ev: Event) {
this._name = (ev.currentTarget as HTMLInputElement).value;
this._errorMessage = undefined;
}
private _isCreateDisabled() {
return this._loading || !this._name.trim() || this._hasDuplicateName();
}
private async _createToken(): Promise<void> {
if (this._isCreateDisabled()) {
return;
}
const name = this._name.trim();
this._loading = true;
this._errorMessage = undefined;
try {
this._token = await this.hass.callWS<string>({
type: "auth/long_lived_access_token",
lifespan: 3650,
client_name: name,
});
this._name = name;
this._createdCallback();
} catch (err: unknown) {
this._errorMessage = err instanceof Error ? err.message : String(err);
} finally {
this._loading = false;
}
}
private async _copyToken(): Promise<void> {
if (!this._token) {
return;
}
await copyToClipboard(this._token);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private _normalizeName(name: string): string {
return name.trim().toLowerCase();
}
private _hasDuplicateName(): boolean {
return this._existingNames.has(this._normalizeName(this._name));
}
private async _generateQR() {
if (!this._token) {
return;
}
const qrcode = await import("qrcode");
const canvas = await qrcode.toCanvas(this._params!.token, {
width: 180,
const canvas = await qrcode.toCanvas(this._token, {
width: 512,
errorCorrectionLevel: "Q",
});
const context = canvas.getContext("2d");
@@ -112,35 +253,46 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
canvas.height / 3
);
this._qrCode = html`<img
alt=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.qr_code_image",
{ name: this._params!.name }
)}
src=${canvas.toDataURL()}
></img>`;
await withViewTransition(() => {
this._qrCode = html`<img
alt=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.qr_code_image",
{ name: this._name }
)}
src=${canvas.toDataURL()}
></img>`;
});
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
#qr {
text-align: center;
}
#qr img {
max-width: 90%;
height: auto;
display: block;
margin: 0 auto;
}
.content {
display: grid;
gap: var(--ha-space-4);
}
.token-row {
display: flex;
gap: var(--ha-space-2);
align-items: center;
}
.token-row ha-textfield {
flex: 1;
}
p {
margin: 0;
}
ha-textfield {
display: block;
--textfield-icon-trailing-padding: 0;
}
ha-textfield > ha-icon-button {
position: relative;
right: -8px;
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
inset-inline-start: initial;
inset-inline-end: -8px;
direction: var(--direction);
}
`,
];

View File

@@ -13,7 +13,6 @@ import type { RefreshToken } from "../../data/refresh_token";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -26,14 +25,14 @@ class HaLongLivedTokens extends LitElement {
@property({ attribute: false }) public refreshTokens?: RefreshToken[];
private _accessTokens = memoizeOne(
(refreshTokens: RefreshToken[]): RefreshToken[] =>
refreshTokens
?.filter((token) => token.type === "long_lived_access_token")
(refreshTokens?: RefreshToken[]): RefreshToken[] =>
(refreshTokens ?? [])
.filter((token) => token.type === "long_lived_access_token")
.reverse()
);
protected render(): TemplateResult {
const accessTokens = this._accessTokens(this.refreshTokens!);
const accessTokens = this._accessTokens(this.refreshTokens);
return html`
<ha-card
@@ -55,13 +54,13 @@ class HaLongLivedTokens extends LitElement {
"ui.panel.profile.long_lived_access_tokens.learn_auth_requests"
)}
</a>
${!accessTokens?.length
${!accessTokens.length
? html`<p>
${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.empty_state"
)}
</p>`
: accessTokens!.map(
: accessTokens.map(
(token) =>
html`<ha-settings-row two-line>
<span slot="heading">${token.client_name}</span>
@@ -98,38 +97,15 @@ class HaLongLivedTokens extends LitElement {
`;
}
private async _createToken(): Promise<void> {
const name = await showPromptDialog(this, {
text: this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.prompt_name"
),
inputLabel: this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.name"
),
private _createToken(): void {
const accessTokens = this._accessTokens(this.refreshTokens);
showLongLivedAccessTokenDialog(this, {
createdCallback: () => fireEvent(this, "hass-refresh-tokens"),
existingNames: accessTokens
.map((token) => token.client_name)
.filter((name): name is string => Boolean(name)),
});
if (!name) {
return;
}
try {
const token = await this.hass.callWS<string>({
type: "auth/long_lived_access_token",
lifespan: 3650,
client_name: name,
});
showLongLivedAccessTokenDialog(this, { token, name });
fireEvent(this, "hass-refresh-tokens");
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.create_failed"
),
text: err.message,
});
}
}
private async _deleteToken(ev: Event): Promise<void> {

View File

@@ -1,8 +1,8 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface LongLivedAccessTokenDialogParams {
token: string;
name: string;
createdCallback: () => void;
existingNames: string[];
}
export const showLongLivedAccessTokenDialog = (

View File

@@ -8907,6 +8907,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",
@@ -9727,8 +9739,10 @@
"confirm_delete_text": "Are you sure you want to delete the long-lived access token for {name}?",
"delete_failed": "Failed to delete the access token.",
"create": "Create token",
"created_title": "Token created: {name}",
"create_failed": "Failed to create the access token.",
"name": "Name",
"name_exists": "A token with this name already exists.",
"prompt_name": "Give the token a name",
"prompt_copy_token": "Copy your access token. It will not be shown again.",
"empty_state": "You have no long-lived access tokens yet.",