Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov
036ae921e7 Fix history-graph card rendering stale data point on left edge
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:58:42 +02:00
15 changed files with 333 additions and 923 deletions

View File

@@ -572,7 +572,6 @@ 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;
}
@@ -602,25 +601,10 @@ export class StatisticsChart extends LitElement {
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(
startDate,
endDate.getTime() < endTime.getTime() ? endDate : endTime,
dataValues
);
pushData(startDate, new Date(stat.end), 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
@@ -635,6 +619,16 @@ 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,4 +1,3 @@
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";
@@ -23,37 +22,11 @@ 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,10 +47,6 @@ 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";
@@ -61,6 +57,10 @@ 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 { 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 {
getEntityIdHiddenTableColumn,
getAreaTableColumn,
getCategoryTableColumn,
getEntityIdHiddenTableColumn,
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 { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
@@ -129,6 +129,7 @@ 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;
@@ -222,7 +223,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@query("#overflow-menu") private _overflowMenu!: HaDropdown;
@query("#overflow-menu") private _overflowMenu!: HaMdMenu;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
@@ -232,8 +233,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
return getAvailableAssistants(this.cloudStatus, this.hass);
}
private _openingOverflow = false;
private _automations = memoizeOne(
(
automations: AutomationEntity[],
@@ -372,27 +371,16 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
);
private _showOverflowMenu = (ev) => {
if (this._overflowMenu.anchorElement === ev.target) {
this._overflowMenu.anchorElement = undefined;
if (
this._overflowMenu.open &&
ev.target === this._overflowMenu.anchorElement
) {
this._overflowMenu.close();
return;
}
this._openingOverflow = true;
this._overflowMenu.anchorElement = ev.target;
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;
this._overflowMenu.anchorElement = ev.target;
this._overflowMenu.show();
};
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@@ -709,58 +697,74 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
<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 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-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-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-md-divider role="separator" tabindex="-1"></ha-md-divider>
<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-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-svg-icon
.path=${this._overflowAutomation?.state === "off"
? mdiToggleSwitch
: mdiToggleSwitchOffOutline}
slot="icon"
slot="start"
></ha-svg-icon>
${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>
<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>
`;
}
@@ -897,59 +901,33 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this._applyFilters();
}
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 _showInfo = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
fireEvent(this, "hass-more-info", { entityId: automation.entity_id });
};
private _showInfo = (automation: AutomationItem) => {
fireEvent(this, "hass-more-info", {
entityId: automation.entity_id,
});
};
private _showSettings = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
private _showSettings = (automation: AutomationItem) => {
fireEvent(this, "hass-more-info", {
entityId: automation.entity_id,
view: "settings",
});
};
private _runActions = (automation: AutomationItem) => {
private _runActions = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
triggerAutomationActions(this.hass, automation.entity_id);
};
private _editCategory = (automation: AutomationItem) => {
private _editCategory = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
const entityReg = this._entityReg.find(
(reg) => reg.entity_id === automation.entity_id
);
@@ -970,7 +948,10 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
});
};
private _showTrace = (automation: AutomationItem) => {
private _showTrace = (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
if (!automation.attributes.id) {
showAlertDialog(this, {
text: this.hass.localize(
@@ -984,14 +965,20 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
);
};
private _toggle = async (automation: AutomationItem): Promise<void> => {
private _toggle = async (item: HaMdMenuItem): Promise<void> => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
const service = automation.state === "off" ? "turn_on" : "turn_off";
await this.hass.callService("automation", service, {
entity_id: automation.entity_id,
});
};
private _deleteConfirm = async (automation: AutomationItem) => {
private _deleteConfirm = async (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.picker.delete_confirm_title"
@@ -1007,9 +994,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
});
};
private async _delete(automation: AutomationItem) {
private async _delete(automation) {
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
);
@@ -1028,11 +1015,14 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
}
}
private _duplicate = async (automation: AutomationItem) => {
private _duplicate = async (item: HaMdMenuItem) => {
const automation = ((item.parentElement as HaMdMenu)!.anchorElement as any)!
.automation;
try {
const config = await fetchAutomationFileConfig(
this.hass,
automation.attributes.id!
automation.attributes.id
);
duplicateAutomation(config);
} catch (err: any) {

View File

@@ -118,7 +118,6 @@ 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,8 +2,6 @@ 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";
@@ -28,11 +26,6 @@ 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;
@@ -47,49 +40,6 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
@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();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
this._stopTick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeDateTime();
}
};
private _initDate() {
if (!this.config || !this.hass) {
return;
@@ -101,27 +51,6 @@ export class HuiClockCardAnalog extends LitElement {
}
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",
@@ -131,31 +60,42 @@ export class HuiClockCardAnalog extends LitElement {
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
this._computeDateTime();
this._computeOffsets();
}
private _startTick() {
this._tick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
}
private _stopTick() {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = undefined;
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _updateDate() {
this._currentDate = new Date();
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
}
private _computeDateTime() {
public disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener(
"visibilitychange",
this._handleVisibilityChange
);
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
}
};
private _computeOffsets() {
if (!this._dateTimeFormat) return;
this._updateDate();
const parts = this._dateTimeFormat.formatToParts(this._currentDate);
const parts = this._dateTimeFormat.formatToParts();
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;
@@ -163,7 +103,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 = this._currentDate.getMilliseconds();
const ms = new Date().getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -171,38 +111,18 @@ 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, isNumbers, isRoman, isUpright, isLongDate } =
this._computeClock(this.config);
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 indicator = (number?: number) => html`
<div
@@ -243,14 +163,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? QUARTER_TICKS.map(
? Array.from({ length: 4 }, (_, i) => i).map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
style=${`--tick-rotation: ${i * 90}deg;`}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -258,30 +178,28 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? HOUR_TICKS.map(
? Array.from({ length: 12 }, (_, i) => i).map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
style=${`--tick-rotation: ${i * 30}deg;`}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? MINUTE_TICKS.map(
? Array.from({ length: 60 }, (_, i) => i).map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
style=${`--tick-rotation: ${i * 6}deg;`}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -290,33 +208,14 @@ 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=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
></div>
<div
class="hand minute"
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
></div>
${this.config.show_seconds
? html`<div
@@ -325,13 +224,11 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${styleMap({
"animation-delay": `-${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s`,
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
></div>`
: nothing}
</div>
@@ -373,14 +270,6 @@ 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);
@@ -518,78 +407,6 @@ 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,8 +24,6 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _initDate() {
@@ -41,27 +39,6 @@ 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",
@@ -116,16 +93,6 @@ 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() {
@@ -146,9 +113,6 @@ 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}
`;
}
@@ -224,20 +188,6 @@ 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,9 +1,8 @@
import type { HassConfig } from "home-assistant-js-websocket";
import {
differenceInMonths,
subHours,
differenceInDays,
differenceInMonths,
differenceInCalendarMonths,
differenceInYears,
startOfYear,
addMilliseconds,
@@ -13,7 +12,6 @@ import {
addHours,
startOfDay,
addDays,
subDays,
} from "date-fns";
import type {
BarSeriesOption,
@@ -35,22 +33,10 @@ import { filterXSS } from "../../../../../common/util/xss";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getSuggestedPeriod } from "../../../../../data/energy";
// 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.
export function getSuggestedMax(period: StatisticPeriod, end: Date): Date {
let suggestedMax = new Date(end);
if (noRounding || period === "5minute") {
if (period === "5minute") {
return suggestedMax;
}
suggestedMax.setMinutes(0, 0, 0);
@@ -96,44 +82,17 @@ 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 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 = {
const options: ECOption = {
xAxis: {
type: "time",
min: start,
max: suggestedMax,
max: getSuggestedMax(suggestedPeriod, end),
},
};
const options: ECOption = {
...(suggestedPeriod === "month" ? monthTimeAxis : normalTimeAxis),
yAxis: {
type: "value",
name: unit,

View File

@@ -332,11 +332,7 @@ 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,
(this._config.chart_type ?? "line") === "line"
)
? getSuggestedMax(this._period!, this._energyEnd)
: undefined}
.fitYData=${this._config.fit_y_data || false}
.hideLegend=${this._config.hide_legend || false}

View File

@@ -435,7 +435,6 @@ 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,19 +39,6 @@ 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(
@@ -106,7 +93,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "box",
mode: "dropdown",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -132,27 +119,6 @@ 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"
? ([
{
@@ -275,13 +241,12 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config: ClockCardConfig) => ({
private _data = memoizeOne((config) => ({
clock_style: "digital",
clock_size: "small",
time_format: "auto",
show_seconds: false,
no_background: false,
date: "none",
// Analog clock options
border: false,
ticks: "hour",
@@ -305,9 +270,8 @@ export class HuiClockCardEditor
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config)
.clock_style as ClockCardConfig["clock_style"],
this._data(this._config).ticks as ClockCardConfig["ticks"],
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
@@ -380,10 +344,6 @@ 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`
@@ -409,10 +369,6 @@ 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,12 +1,9 @@
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-footer";
import "../../components/ha-wa-dialog";
import "../../components/ha-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";
@@ -31,7 +28,7 @@ class HaMfaModuleSetupFlow extends LitElement {
@state() private _loading = false;
@state() private _open = false;
@state() private _opened = false;
@state() private _stepData: any = {};
@@ -42,7 +39,7 @@ class HaMfaModuleSetupFlow extends LitElement {
public showDialog({ continueFlowId, mfaModuleId, dialogClosedCallback }) {
this._instance = instance++;
this._dialogClosedCallback = dialogClosedCallback;
this._open = true;
this._opened = true;
const fetchStep = continueFlowId
? this.hass.callWS({
@@ -64,29 +61,22 @@ class HaMfaModuleSetupFlow extends LitElement {
}
public closeDialog() {
this._open = false;
}
private _dialogClosed() {
// Closed dialog by clicking on the overlay
if (this._step) {
this._flowDone();
return;
}
this._resetDialogState();
this._opened = false;
}
protected render() {
if (this._instance === undefined) {
if (!this._opened) {
return nothing;
}
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
prevent-scrim-close
header-title=${this._computeStepTitle()}
@closed=${this._dialogClosed}
<ha-dialog
open
.heading=${this._computeStepTitle()}
@closed=${this.closeDialog}
>
<div>
${this._errorMessage
@@ -125,7 +115,6 @@ class HaMfaModuleSetupFlow extends LitElement {
)}
></ha-markdown>
<ha-form
autofocus
.hass=${this.hass}
.data=${this._stepData}
.schema=${autocompleteLoginFields(
@@ -138,33 +127,31 @@ class HaMfaModuleSetupFlow extends LitElement {
></ha-form>`
: ""}`}
</div>
<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>
<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>
`;
}
@@ -175,6 +162,9 @@ class HaMfaModuleSetupFlow extends LitElement {
.error {
color: red;
}
ha-dialog {
max-width: 500px;
}
ha-markdown {
--markdown-svg-background-color: white;
--markdown-svg-color: black;
@@ -187,17 +177,9 @@ 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;
}
@@ -224,10 +206,6 @@ class HaMfaModuleSetupFlow extends LitElement {
}
private _submitStep() {
if (this._isSubmitDisabled()) {
return;
}
this._loading = true;
this._errorMessage = undefined;
@@ -256,62 +234,6 @@ 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;
@@ -329,15 +251,12 @@ class HaMfaModuleSetupFlow extends LitElement {
this._dialogClosedCallback!({
flowFinished,
});
this._resetDialogState();
}
private _resetDialogState() {
this._errorMessage = undefined;
this._step = undefined;
this._stepData = {};
this._dialogClosedCallback = undefined;
this._instance = undefined;
this.closeDialog();
}
private _computeStepTitle() {

View File

@@ -1,18 +1,17 @@
import { mdiContentCopy, mdiQrcode } from "@mdi/js";
import { mdiContentCopy } 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 { copyToClipboard } from "../../common/util/copy-clipboard";
import { withViewTransition } from "../../common/util/view-transition";
import "../../components/ha-alert";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-textfield";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-svg-icon";
import "../../components/ha-wa-dialog";
import "../../components/ha-icon-button";
import { haStyleDialog } from "../../resources/styles";
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";
@@ -21,221 +20,81 @@ 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._createdCallback = params.createdCallback;
this._existingNames = new Set(
params.existingNames.map((name) => this._normalizeName(name))
);
this._renderDialog = true;
this._open = true;
this._params = params;
}
public closeDialog() {
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._params = undefined;
this._qrCode = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._renderDialog) {
if (!this._params || !this._params.token) {
return nothing;
}
return html`
<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"
)}
prevent-scrim-close
@closed=${this._dialogClosed}
<ha-dialog
open
hideActions
.heading=${createCloseHeading(this.hass, this._params.name)}
@closed=${this.closeDialog}
>
<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")}
<div>
<ha-textfield
dialogInitialFocus
.value=${this._params.token}
.label=${this.hass.localize(
"ui.panel.profile.long_lived_access_tokens.prompt_copy_token"
)}
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"
)}
</ha-button>
</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>
</div>
<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>
</ha-dialog>
`;
}
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);
private async _copyToken(ev): Promise<void> {
const textField = ev.target.parentElement as HaTextField;
await copyToClipboard(textField.value);
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._token, {
width: 512,
const canvas = await qrcode.toCanvas(this._params!.token, {
width: 180,
errorCorrectionLevel: "Q",
});
const context = canvas.getContext("2d");
@@ -253,46 +112,35 @@ export class HaLongLivedAccessTokenDialog extends LitElement {
canvas.height / 3
);
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>`;
});
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>`;
}
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,6 +13,7 @@ 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";
@@ -25,14 +26,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
@@ -54,13 +55,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>
@@ -97,15 +98,38 @@ class HaLongLivedTokens extends LitElement {
`;
}
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)),
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"
),
});
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 {
createdCallback: () => void;
existingNames: string[];
token: string;
name: string;
}
export const showLongLivedAccessTokenDialog = (

View File

@@ -8907,18 +8907,6 @@
"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",
@@ -9739,10 +9727,8 @@
"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.",