Skip to content

Commit bb5fefc

Browse files
frenckclaudebramkragten
authored
Introduce Home Assistant Labs (#27989)
Co-authored-by: Claude <[email protected]> Co-authored-by: Bram Kragten <[email protected]>
1 parent 5703de9 commit bb5fefc

11 files changed

+1083
-0
lines changed

src/data/labs.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Connection } from "home-assistant-js-websocket";
2+
import { createCollection } from "home-assistant-js-websocket";
3+
import type { Store } from "home-assistant-js-websocket/dist/store";
4+
import { debounce } from "../common/util/debounce";
5+
import type { HomeAssistant } from "../types";
6+
7+
export interface LabPreviewFeature {
8+
preview_feature: string;
9+
domain: string;
10+
enabled: boolean;
11+
is_built_in: boolean;
12+
feedback_url?: string;
13+
learn_more_url?: string;
14+
report_issue_url?: string;
15+
}
16+
17+
export interface LabPreviewFeaturesResponse {
18+
features: LabPreviewFeature[];
19+
}
20+
21+
export const fetchLabFeatures = async (
22+
hass: HomeAssistant
23+
): Promise<LabPreviewFeature[]> => {
24+
const response = await hass.callWS<LabPreviewFeaturesResponse>({
25+
type: "labs/list",
26+
});
27+
return response.features;
28+
};
29+
30+
export const labsUpdatePreviewFeature = (
31+
hass: HomeAssistant,
32+
domain: string,
33+
preview_feature: string,
34+
enabled: boolean,
35+
create_backup?: boolean
36+
): Promise<void> =>
37+
hass.callWS({
38+
type: "labs/update",
39+
domain,
40+
preview_feature,
41+
enabled,
42+
...(create_backup !== undefined && { create_backup }),
43+
});
44+
45+
const fetchLabFeaturesCollection = (conn: Connection) =>
46+
conn
47+
.sendMessagePromise<LabPreviewFeaturesResponse>({
48+
type: "labs/list",
49+
})
50+
.then((response) => response.features);
51+
52+
const subscribeLabUpdates = (
53+
conn: Connection,
54+
store: Store<LabPreviewFeature[]>
55+
) =>
56+
conn.subscribeEvents(
57+
debounce(
58+
() =>
59+
fetchLabFeaturesCollection(conn).then((features: LabPreviewFeature[]) =>
60+
store.setState(features, true)
61+
),
62+
500,
63+
true
64+
),
65+
"labs_updated"
66+
);
67+
68+
export const subscribeLabFeatures = (
69+
conn: Connection,
70+
onChange: (features: LabPreviewFeature[]) => void
71+
) =>
72+
createCollection<LabPreviewFeature[]>(
73+
"_labFeatures",
74+
fetchLabFeaturesCollection,
75+
subscribeLabUpdates,
76+
conn,
77+
onChange
78+
);

src/data/translation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export type TranslationCategory =
7272
| "system_health"
7373
| "application_credentials"
7474
| "issues"
75+
| "preview_features"
7576
| "selector"
7677
| "services"
7778
| "triggers";

src/panels/config/core/ha-config-system-navigation.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
fetchHassioHassOsInfo,
2424
fetchHassioHostInfo,
2525
} from "../../../data/hassio/host";
26+
import type { LabPreviewFeature } from "../../../data/labs";
27+
import { fetchLabFeatures } from "../../../data/labs";
2628
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
2729
import "../../../layouts/hass-subpage";
2830
import { haStyle } from "../../../resources/styles";
@@ -50,6 +52,8 @@ class HaConfigSystemNavigation extends LitElement {
5052

5153
@state() private _externalAccess = false;
5254

55+
@state() private _labFeatures?: LabPreviewFeature[];
56+
5357
protected render(): TemplateResult {
5458
const pages = configSections.general
5559
.filter((page) => canShowPage(this.hass, page))
@@ -94,6 +98,12 @@ class HaConfigSystemNavigation extends LitElement {
9498
this._boardName ||
9599
this.hass.localize("ui.panel.config.hardware.description");
96100
break;
101+
case "labs":
102+
description =
103+
this._labFeatures && this._labFeatures.some((f) => f.enabled)
104+
? this.hass.localize("ui.panel.config.labs.description_enabled")
105+
: this.hass.localize("ui.panel.config.labs.description");
106+
break;
97107

98108
default:
99109
description = this.hass.localize(
@@ -156,6 +166,7 @@ class HaConfigSystemNavigation extends LitElement {
156166
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
157167
this._fetchBackupInfo();
158168
this._fetchHardwareInfo(isHassioLoaded);
169+
this._fetchLabFeatures();
159170
if (isHassioLoaded) {
160171
this._fetchStorageInfo();
161172
}
@@ -211,6 +222,12 @@ class HaConfigSystemNavigation extends LitElement {
211222
this._externalAccess = this.hass.config.external_url !== null;
212223
}
213224

225+
private async _fetchLabFeatures() {
226+
if (isComponentLoaded(this.hass, "labs")) {
227+
this._labFeatures = await fetchLabFeatures(this.hass);
228+
}
229+
}
230+
214231
private async _showRestartDialog() {
215232
showRestartDialog(this);
216233
}

src/panels/config/ha-panel-config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
mdiCog,
88
mdiDatabase,
99
mdiDevices,
10+
mdiFlask,
1011
mdiInformation,
1112
mdiInformationOutline,
1213
mdiLabel,
@@ -328,6 +329,13 @@ export const configSections: Record<string, PageNavigation[]> = {
328329
iconPath: mdiShape,
329330
iconColor: "#f1c447",
330331
},
332+
{
333+
path: "/config/labs",
334+
translationKey: "labs",
335+
iconPath: mdiFlask,
336+
iconColor: "#b1b134",
337+
core: true,
338+
},
331339
{
332340
path: "/config/network",
333341
translationKey: "network",
@@ -515,6 +523,10 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
515523
tag: "ha-config-section-general",
516524
load: () => import("./core/ha-config-section-general"),
517525
},
526+
labs: {
527+
tag: "ha-config-labs",
528+
load: () => import("./labs/ha-config-labs"),
529+
},
518530
zha: {
519531
tag: "zha-config-dashboard-router",
520532
load: () =>
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { css, html, LitElement, nothing } from "lit";
2+
import { customElement, property, query, state } from "lit/decorators";
3+
import { relativeTime } from "../../../common/datetime/relative_time";
4+
import { fireEvent } from "../../../common/dom/fire_event";
5+
import "../../../components/ha-button";
6+
import type { HaMdDialog } from "../../../components/ha-md-dialog";
7+
import "../../../components/ha-md-dialog";
8+
import "../../../components/ha-md-list";
9+
import "../../../components/ha-md-list-item";
10+
import type { HaSwitch } from "../../../components/ha-switch";
11+
import "../../../components/ha-switch";
12+
import type { BackupConfig } from "../../../data/backup";
13+
import { fetchBackupConfig } from "../../../data/backup";
14+
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
15+
import type { HomeAssistant } from "../../../types";
16+
import type { LabsPreviewFeatureEnableDialogParams } from "./show-dialog-labs-preview-feature-enable";
17+
18+
@customElement("dialog-labs-preview-feature-enable")
19+
export class DialogLabsPreviewFeatureEnable
20+
extends LitElement
21+
implements HassDialog<LabsPreviewFeatureEnableDialogParams>
22+
{
23+
@property({ attribute: false }) public hass!: HomeAssistant;
24+
25+
@state() private _params?: LabsPreviewFeatureEnableDialogParams;
26+
27+
@state() private _backupConfig?: BackupConfig;
28+
29+
@state() private _createBackup = false;
30+
31+
@query("ha-md-dialog") private _dialog?: HaMdDialog;
32+
33+
public async showDialog(
34+
params: LabsPreviewFeatureEnableDialogParams
35+
): Promise<void> {
36+
this._params = params;
37+
this._createBackup = false;
38+
await this._fetchBackupConfig();
39+
}
40+
41+
public closeDialog(): boolean {
42+
this._dialog?.close();
43+
return true;
44+
}
45+
46+
private _dialogClosed(): void {
47+
this._params = undefined;
48+
this._backupConfig = undefined;
49+
this._createBackup = false;
50+
fireEvent(this, "dialog-closed", { dialog: this.localName });
51+
}
52+
53+
private async _fetchBackupConfig() {
54+
try {
55+
const { config } = await fetchBackupConfig(this.hass);
56+
this._backupConfig = config;
57+
58+
// Default to enabled if automatic backups are configured, disabled otherwise
59+
this._createBackup =
60+
config.automatic_backups_configured &&
61+
!!config.create_backup.password &&
62+
config.create_backup.agent_ids.length > 0;
63+
} catch {
64+
// User will get manual backup option if fetch fails
65+
this._createBackup = false;
66+
}
67+
}
68+
69+
private _computeCreateBackupTexts():
70+
| { title: string; description?: string }
71+
| undefined {
72+
if (
73+
!this._backupConfig ||
74+
!this._backupConfig.automatic_backups_configured ||
75+
!this._backupConfig.create_backup.password ||
76+
this._backupConfig.create_backup.agent_ids.length === 0
77+
) {
78+
return {
79+
title: this.hass.localize("ui.panel.config.labs.create_backup.manual"),
80+
description: this.hass.localize(
81+
"ui.panel.config.labs.create_backup.manual_description"
82+
),
83+
};
84+
}
85+
86+
const lastAutomaticBackupDate = this._backupConfig
87+
.last_completed_automatic_backup
88+
? new Date(this._backupConfig.last_completed_automatic_backup)
89+
: null;
90+
const now = new Date();
91+
92+
return {
93+
title: this.hass.localize("ui.panel.config.labs.create_backup.automatic"),
94+
description: lastAutomaticBackupDate
95+
? this.hass.localize(
96+
"ui.panel.config.labs.create_backup.automatic_description_last",
97+
{
98+
relative_time: relativeTime(
99+
lastAutomaticBackupDate,
100+
this.hass.locale,
101+
now,
102+
true
103+
),
104+
}
105+
)
106+
: this.hass.localize(
107+
"ui.panel.config.labs.create_backup.automatic_description_none"
108+
),
109+
};
110+
}
111+
112+
private _createBackupChanged(ev: Event): void {
113+
this._createBackup = (ev.target as HaSwitch).checked;
114+
}
115+
116+
private _handleCancel(): void {
117+
this.closeDialog();
118+
}
119+
120+
private _handleConfirm(): void {
121+
if (this._params) {
122+
this._params.onConfirm(this._createBackup);
123+
}
124+
this.closeDialog();
125+
}
126+
127+
protected render() {
128+
if (!this._params) {
129+
return nothing;
130+
}
131+
132+
const createBackupTexts = this._computeCreateBackupTexts();
133+
134+
return html`
135+
<ha-md-dialog open @closed=${this._dialogClosed}>
136+
<span slot="headline">
137+
${this.hass.localize("ui.panel.config.labs.enable_title")}
138+
</span>
139+
<div slot="content">
140+
<p>
141+
${this.hass.localize(
142+
`component.${this._params.preview_feature.domain}.preview_features.${this._params.preview_feature.preview_feature}.enable_confirmation`
143+
) || this.hass.localize("ui.panel.config.labs.enable_confirmation")}
144+
</p>
145+
</div>
146+
<div slot="actions">
147+
${createBackupTexts
148+
? html`
149+
<ha-md-list>
150+
<ha-md-list-item>
151+
<span slot="headline">${createBackupTexts.title}</span>
152+
${createBackupTexts.description
153+
? html`
154+
<span slot="supporting-text">
155+
${createBackupTexts.description}
156+
</span>
157+
`
158+
: nothing}
159+
<ha-switch
160+
slot="end"
161+
.checked=${this._createBackup}
162+
@change=${this._createBackupChanged}
163+
></ha-switch>
164+
</ha-md-list-item>
165+
</ha-md-list>
166+
`
167+
: nothing}
168+
<div>
169+
<ha-button appearance="plain" @click=${this._handleCancel}>
170+
${this.hass.localize("ui.common.cancel")}
171+
</ha-button>
172+
<ha-button
173+
appearance="filled"
174+
variant="brand"
175+
@click=${this._handleConfirm}
176+
>
177+
${this.hass.localize("ui.panel.config.labs.enable")}
178+
</ha-button>
179+
</div>
180+
</div>
181+
</ha-md-dialog>
182+
`;
183+
}
184+
185+
static readonly styles = css`
186+
ha-md-dialog {
187+
--dialog-content-padding: var(--ha-space-6);
188+
}
189+
190+
p {
191+
margin: 0;
192+
color: var(--secondary-text-color);
193+
}
194+
195+
div[slot="actions"] {
196+
display: flex;
197+
flex-direction: column;
198+
padding: 0;
199+
}
200+
201+
ha-md-list {
202+
background: none;
203+
--md-list-item-leading-space: var(--ha-space-6);
204+
--md-list-item-trailing-space: var(--ha-space-6);
205+
margin: 0;
206+
padding: 0;
207+
border-top: 1px solid var(--divider-color);
208+
}
209+
210+
div[slot="actions"] > div {
211+
display: flex;
212+
justify-content: flex-end;
213+
gap: var(--ha-space-2);
214+
padding: var(--ha-space-4) var(--ha-space-6);
215+
}
216+
`;
217+
}
218+
219+
declare global {
220+
interface HTMLElementTagNameMap {
221+
"dialog-labs-preview-feature-enable": DialogLabsPreviewFeatureEnable;
222+
}
223+
}

0 commit comments

Comments
 (0)