Compare commits

..

39 Commits

Author SHA1 Message Date
J. Nick Koston
aacffb18ea preen 2025-09-12 10:30:08 -05:00
J. Nick Koston
6dbc2ef3ff typing 2025-09-12 10:27:35 -05:00
J. Nick Koston
90fbbe8e9e typing 2025-09-12 10:26:50 -05:00
J. Nick Koston
f4b1de2af5 typing 2025-09-12 10:26:11 -05:00
J. Nick Koston
e70af1b2f8 typing 2025-09-12 10:24:46 -05:00
J. Nick Koston
88d710496f typing 2025-09-12 10:24:28 -05:00
J. Nick Koston
0f9109a1b7 fix accidental removal 2025-09-12 10:24:03 -05:00
J. Nick Koston
8c49be013c review comments 2025-09-12 10:19:44 -05:00
J. Nick Koston
c3f11784dc cleanup, dry 2025-09-12 10:17:00 -05:00
J. Nick Koston
9a5767c61d cleanup, dry 2025-09-12 10:15:10 -05:00
J. Nick Koston
7044d57cae cleanup, dry 2025-09-12 10:04:10 -05:00
J. Nick Koston
21c003d7f8 cleanup, dry 2025-09-12 10:02:56 -05:00
J. Nick Koston
73908ea650 cleanup, dry 2025-09-12 10:00:57 -05:00
J. Nick Koston
f7e4261dc9 cleanup, dry 2025-09-12 09:59:53 -05:00
J. Nick Koston
16272bc4ae preen 2025-09-12 09:56:23 -05:00
J. Nick Koston
7989db8f54 preen 2025-09-12 09:51:55 -05:00
J. Nick Koston
911720a983 preen 2025-09-12 09:51:18 -05:00
J. Nick Koston
c87f50e336 dry 2025-09-12 09:49:26 -05:00
J. Nick Koston
a0778e3407 dry 2025-09-12 09:47:17 -05:00
J. Nick Koston
e46e502db3 make sure we can do bulk ops for non-unique id entities 2025-09-12 09:45:26 -05:00
J. Nick Koston
1e573bb358 make sure we can do bulk ops for non-unique id entities 2025-09-12 09:43:59 -05:00
J. Nick Koston
282b9596ea make sure we can do bulk ops for non-unique id entities 2025-09-12 09:42:26 -05:00
J. Nick Koston
0e33349d7d make sure we can do bulk ops for non-unique id entities 2025-09-12 09:38:09 -05:00
J. Nick Koston
dd4111b570 make sure we can do bulk ops for non-unique id entities 2025-09-12 09:35:21 -05:00
J. Nick Koston
d1e515a5d1 make sure we can do bulk ops for non-unique id entities 2025-09-12 09:35:01 -05:00
J. Nick Koston
38ee5f2c81 make sure we can do bulk ops for non-unique id entities 2025-09-12 09:34:14 -05:00
J. Nick Koston
7afaa1bd1b handle non-unique entity changes 2025-09-12 09:29:18 -05:00
J. Nick Koston
37f4d7ac98 handle non-unique entity changes 2025-09-12 09:25:54 -05:00
J. Nick Koston
a438f5f0f9 add bulk controls 2025-09-12 09:22:45 -05:00
J. Nick Koston
0be739e7d9 add bulk controls 2025-09-12 09:17:30 -05:00
J. Nick Koston
bb7c9ced96 add settings for non unique id entities 2025-09-12 09:04:51 -05:00
J. Nick Koston
d429997938 add settings for non unique id entities 2025-09-12 09:03:03 -05:00
J. Nick Koston
3ca9e3cc54 add settings for non unique id entities 2025-09-12 09:02:28 -05:00
J. Nick Koston
ab360614e8 add settings for non unique id entities 2025-09-12 09:00:45 -05:00
J. Nick Koston
bd9fa9a7c6 add settings for non unique id entities 2025-09-12 08:52:07 -05:00
J. Nick Koston
fcdea9abee remove test toast 2025-09-12 08:46:45 -05:00
J. Nick Koston
101076cc7c remove test toast 2025-09-12 08:46:32 -05:00
J. Nick Koston
c4e64c3a2e Add controls for selecting which entities are recorded
core pr: https://github.com/home-assistant/core/pull/152168
2025-09-12 08:39:12 -05:00
J. Nick Koston
3f4d735d42 Add controls for selecting which entities are recorded
core pr: https://github.com/home-assistant/core/pull/152168
2025-09-12 08:39:00 -05:00
16 changed files with 906 additions and 594 deletions

View File

@@ -1,62 +1,262 @@
import { css, html, LitElement, type PropertyValues } from "lit";
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
const ANIMATION_DURATION_MS = 300;
/**
* A bottom sheet component that slides up from the bottom of the screen.
*
* The bottom sheet provides a draggable interface that allows users to resize
* the sheet by dragging the handle at the top. It supports both mouse and touch
* interactions and automatically closes when dragged below a 20% of screen height.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
*
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
*/
@customElement("ha-bottom-sheet")
export class HaBottomSheet extends LitElement {
@property({ type: Boolean }) public open = false;
@query("dialog") private _dialog!: HTMLDialogElement;
@state() private _drawerOpen = false;
private _dragging = false;
private _handleAfterHide() {
this.open = false;
const ev = new Event("closed", {
bubbles: true,
composed: true,
});
this.dispatchEvent(ev);
private _dragStartY = 0;
private _initialSize = 0;
@state() private _dialogMaxViewpointHeight = 70;
@state() private _dialogMinViewpointHeight = 55;
@state() private _dialogViewportHeight?: number;
render() {
return html`<dialog
open
@transitionend=${this._handleTransitionEnd}
style=${styleMap({
height: this._dialogViewportHeight
? `${this._dialogViewportHeight}vh`
: "auto",
maxHeight: `${this._dialogMaxViewpointHeight}vh`,
minHeight: `${this._dialogMinViewpointHeight}vh`,
})}
>
<div class="handle-wrapper">
<div
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
class="handle"
></div>
</div>
<slot></slot>
</dialog>`;
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("open")) {
this._drawerOpen = this.open;
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._openSheet();
}
private _openSheet() {
requestAnimationFrame(() => {
// trigger opening animation
this._dialog.classList.add("show");
});
}
public closeSheet() {
requestAnimationFrame(() => {
this._dialog.classList.remove("show");
});
}
private _handleTransitionEnd() {
if (this._dialog.classList.contains("show")) {
// after show animation is done
// - set the height to the natural height, to prevent content shift when switch content
// - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
this._dialogViewportHeight =
(this._dialog.offsetHeight / window.innerHeight) * 100;
this._dialogMaxViewpointHeight = 90;
this._dialogMinViewpointHeight = 20;
} else {
// after close animation is done close dialog element and fire closed event
this._dialog.close();
fireEvent(this, "bottom-sheet-closed");
}
}
render() {
return html`
<wa-drawer
placement="bottom"
.open=${this._drawerOpen}
@wa-after-hide=${this._handleAfterHide}
without-header
>
<slot></slot>
</wa-drawer>
`;
connectedCallback() {
super.connectedCallback();
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
}
disconnectedCallback() {
super.disconnectedCallback();
// unregister event listeners for drag handling
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
}
private _handleMouseDown = (ev: MouseEvent) => {
this._startDrag(ev.clientY);
};
private _handleTouchStart = (ev: TouchEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(ev.touches[0].clientY);
};
private _startDrag(clientY: number) {
this._dragging = true;
this._dragStartY = clientY;
this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
document.body.style.setProperty("cursor", "grabbing");
}
private _handleMouseMove = (ev: MouseEvent) => {
if (!this._dragging) {
return;
}
this._updateSize(ev.clientY);
};
private _handleTouchMove = (ev: TouchEvent) => {
if (!this._dragging) {
return;
}
ev.preventDefault(); // Prevent scrolling
this._updateSize(ev.touches[0].clientY);
};
private _updateSize(clientY: number) {
const deltaY = this._dragStartY - clientY;
const viewportHeight = window.innerHeight;
const deltaVh = (deltaY / viewportHeight) * 100;
// Calculate new size and clamp between 10vh and 90vh
let newSize = this._initialSize + deltaVh;
newSize = Math.max(10, Math.min(90, newSize));
// on drag down and below 20vh
if (newSize < 20 && deltaY < 0) {
this._endDrag();
this.closeSheet();
return;
}
this._dialogViewportHeight = newSize;
}
private _handleMouseUp = () => {
this._endDrag();
};
private _handleTouchEnd = () => {
this._endDrag();
};
private _endDrag() {
if (!this._dragging) {
return;
}
this._dragging = false;
document.body.style.removeProperty("cursor");
}
static styles = css`
wa-drawer {
--wa-color-surface-raised: var(
.handle-wrapper {
position: absolute;
top: 0;
width: 100%;
padding-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
touch-action: none;
}
.handle-wrapper .handle {
height: 20px;
width: 200px;
display: flex;
justify-content: center;
align-items: center;
z-index: 7;
padding-bottom: 76px;
}
.handle-wrapper .handle::after {
content: "";
border-radius: 8px;
height: 4px;
background: var(--divider-color, #e0e0e0);
width: 80px;
}
.handle-wrapper .handle:active::after {
cursor: grabbing;
}
dialog {
height: auto;
max-height: 70vh;
min-height: 30vh;
background-color: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
--spacing: 0;
--size: auto;
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
display: flex;
flex-direction: column;
top: 0;
inset-inline-start: 0;
position: fixed;
width: calc(100% - 4px);
max-width: 100%;
border: none;
box-shadow: var(--wa-shadow-l);
padding: 0;
margin: 0;
top: auto;
inset-inline-end: auto;
bottom: 0;
inset-inline-start: 0;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
border-top-left-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
border-top-right-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
transform: translateY(100%);
transition: transform ${ANIMATION_DURATION_MS}ms ease;
border-top-width: var(--ha-bottom-sheet-border-width);
border-right-width: var(--ha-bottom-sheet-border-width);
border-left-width: var(--ha-bottom-sheet-border-width);
border-bottom-width: 0;
border-style: var(--ha-bottom-sheet-border-style);
border-color: var(--ha-bottom-sheet-border-color);
}
wa-drawer::part(dialog) {
border-top-left-radius: var(--ha-border-radius-lg);
border-top-right-radius: var(--ha-border-radius-lg);
max-height: 90vh;
}
wa-drawer::part(body) {
padding-bottom: var(--safe-area-inset-bottom);
dialog.show {
transform: translateY(0);
}
`;
}
@@ -65,4 +265,8 @@ declare global {
interface HTMLElementTagNameMap {
"ha-bottom-sheet": HaBottomSheet;
}
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
}
}

View File

@@ -1,271 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
/**
* A bottom sheet component that slides up from the bottom of the screen.
*
* The bottom sheet provides a draggable interface that allows users to resize
* the sheet by dragging the handle at the top. It supports both mouse and touch
* interactions and automatically closes when dragged below a 20% of screen height.
*
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
*
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
*/
@customElement("ha-resizable-bottom-sheet")
export class HaResizableBottomSheet extends LitElement {
@query("dialog") private _dialog!: HTMLDialogElement;
private _dragging = false;
private _dragStartY = 0;
private _initialSize = 0;
@state() private _dialogMaxViewpointHeight = 70;
@state() private _dialogMinViewpointHeight = 55;
@state() private _dialogViewportHeight?: number;
render() {
return html`<dialog
open
@transitionend=${this._handleTransitionEnd}
style=${styleMap({
height: this._dialogViewportHeight
? `${this._dialogViewportHeight}vh`
: "auto",
maxHeight: `${this._dialogMaxViewpointHeight}vh`,
minHeight: `${this._dialogMinViewpointHeight}vh`,
})}
>
<div class="handle-wrapper">
<div
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
class="handle"
></div>
</div>
<slot></slot>
</dialog>`;
}
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._openSheet();
}
private _openSheet() {
requestAnimationFrame(() => {
// trigger opening animation
this._dialog.classList.add("show");
});
}
public closeSheet() {
requestAnimationFrame(() => {
this._dialog.classList.remove("show");
});
}
private _handleTransitionEnd() {
if (this._dialog.classList.contains("show")) {
// after show animation is done
// - set the height to the natural height, to prevent content shift when switch content
// - set max height to 90vh, so it opens at max 70vh but can be resized to 90vh
this._dialogViewportHeight =
(this._dialog.offsetHeight / window.innerHeight) * 100;
this._dialogMaxViewpointHeight = 90;
this._dialogMinViewpointHeight = 20;
} else {
// after close animation is done close dialog element and fire closed event
this._dialog.close();
fireEvent(this, "bottom-sheet-closed");
}
}
connectedCallback() {
super.connectedCallback();
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
}
disconnectedCallback() {
super.disconnectedCallback();
// unregister event listeners for drag handling
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
}
private _handleMouseDown = (ev: MouseEvent) => {
this._startDrag(ev.clientY);
};
private _handleTouchStart = (ev: TouchEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(ev.touches[0].clientY);
};
private _startDrag(clientY: number) {
this._dragging = true;
this._dragStartY = clientY;
this._initialSize = (this._dialog.offsetHeight / window.innerHeight) * 100;
document.body.style.setProperty("cursor", "grabbing");
}
private _handleMouseMove = (ev: MouseEvent) => {
if (!this._dragging) {
return;
}
this._updateSize(ev.clientY);
};
private _handleTouchMove = (ev: TouchEvent) => {
if (!this._dragging) {
return;
}
ev.preventDefault(); // Prevent scrolling
this._updateSize(ev.touches[0].clientY);
};
private _updateSize(clientY: number) {
const deltaY = this._dragStartY - clientY;
const viewportHeight = window.innerHeight;
const deltaVh = (deltaY / viewportHeight) * 100;
// Calculate new size and clamp between 10vh and 90vh
let newSize = this._initialSize + deltaVh;
newSize = Math.max(10, Math.min(90, newSize));
// on drag down and below 20vh
if (newSize < 20 && deltaY < 0) {
this._endDrag();
this.closeSheet();
return;
}
this._dialogViewportHeight = newSize;
}
private _handleMouseUp = () => {
this._endDrag();
};
private _handleTouchEnd = () => {
this._endDrag();
};
private _endDrag() {
if (!this._dragging) {
return;
}
this._dragging = false;
document.body.style.removeProperty("cursor");
}
static styles = css`
.handle-wrapper {
position: absolute;
top: 0;
width: 100%;
padding-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
touch-action: none;
}
.handle-wrapper .handle {
height: 20px;
width: 200px;
display: flex;
justify-content: center;
align-items: center;
z-index: 7;
padding-bottom: 76px;
}
.handle-wrapper .handle::after {
content: "";
border-radius: 8px;
height: 4px;
background: var(--divider-color, #e0e0e0);
width: 80px;
}
.handle-wrapper .handle:active::after {
cursor: grabbing;
}
dialog {
height: auto;
max-height: 70vh;
min-height: 30vh;
background-color: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
display: flex;
flex-direction: column;
top: 0;
inset-inline-start: 0;
position: fixed;
width: calc(100% - 4px);
max-width: 100%;
border: none;
box-shadow: var(--wa-shadow-l);
padding: 0;
margin: 0;
top: auto;
inset-inline-end: auto;
bottom: 0;
inset-inline-start: 0;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
border-top-left-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
border-top-right-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-2xl)
);
transform: translateY(100%);
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
border-top-width: var(--ha-bottom-sheet-border-width);
border-right-width: var(--ha-bottom-sheet-border-width);
border-left-width: var(--ha-bottom-sheet-border-width);
border-bottom-width: 0;
border-style: var(--ha-bottom-sheet-border-style);
border-color: var(--ha-bottom-sheet-border-color);
}
dialog.show {
transform: translateY(0);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-resizable-bottom-sheet": HaResizableBottomSheet;
}
interface HASSDomEvents {
"bottom-sheet-closed": undefined;
}
}

View File

@@ -116,6 +116,10 @@ export interface SwitchAsXEntityOptions {
invert: boolean;
}
export interface RecorderEntityOptions {
recording_disabled_by?: string | null;
}
export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
@@ -124,6 +128,7 @@ export interface EntityRegistryOptions {
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
recorder?: RecorderEntityOptions;
conversation?: Record<string, unknown>;
"cloud.alexa"?: Record<string, unknown>;
"cloud.google_assistant"?: Record<string, unknown>;

View File

@@ -365,3 +365,36 @@ export const isExternalStatistic = (statisticsId: string): boolean =>
export const updateStatisticsIssues = (hass: HomeAssistant) =>
hass.callWS<undefined>({ type: "recorder/update_statistics_issues" });
export interface EntityRecordingSettings {
recording_disabled_by: string | null;
}
export type EntityRecordingList = Record<string, EntityRecordingSettings>;
export const getEntityRecordingList = (hass: HomeAssistant) =>
hass
.callWS<{ recorded_entities: EntityRecordingList }>({
type: "homeassistant/record_entity/list",
})
.then((response) => response.recorded_entities);
export const getEntityRecordingSettings = (
hass: HomeAssistant,
entity_id: string
) =>
hass.callWS<EntityRecordingSettings>({
type: "homeassistant/record_entity/get",
entity_id,
});
export const setEntityRecordingOptions = (
hass: HomeAssistant,
entity_ids: string[],
recording_disabled_by: string | null
) =>
hass.callWS<undefined>({
type: "homeassistant/record_entity/set_options",
entity_ids,
recording_disabled_by,
});

View File

@@ -1,7 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-bottom-sheet";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-icon";
import "../../components/ha-md-list";
@@ -41,54 +40,6 @@ export class ListItemsDialog
return nothing;
}
const content = html`
<div class="container">
<ha-md-list>
${this._params.items.map(
(item) => html`
<ha-md-list-item
type="button"
@click=${this._itemClicked}
.item=${item}
>
${item.iconPath
? html`
<ha-svg-icon
.path=${item.iconPath}
slot="start"
class="item-icon"
></ha-svg-icon>
`
: item.icon
? html`
<ha-icon
icon=${item.icon}
slot="start"
class="item-icon"
></ha-icon>
`
: nothing}
<span class="headline">${item.label}</span>
${item.description
? html`
<span class="supporting-text">${item.description}</span>
`
: nothing}
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
`;
if (this._params.mode === "bottom-sheet") {
return html`
<ha-bottom-sheet placement="bottom" open @closed=${this._dialogClosed}>
${content}
</ha-bottom-sheet>
`;
}
return html`
<ha-dialog
open
@@ -96,7 +47,43 @@ export class ListItemsDialog
@closed=${this._dialogClosed}
hideActions
>
${content}
<div class="container">
<ha-md-list>
${this._params.items.map(
(item) => html`
<ha-md-list-item
type="button"
@click=${this._itemClicked}
.item=${item}
>
${item.iconPath
? html`
<ha-svg-icon
.path=${item.iconPath}
slot="start"
class="item-icon"
></ha-svg-icon>
`
: item.icon
? html`
<ha-icon
icon=${item.icon}
slot="start"
class="item-icon"
></ha-icon>
`
: nothing}
<span class="headline">${item.label}</span>
${item.description
? html`
<span class="supporting-text">${item.description}</span>
`
: nothing}
</ha-md-list-item>
`
)}
</ha-md-list>
</div>
</ha-dialog>
`;
}

View File

@@ -11,7 +11,6 @@ interface ListItem {
export interface ListItemsDialogParams {
title?: string;
items: ListItem[];
mode?: "dialog" | "bottom-sheet";
}
export const showListItemsDialog = (

View File

@@ -9,6 +9,7 @@ import type {
} from "../../data/entity_registry";
import { PLATFORMS_WITH_SETTINGS_TAB } from "../../panels/config/entities/const";
import "../../panels/config/entities/entity-registry-settings";
import "../../panels/config/entities/entity-settings-without-unique-id";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
@@ -28,7 +29,7 @@ export class HaMoreInfoSettings extends LitElement {
return nothing;
}
// No unique ID
// No unique ID - show limited settings
if (this.entry === null) {
return html`
<div class="content">
@@ -43,6 +44,10 @@ export class HaMoreInfoSettings extends LitElement {
>`,
})}
</ha-alert>
<entity-settings-without-unique-id
.hass=${this.hass}
.entityId=${this.entityId}
></entity-settings-without-unique-id>
</div>
`;
}

View File

@@ -1,8 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-resizable-bottom-sheet";
import type { HaResizableBottomSheet } from "../../../components/ha-resizable-bottom-sheet";
import "../../../components/ha-bottom-sheet";
import type { HaBottomSheet } from "../../../components/ha-bottom-sheet";
import {
isCondition,
isScriptField,
@@ -38,38 +37,7 @@ export default class HaAutomationSidebar extends LitElement {
@state() private _yamlMode = false;
@query("ha-resizable-bottom-sheet")
private _bottomSheetElement?: HaResizableBottomSheet;
private _dragging = false;
private _dragStartX = 0;
private _initialSize = 0;
connectedCallback() {
super.connectedCallback();
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._handleMouseUp);
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
}
disconnectedCallback() {
super.disconnectedCallback();
// unregister event listeners for drag handling
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._handleMouseUp);
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
}
@query("ha-bottom-sheet") private _bottomSheetElement?: HaBottomSheet;
private _renderContent() {
// get config type
@@ -179,84 +147,13 @@ export default class HaAutomationSidebar extends LitElement {
if (this.narrow) {
return html`
<ha-resizable-bottom-sheet @bottom-sheet-closed=${this._closeSidebar}>
<ha-bottom-sheet @bottom-sheet-closed=${this._closeSidebar}>
${this._renderContent()}
</ha-resizable-bottom-sheet>
</ha-bottom-sheet>
`;
}
return html`
<div
class="handle"
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleTouchStart}
></div>
${this._renderContent()}
`;
}
private _handleMouseDown = (ev: MouseEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(ev.clientX);
};
private _handleTouchStart = (ev: TouchEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startDrag(ev.touches[0].clientX);
};
private _startDrag(clientX: number) {
this._dragging = true;
this._dragStartX = clientX;
this._initialSize = (this.offsetWidth / window.innerWidth) * 100;
document.body.style.setProperty("cursor", "grabbing");
}
private _handleMouseMove = (ev: MouseEvent) => {
if (!this._dragging) {
return;
}
this._updateSize(ev.clientX);
};
private _handleTouchMove = (ev: TouchEvent) => {
if (!this._dragging) {
return;
}
ev.preventDefault(); // Prevent scrolling
this._updateSize(ev.touches[0].clientX);
};
private _updateSize(clientX: number) {
const deltaX = this._dragStartX - clientX;
const viewportWidth = window.innerWidth;
const deltaVw = (deltaX / viewportWidth) * 100;
// Calculate new size and clamp between 30vh and 70vh
let newSize = this._initialSize + deltaVw;
newSize = Math.max(30, Math.min(70, newSize));
requestAnimationFrame(() => {
fireEvent(this, "sidebar-width-changed", { width: newSize });
});
}
private _handleMouseUp = () => {
this._endDrag();
};
private _handleTouchEnd = () => {
this._endDrag();
};
private _endDrag() {
if (!this._dragging) {
return;
}
this._dragging = false;
document.body.style.removeProperty("cursor");
return this._renderContent();
}
private _getType() {
@@ -329,15 +226,6 @@ export default class HaAutomationSidebar extends LitElement {
max-height: 100%;
}
}
.handle {
position: absolute;
left: -4;
height: 100%;
width: 8px;
z-index: 7;
cursor: ew-resize;
}
`;
}
@@ -351,8 +239,5 @@ declare global {
"yaml-changed": {
value: unknown;
};
"sidebar-width-changed": {
width: number;
};
}
}

View File

@@ -22,7 +22,6 @@ import {
union,
} from "superstruct";
import { ensureArray } from "../../../common/array/ensure-array";
import { storage } from "../../../common/decorators/storage";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
@@ -102,13 +101,6 @@ export class HaManualAutomationEditor extends LitElement {
@state() private _sidebarKey?: string;
@storage({
key: "automation-sidebar-width-percentage",
state: false,
subscribe: false,
})
private _sidebarWidth? = 30;
@query("ha-automation-sidebar") private _sidebarElement?: HaAutomationSidebar;
@queryAll("ha-automation-action, ha-automation-condition")
@@ -314,7 +306,6 @@ export class HaManualAutomationEditor extends LitElement {
@value-changed=${this._sidebarConfigChanged}
.disabled=${this.disabled}
.sidebarKey=${this._sidebarKey}
@sidebar-width-changed=${this._resizeSidebar}
></ha-automation-sidebar>
</div>
</div>
@@ -323,11 +314,6 @@ export class HaManualAutomationEditor extends LitElement {
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._sidebarWidth}vw`
);
const expanded = extractSearchParam("expanded");
if (expanded === "1") {
this._clearParam("expanded");
@@ -656,14 +642,6 @@ export class HaManualAutomationEditor extends LitElement {
}
}
private _resizeSidebar(ev) {
ev.stopPropagation();
const width = ev.detail.width as number;
this.style.setProperty("--sidebar-dynamic-width", `${width}vw`);
this._sidebarWidth = width;
}
static get styles(): CSSResultGroup {
return [
saveFabStyles,

View File

@@ -109,8 +109,7 @@ export const manualEditorStyles = css`
}
.has-sidebar {
--sidebar-width: min(var(--sidebar-dynamic-width), 1078px);
/* 1540 * 0.7 = 1078px */
--sidebar-width: min(35vw, 500px);
--sidebar-gap: 16px;
}

View File

@@ -65,6 +65,7 @@ import {
updateEntityRegistryEntry,
} from "../../../data/entity_registry";
import { entityIcon, entryIcon } from "../../../data/icons";
import { handleRecordingChange } from "./recorder-util";
import {
domainToName,
fetchIntegrationManifest,
@@ -197,6 +198,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _noDeviceArea?: boolean;
@state() private _recordingDisabled?: boolean;
private _origEntityId!: string;
private _deviceClassOptions?: string[][];
@@ -227,6 +230,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
const domain = computeDomain(this.entry.entity_id);
// Fetch recording settings
this._fetchRecordingSettings();
if (domain === "camera" && isComponentLoaded(this.hass, "stream")) {
const stateObj: HassEntity | undefined =
this.hass.states[this.entry.entity_id];
@@ -972,6 +978,27 @@ export class EntityRegistrySettingsEditor extends LitElement {
></ha-switch>
</ha-settings-row>
${isComponentLoaded(this.hass, "recorder")
? html`
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_label"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_description"
)}</span
>
<ha-switch
.checked=${!this._recordingDisabled}
.disabled=${this.disabled}
@change=${this._recordingChanged}
></ha-switch>
</ha-settings-row>
`
: ""}
${this.entry.device_id
? html`<ha-settings-row>
<span slot="heading"
@@ -1399,6 +1426,15 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._labels = ev.detail.value;
}
private _fetchRecordingSettings() {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
// Get recording settings from entity registry entry options
const recorderOptions = this.entry.options?.recorder;
this._recordingDisabled = recorderOptions?.recording_disabled_by !== null;
}
private async _fetchCameraPrefs() {
const capabilities = await fetchCameraCapabilities(
this.hass,
@@ -1462,6 +1498,19 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private async _recordingChanged(ev: CustomEvent): Promise<void> {
const checkbox = ev.currentTarget as HaSwitch;
await handleRecordingChange({
hass: this.hass,
entityId: this.entry.entity_id,
checkbox,
onSuccess: (recordingDisabled) => {
this._recordingDisabled = recordingDisabled;
},
});
}
private _openDeviceSettings() {
showDeviceRegistryDetailDialog(this, {
device: this._device!,

View File

@@ -0,0 +1,126 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-settings-row";
import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-textfield";
import { getEntityRecordingSettings } from "../../../data/recorder";
import { handleRecordingChange } from "./recorder-util";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("entity-settings-without-unique-id")
export class EntitySettingsWithoutUniqueId extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id" }) public entityId!: string;
@state() private _recordingDisabled?: boolean;
protected firstUpdated() {
this._fetchRecordingSettings();
}
private async _fetchRecordingSettings() {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
try {
const settings = await getEntityRecordingSettings(
this.hass,
this.entityId
);
this._recordingDisabled = settings?.recording_disabled_by !== null;
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Error fetching recording settings:", err);
}
}
protected render() {
const stateObj = this.hass.states[this.entityId];
const name = stateObj?.attributes?.friendly_name || this.entityId;
return html`
<ha-textfield
.label=${this.hass.localize("ui.dialogs.entity_registry.editor.name")}
.value=${name}
disabled
readonly
></ha-textfield>
<ha-textfield
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.entity_id"
)}
.value=${this.entityId}
disabled
readonly
></ha-textfield>
${isComponentLoaded(this.hass, "recorder")
? html`
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_label"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.record_description"
)}</span
>
<ha-switch
.checked=${this._recordingDisabled !== true}
@change=${this._recordingChanged}
></ha-switch>
</ha-settings-row>
`
: nothing}
`;
}
private async _recordingChanged(ev: CustomEvent): Promise<void> {
const checkbox = ev.currentTarget as HaSwitch;
await handleRecordingChange({
hass: this.hass,
entityId: this.entityId,
checkbox,
onSuccess: (recordingDisabled) => {
this._recordingDisabled = recordingDisabled;
// Fire event to notify entities table to refresh recording data
this.dispatchEvent(
new CustomEvent("entity-recording-updated", {
detail: { entityId: this.entityId },
bubbles: true,
composed: true,
})
);
},
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin-top: 16px;
}
ha-textfield {
display: block;
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"entity-settings-without-unique-id": EntitySettingsWithoutUniqueId;
}
}

View File

@@ -23,6 +23,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoize from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -42,7 +43,10 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
@@ -97,6 +101,12 @@ import {
subscribeLabelRegistry,
} from "../../../data/label_registry";
import { regenerateEntityIds } from "../../../data/regenerate_entity_ids";
import {
getEntityRecordingList,
getEntityRecordingSettings,
setEntityRecordingOptions,
type EntityRecordingList,
} from "../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -138,6 +148,7 @@ export interface EntityRow extends StateEntity {
enabled: string;
visible: string;
available: string;
recorded?: boolean;
}
@customElement("ha-config-entities")
@@ -193,6 +204,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entitySources?: EntitySources;
@state() private _recordingEntities?: EntityRecordingList;
@storage({ key: "entities-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@@ -227,12 +240,20 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
window.addEventListener(
"entity-recording-updated",
this._handleEntityRecordingUpdated
);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
window.removeEventListener(
"entity-recording-updated",
this._handleEntityRecordingUpdated
);
}
private _locationChanged = () => {
@@ -249,6 +270,32 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
}
};
private _handleEntityRecordingUpdated = async (ev: Event) => {
const customEvent = ev as CustomEvent<{ entityId: string }>;
// Update recording data for the specific entity that changed
if (this._activeHiddenColumns?.includes("recorded")) {
return;
}
try {
const settings = await getEntityRecordingSettings(
this.hass,
customEvent.detail.entityId
);
// Update the recording data for this specific entity
this._recordingEntities = {
...this._recordingEntities,
[customEvent.detail.entityId]: settings,
};
} catch (_err) {
// Entity might not have recording settings yet, treat as enabled
this._recordingEntities = {
...this._recordingEntities,
[customEvent.detail.entityId]: { recording_disabled_by: null },
};
}
};
private _states = memoize((localize: LocalizeFunc) => [
{
value: "available",
@@ -490,9 +537,75 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
template: (entry) =>
entry.label_entries.map((lbl) => lbl.name).join(" "),
},
recorded: {
title: localize("ui.panel.config.entities.picker.headers.recorded"),
type: "icon",
sortable: true,
filterable: true,
defaultHidden: true,
showNarrow: true,
minWidth: "86px",
maxWidth: "86px",
template: (entry) =>
entry.recorded === undefined
? "—"
: entry.recorded
? html`
<div
tabindex="0"
style="display:inline-block; position: relative;"
>
<ha-svg-icon
.id="recording-icon-${slugify(entry.entity_id)}"
.path=${mdiToggleSwitch}
style="color: var(--success-color)"
></ha-svg-icon>
<ha-tooltip
.for="recording-icon-${slugify(entry.entity_id)}"
placement="left"
>
${this.hass.localize(
"ui.panel.config.entities.picker.recorded.enabled"
)}
</ha-tooltip>
</div>
`
: html`
<div
tabindex="0"
style="display:inline-block; position: relative;"
>
<ha-svg-icon
.id="recording-icon-${slugify(entry.entity_id)}"
.path=${mdiToggleSwitchOffOutline}
style="color: var(--secondary-text-color)"
></ha-svg-icon>
<ha-tooltip
.for="recording-icon-${slugify(entry.entity_id)}"
placement="left"
>
${this.hass.localize(
"ui.panel.config.entities.picker.recorded.disabled"
)}
</ha-tooltip>
</div>
`,
},
})
);
private _hasNonUniqueIdEntities = memoize(
(selected: string[], filteredEntities: EntityRow[]) => {
// Create a Set of readonly entity IDs for O(1) lookup
const readonlyEntityIds = new Set(
filteredEntities
.filter((e) => e.readonly === true)
.map((e) => e.entity_id)
);
return selected.some((entityId) => readonlyEntityIds.has(entityId));
}
);
private _filteredEntitiesAndDomains = memoize(
(
localize: LocalizeFunc,
@@ -503,7 +616,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
filters: DataTableFiltersValues,
filteredItems: DataTableFiltersItems,
entries?: ConfigEntry[],
labelReg?: LabelRegistryEntry[]
labelReg?: LabelRegistryEntry[],
recordingEntities?: EntityRecordingList
) => {
const result: EntityRow[] = [];
@@ -694,6 +808,15 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
: deviceName
: undefined;
// Determine recording status
const recorded: boolean | undefined = recordingEntities
? recordingEntities[entry.entity_id]
? recordingEntities[entry.entity_id].recording_disabled_by === null
: entry.options?.recorder
? entry.options.recorder.recording_disabled_by === null
: true
: undefined;
result.push({
...entry,
entity,
@@ -730,6 +853,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
visible: hidden
? localize("ui.panel.config.entities.picker.status.hidden")
: localize("ui.panel.config.entities.picker.status.visible"),
recorded,
});
}
@@ -760,7 +884,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
this._filters,
this._filteredItems,
this._entries,
this._labels
this._labels,
this._recordingEntities
);
const includeAddDeviceFab =
@@ -769,6 +894,54 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
[...filteredDomains][0]
);
// Check if any selected entities are without unique IDs (memoized for performance)
const hasNonUniqueIdEntities = this._hasNonUniqueIdEntities(
this._selected,
filteredEntities
);
// Helper to render menu items that can be disabled for non-unique ID entities
const renderDisableableMenuItem = (
action: () => void,
icon: string,
labelKey: LocalizeKeys,
itemId: string,
warning = false
) => {
if (hasNonUniqueIdEntities) {
return html`
<div
id=${itemId}
style="position: relative; cursor: not-allowed;"
tabindex="0"
>
<ha-md-menu-item
.disabled=${true}
class=${warning ? "warning" : ""}
style="pointer-events: none; opacity: 0.5;"
>
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
<div slot="headline">${this.hass.localize(labelKey)}</div>
</ha-md-menu-item>
<ha-tooltip .for=${itemId} placement="left">
${this.hass.localize(
"ui.panel.config.entities.picker.non_unique_id_selected"
)}
</ha-tooltip>
</div>
`;
}
return html`
<ha-md-menu-item
.clickAction=${action}
class=${warning ? "warning" : ""}
>
<ha-svg-icon slot="start" .path=${icon}></ha-svg-icon>
<div slot="headline">${this.hass.localize(labelKey)}</div>
</ha-md-menu-item>
`;
};
const labelItems = html` ${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
@@ -915,77 +1088,79 @@ ${
: nothing
}
<ha-md-menu-item .clickAction=${this._enableSelected}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.enable_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._disableSelected}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.disable_selected.button"
)}
</div>
</ha-md-menu-item>
${renderDisableableMenuItem(
this._enableSelected,
mdiToggleSwitch,
"ui.panel.config.entities.picker.enable_selected.button",
"enable-selected-disabled"
)}
${renderDisableableMenuItem(
this._disableSelected,
mdiToggleSwitchOffOutline,
"ui.panel.config.entities.picker.disable_selected.button",
"disable-selected-disabled"
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._unhideSelected}>
<ha-svg-icon
slot="start"
.path=${mdiEye}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.unhide_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._hideSelected}>
<ha-svg-icon
slot="start"
.path=${mdiEyeOff}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.hide_selected.button"
)}
</div>
</ha-md-menu-item>
${renderDisableableMenuItem(
this._unhideSelected,
mdiEye,
"ui.panel.config.entities.picker.unhide_selected.button",
"unhide-selected-disabled"
)}
${renderDisableableMenuItem(
this._hideSelected,
mdiEyeOff,
"ui.panel.config.entities.picker.hide_selected.button",
"hide-selected-disabled"
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._restoreEntityIdSelected}>
<ha-svg-icon
slot="start"
.path=${mdiRestore}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.restore_entity_id_selected.button"
)}
</div>
</ha-md-menu-item>
${
isComponentLoaded(this.hass, "recorder")
? html`
<ha-md-menu-item .clickAction=${this._enableRecordingSelected}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.enable_recording_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._disableRecordingSelected}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.disable_recording_selected.button"
)}
</div>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
`
: ""
}
${renderDisableableMenuItem(
this._restoreEntityIdSelected,
mdiRestore,
"ui.panel.config.entities.picker.restore_entity_id_selected.button",
"restore-selected-disabled"
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._removeSelected} class="warning">
<ha-svg-icon
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.entities.picker.delete_selected.button"
)}
</div>
</ha-md-menu-item>
${renderDisableableMenuItem(
this._removeSelected,
mdiDelete,
"ui.panel.config.entities.picker.delete_selected.button",
"delete-selected-disabled",
true // warning style
)}
</ha-md-button-menu>
${
@@ -1107,6 +1282,12 @@ ${
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
// Fetch recording data if the column is visible
if (!this._activeHiddenColumns?.includes("recorded")) {
this._fetchRecordingData();
}
if (Object.keys(this._filters).length) {
return;
}
@@ -1162,6 +1343,14 @@ ${
changedProps.has("_entities") ||
changedProps.has("_entitySources")
) {
// Re-fetch recording data when entities change
if (
changedProps.has("_entities") &&
this._recordingEntities &&
!this._activeHiddenColumns?.includes("recorded")
) {
this._fetchRecordingData();
}
const stateEntities: StateEntity[] = [];
const regEntityIds = new Set(
this._entities.map((entity) => entity.entity_id)
@@ -1189,7 +1378,7 @@ ${
device_id: null,
icon: null,
readonly: true,
selectable: false,
selectable: true,
entity_category: null,
has_entity_name: false,
options: null,
@@ -1339,6 +1528,53 @@ ${
this._clearSelection();
};
private _setRecordingSelected = async (enable: boolean) => {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
const action = enable ? "enable" : "disable";
showConfirmationDialog(this, {
title: this.hass.localize(
`ui.panel.config.entities.picker.${action}_recording_selected.confirm_title`,
{ number: this._selected.length }
),
text: this.hass.localize(
`ui.panel.config.entities.picker.${action}_recording_selected.confirm_text`
),
confirmText: this.hass.localize(`ui.common.${action}`),
dismissText: this.hass.localize("ui.common.cancel"),
confirm: async () => {
try {
await setEntityRecordingOptions(
this.hass,
this._selected,
enable ? null : "user" // null = enabled, "user" = disabled by user
);
// Re-fetch recording data to update the table
if (this._recordingEntities) {
await this._fetchRecordingData();
}
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.common.multiselect.failed",
{
number: this._selected.length,
}
),
text: err.message,
});
}
},
});
};
private _enableRecordingSelected = () => this._setRecordingSelected(true);
private _disableRecordingSelected = () => this._setRecordingSelected(false);
private async _handleBulkLabel(ev) {
const label = ev.currentTarget.value;
const action = ev.currentTarget.action;
@@ -1497,7 +1733,8 @@ ${rejected
this._filters,
this._filteredItems,
this._entries,
this._labels
this._labels,
this._recordingEntities
);
if (
filteredDomains.size === 1 &&
@@ -1527,9 +1764,30 @@ ${rejected
this._activeCollapsed = ev.detail.value;
}
private async _fetchRecordingData() {
if (!isComponentLoaded(this.hass, "recorder")) {
return;
}
try {
this._recordingEntities = await getEntityRecordingList(this.hass);
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Failed to fetch recording data:", err);
this._recordingEntities = undefined;
}
}
private _handleColumnsChanged(ev: CustomEvent) {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
if (
!this._activeHiddenColumns?.includes("recorded") &&
!this._recordingEntities
) {
this._fetchRecordingData();
}
}
static get styles(): CSSResultGroup {

View File

@@ -0,0 +1,38 @@
import type { HaSwitch } from "../../../components/ha-switch";
import { setEntityRecordingOptions } from "../../../data/recorder";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
export interface RecordingChangeParams {
hass: HomeAssistant;
entityId: string;
checkbox: HaSwitch;
onSuccess?: (recordingDisabled: boolean) => void;
onError?: () => void;
}
export const handleRecordingChange = async (params: RecordingChangeParams) => {
const { hass, entityId, checkbox, onSuccess, onError } = params;
const newRecordingDisabled = !checkbox.checked;
try {
await setEntityRecordingOptions(
hass,
[entityId],
newRecordingDisabled ? "user" : null
);
if (onSuccess) {
onSuccess(newRecordingDisabled);
}
} catch (err: any) {
showAlertDialog(checkbox, {
text: err.message,
});
checkbox.checked = !checkbox.checked;
if (onError) {
onError();
}
}
};

View File

@@ -230,10 +230,10 @@ class HUIRoot extends LitElement {
},
{
icon: mdiSofa,
key: "ui.panel.lovelace.menu.create_area",
key: "ui.panel.lovelace.menu.add_area",
visible: true,
action: this._createArea,
overflowAction: this._handleCreateArea,
action: this._addArea,
overflowAction: this._handleAddArea,
},
{
icon: mdiAccount,
@@ -366,7 +366,6 @@ class HUIRoot extends LitElement {
}
showListItemsDialog(this, {
title: title,
mode: this.narrow ? "bottom-sheet" : "dialog",
items: i.subItems!.map((si) => ({
iconPath: si.icon,
label: this.hass!.localize(si.key),
@@ -838,14 +837,14 @@ class HUIRoot extends LitElement {
showNewAutomationDialog(this, { mode: "automation" });
};
private _handleCreateArea(ev: CustomEvent<RequestSelectedDetail>): void {
private _handleAddArea(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._createArea();
this._addArea();
}
private _createArea = async () => {
private _addArea = async () => {
await this.hass.loadFragmentTranslation("config");
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
@@ -855,15 +854,13 @@ class HUIRoot extends LitElement {
}
showToast(this, {
message: this.hass.localize(
"ui.panel.lovelace.menu.create_area_success"
"ui.panel.lovelace.menu.add_area_success"
),
action: {
action: () => {
navigate(`/config/areas/area/${area.area_id}`);
},
text: this.hass.localize(
"ui.panel.lovelace.menu.create_area_action"
),
text: this.hass.localize("ui.panel.lovelace.menu.add_area_action"),
},
});
},

View File

@@ -1512,7 +1512,7 @@
"settings": "Settings",
"control": "Control",
"related": "Related",
"no_unique_id": "This entity (''{entity_id}'') does not have a unique ID, therefore its settings cannot be managed from the UI. See the {faq_link} for more detail.",
"no_unique_id": "This entity (''{entity_id}'') does not have a unique ID, therefore only limited settings can be managed from the UI. See the {faq_link} for more detail.",
"faq": "documentation",
"editor": {
"name": "Name",
@@ -1605,6 +1605,10 @@
"enabled_delay_confirm": "The enabled entities will be added to Home Assistant in {delay} seconds",
"enabled_restart_confirm": "Restart Home Assistant to finish enabling the entities",
"hidden_explanation": "Hidden entities will not be included in auto-populated dashboards or when their area, device or label is referenced. Their history is still tracked and you can still interact with them with actions.",
"record_label": "Record",
"record_description": "Control whether this entity's state history is recorded by the Home Assistant Recorder.",
"error_loading_recording_settings": "Error loading recording settings",
"error_updating_recording_settings": "Error updating recording settings",
"delete": "Delete",
"confirm_delete": "Are you sure you want to delete this entity?",
"update": "Update",
@@ -5306,7 +5310,12 @@
"domain": "Domain",
"availability": "Availability",
"visibility": "Visibility",
"enabled": "Enabled"
"enabled": "Enabled",
"recorded": "Recorded"
},
"recorded": {
"enabled": "Recording enabled",
"disabled": "Recording disabled"
},
"selected": "{number} selected",
"enable_selected": {
@@ -5331,6 +5340,16 @@
"confirm_text": "Are you sure you want to delete the entities?\n\nRemove them from your dashboard and automations if they include these entities.",
"confirm_partly_text": "You can only delete {deletable} of the {selected} entities. The others require the integration to stop providing them, and sometimes a Home Assistant restart is needed. Are you sure you want to delete the deletable entities?\n\nRemove them from your dashboard and automations if they include these entities."
},
"enable_recording_selected": {
"button": "Enable recording for selected",
"confirm_title": "Do you want to enable recording for {number} {number, plural,\n one {entity}\n other {entities}\n}?",
"confirm_text": "This will resume recording their state history in the recorder."
},
"disable_recording_selected": {
"button": "Disable recording for selected",
"confirm_title": "Do you want to disable recording for {number} {number, plural,\n one {entity}\n other {entities}\n}?",
"confirm_text": "This will stop recording their state history in the recorder."
},
"hide_selected": {
"button": "Hide selected",
"confirm_title": "Do you want to hide {number} {number, plural,\n one {entity}\n other {entities}\n}?",
@@ -5338,7 +5357,8 @@
},
"unhide_selected": {
"button": "Unhide selected"
}
},
"non_unique_id_selected": "Select only entities with unique IDs to enable this operation"
}
},
"person": {
@@ -7059,9 +7079,9 @@
"add": "Add to Home Assistant",
"add_device": "Add device",
"create_automation": "Create automation",
"create_area": "Create area",
"create_area_success": "Area created",
"create_area_action": "View area",
"add_area": "Add area",
"add_area_success": "Area added",
"add_area_action": "View area",
"add_person_success": "Person added",
"add_person_action": "View persons",
"add_person": "Add person"