mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-01 16:42:25 +00:00
Compare commits
1 Commits
rc
...
dashboard-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
275662b4b4 |
@@ -22,6 +22,10 @@ export interface HomeFrontendSystemData {
|
||||
welcome_banner_dismissed?: boolean;
|
||||
}
|
||||
|
||||
export interface MaintenanceFrontendSystemData {
|
||||
battery_attention_threshold?: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface FrontendUserData {
|
||||
core: CoreFrontendUserData;
|
||||
@@ -30,6 +34,7 @@ declare global {
|
||||
interface FrontendSystemData {
|
||||
core: CoreFrontendSystemData;
|
||||
home: HomeFrontendSystemData;
|
||||
maintenance: MaintenanceFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const COMPONENTS = {
|
||||
todo: () => import("../panels/todo/ha-panel-todo"),
|
||||
"media-browser": () =>
|
||||
import("../panels/media-browser/ha-panel-media-browser"),
|
||||
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
|
||||
light: () => import("../panels/light/ha-panel-light"),
|
||||
security: () => import("../panels/security/ha-panel-security"),
|
||||
climate: () => import("../panels/climate/ha-panel-climate"),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
type HomeFrontendSystemData,
|
||||
type MaintenanceFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
|
||||
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
|
||||
@@ -46,6 +47,8 @@ class PanelHome extends LitElement {
|
||||
|
||||
@state() private _config: FrontendSystemData["home"] = {};
|
||||
|
||||
@state() private _maintenanceConfig: MaintenanceFrontendSystemData = {};
|
||||
|
||||
@state() private _extraActionItems?: ExtraActionItem[];
|
||||
|
||||
private get _showBanner(): boolean {
|
||||
@@ -122,15 +125,18 @@ class PanelHome extends LitElement {
|
||||
private async _setup() {
|
||||
this._updateExtraActionItems();
|
||||
try {
|
||||
const [_, data] = await Promise.all([
|
||||
const [_, homeData, maintenanceData] = await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "home"),
|
||||
fetchFrontendSystemData(this.hass.connection, "maintenance"),
|
||||
]);
|
||||
this._config = data || {};
|
||||
this._config = homeData || {};
|
||||
this._maintenanceConfig = maintenanceData || {};
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load favorites:", err);
|
||||
this._config = {};
|
||||
this._maintenanceConfig = {};
|
||||
}
|
||||
this._setLovelace();
|
||||
}
|
||||
@@ -317,6 +323,8 @@ class PanelHome extends LitElement {
|
||||
strategy: {
|
||||
type: "home",
|
||||
favorite_entities: this._config.favorite_entities,
|
||||
battery_attention_threshold:
|
||||
this._maintenanceConfig.battery_attention_threshold,
|
||||
home_panel: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import { countMaintenanceDevicesNeedingAttention } from "../../maintenance/maintenance-battery-data";
|
||||
import {
|
||||
getSummaryLabel,
|
||||
HOME_SUMMARIES_FILTERS,
|
||||
@@ -42,6 +43,7 @@ const COLORS: Record<HomeSummary, string> = {
|
||||
climate: "deep-orange",
|
||||
security: "blue-grey",
|
||||
media_players: "blue",
|
||||
maintenance: "amber",
|
||||
energy: "amber",
|
||||
};
|
||||
|
||||
@@ -257,6 +259,21 @@ export class HuiHomeSummaryCard
|
||||
const totalConsumption = consumption.total.used_total;
|
||||
return formatConsumptionShort(this.hass, totalConsumption, "kWh");
|
||||
}
|
||||
case "maintenance": {
|
||||
const count = countMaintenanceDevicesNeedingAttention(
|
||||
this.hass,
|
||||
this._config.attention_threshold
|
||||
);
|
||||
|
||||
return count > 0
|
||||
? this.hass.localize(
|
||||
"ui.card.home-summary.count_devices_needing_attention",
|
||||
{ count }
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.card.home-summary.no_devices_needing_attention"
|
||||
);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -664,6 +664,7 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
|
||||
|
||||
export interface HomeSummaryCard extends LovelaceCardConfig {
|
||||
summary: HomeSummary;
|
||||
attention_threshold?: number;
|
||||
vertical?: boolean;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
|
||||
@@ -56,6 +56,8 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
|
||||
"home-area": () => import("./home/home-area-view-strategy"),
|
||||
"home-other-devices": () =>
|
||||
import("./home/home-other-devices-view-strategy"),
|
||||
maintenance: () =>
|
||||
import("../../maintenance/strategies/maintenance-view-strategy"),
|
||||
light: () => import("../../light/strategies/light-view-strategy"),
|
||||
security: () => import("../../security/strategies/security-view-strategy"),
|
||||
climate: () => import("../../climate/strategies/climate-view-strategy"),
|
||||
|
||||
@@ -9,6 +9,7 @@ export const HOME_SUMMARIES = [
|
||||
"climate",
|
||||
"security",
|
||||
"media_players",
|
||||
"maintenance",
|
||||
"energy",
|
||||
] as const;
|
||||
|
||||
@@ -19,6 +20,7 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
|
||||
climate: "mdi:home-thermometer",
|
||||
security: "mdi:security",
|
||||
media_players: "mdi:multimedia",
|
||||
maintenance: "mdi:battery-heart-variant",
|
||||
energy: "mdi:lightning-bolt",
|
||||
};
|
||||
|
||||
@@ -27,6 +29,7 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
|
||||
climate: climateEntityFilters,
|
||||
security: securityEntityFilters,
|
||||
media_players: [{ domain: "media_player", entity_category: "none" }],
|
||||
maintenance: [],
|
||||
energy: [], // Uses energy collection data
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strate
|
||||
export interface HomeDashboardStrategyConfig {
|
||||
type: "home";
|
||||
favorite_entities?: string[];
|
||||
battery_attention_threshold?: number;
|
||||
home_panel?: boolean;
|
||||
}
|
||||
|
||||
@@ -93,6 +94,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
strategy: {
|
||||
type: "home-overview",
|
||||
favorite_entities: config.favorite_entities,
|
||||
battery_attention_threshold: config.battery_attention_threshold,
|
||||
home_panel: config.home_panel,
|
||||
} satisfies HomeOverviewViewStrategyConfig,
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
TileCardConfig,
|
||||
UpdatesCardConfig,
|
||||
} from "../../cards/types";
|
||||
import { getMaintenanceBatteryDevices } from "../../../maintenance/maintenance-battery-data";
|
||||
import {
|
||||
LARGE_SCREEN_CONDITION,
|
||||
SMALL_SCREEN_CONDITION,
|
||||
@@ -38,6 +39,7 @@ import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
|
||||
export interface HomeOverviewViewStrategyConfig {
|
||||
type: "home-overview";
|
||||
favorite_entities?: string[];
|
||||
battery_attention_threshold?: number;
|
||||
home_panel?: boolean;
|
||||
}
|
||||
|
||||
@@ -232,6 +234,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
const hasSecurity =
|
||||
hass.panels.security &&
|
||||
findEntities(allEntities, securityFilters).length > 0;
|
||||
const hasMaintenance =
|
||||
hass.panels.maintenance &&
|
||||
getMaintenanceBatteryDevices(hass, config.battery_attention_threshold)
|
||||
.length > 0;
|
||||
|
||||
const weatherFilter = generateEntityFilter(hass, {
|
||||
domain: "weather",
|
||||
@@ -315,6 +321,16 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
navigation_path: "media-players",
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
hasMaintenance &&
|
||||
({
|
||||
type: "home-summary",
|
||||
summary: "maintenance",
|
||||
attention_threshold: config.battery_attention_threshold,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/maintenance?historyBack=1",
|
||||
},
|
||||
} satisfies HomeSummaryCard),
|
||||
weatherEntity &&
|
||||
({
|
||||
type: "tile",
|
||||
|
||||
178
src/panels/maintenance/dialogs/dialog-edit-maintenance.ts
Normal file
178
src/panels/maintenance/dialogs/dialog-edit-maintenance.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-form/ha-form";
|
||||
import type { HaFormSchema } from "../../../components/ha-form/types";
|
||||
import type { MaintenanceFrontendSystemData } from "../../../data/frontend";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { DEFAULT_BATTERY_ATTENTION_THRESHOLD } from "../maintenance-battery-data";
|
||||
import type { EditMaintenanceDialogParams } from "./show-dialog-edit-maintenance";
|
||||
|
||||
const THRESHOLD_SCHEMA = [
|
||||
{
|
||||
name: "battery_attention_threshold",
|
||||
selector: {
|
||||
number: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
mode: "slider",
|
||||
slider_ticks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies HaFormSchema[];
|
||||
|
||||
@customElement("dialog-edit-maintenance")
|
||||
export class DialogEditMaintenance
|
||||
extends LitElement
|
||||
implements HassDialog<EditMaintenanceDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EditMaintenanceDialogParams;
|
||||
|
||||
@state() private _config?: MaintenanceFrontendSystemData;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
public showDialog(params: EditMaintenanceDialogParams): void {
|
||||
this._params = params;
|
||||
this._config = { ...params.config };
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._config = undefined;
|
||||
this._submitting = false;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
.headerTitle=${this.hass.localize("ui.panel.maintenance.editor.title")}
|
||||
prevent-scrim-close
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<p class="description">
|
||||
${this.hass.localize("ui.panel.maintenance.editor.description")}
|
||||
</p>
|
||||
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.data=${{
|
||||
battery_attention_threshold:
|
||||
this._config?.battery_attention_threshold ??
|
||||
DEFAULT_BATTERY_ATTENTION_THRESHOLD,
|
||||
}}
|
||||
.schema=${THRESHOLD_SCHEMA}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
const threshold = ev.detail.value.battery_attention_threshold as number;
|
||||
|
||||
this._config = {
|
||||
battery_attention_threshold:
|
||||
threshold === DEFAULT_BATTERY_ATTENTION_THRESHOLD
|
||||
? undefined
|
||||
: threshold,
|
||||
};
|
||||
}
|
||||
|
||||
private _computeLabel = (schema: HaFormSchema) =>
|
||||
schema.name === "battery_attention_threshold"
|
||||
? this.hass.localize(
|
||||
"ui.panel.maintenance.editor.battery_attention_threshold"
|
||||
)
|
||||
: "";
|
||||
|
||||
private _computeHelper = (schema: HaFormSchema) =>
|
||||
schema.name === "battery_attention_threshold"
|
||||
? this.hass.localize(
|
||||
"ui.panel.maintenance.editor.battery_attention_threshold_helper"
|
||||
)
|
||||
: "";
|
||||
|
||||
private async _save(): Promise<void> {
|
||||
if (!this._params || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._submitting = true;
|
||||
|
||||
try {
|
||||
await this._params.saveConfig(this._config);
|
||||
this.closeDialog();
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-form {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-edit-maintenance": DialogEditMaintenance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { MaintenanceFrontendSystemData } from "../../../data/frontend";
|
||||
|
||||
export interface EditMaintenanceDialogParams {
|
||||
config: MaintenanceFrontendSystemData;
|
||||
saveConfig: (config: MaintenanceFrontendSystemData) => Promise<void>;
|
||||
}
|
||||
|
||||
export const loadEditMaintenanceDialog = () =>
|
||||
import("./dialog-edit-maintenance");
|
||||
|
||||
export const showEditMaintenanceDialog = (
|
||||
element: HTMLElement,
|
||||
params: EditMaintenanceDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-edit-maintenance",
|
||||
dialogImport: loadEditMaintenanceDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
311
src/panels/maintenance/ha-panel-maintenance.ts
Normal file
311
src/panels/maintenance/ha-panel-maintenance.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { goBack } from "../../common/navigate";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-menu-button";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
type MaintenanceFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import { showEditMaintenanceDialog } from "./dialogs/show-dialog-edit-maintenance";
|
||||
|
||||
@customElement("ha-panel-maintenance")
|
||||
class PanelMaintenance extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@state() private _viewIndex = 0;
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config: MaintenanceFrontendSystemData = {};
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!changedProps.has("hass")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as this["hass"];
|
||||
if (oldHass && oldHass.localize !== this.hass.localize) {
|
||||
this._setLovelace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldHass && this.hass) {
|
||||
if (
|
||||
oldHass.entities !== this.hass.entities ||
|
||||
oldHass.devices !== this.hass.devices ||
|
||||
oldHass.areas !== this.hass.areas ||
|
||||
oldHass.floors !== this.hass.floors ||
|
||||
oldHass.panels !== this.hass.panels
|
||||
) {
|
||||
if (this.hass.config.state === "RUNNING") {
|
||||
this._debounceRegistriesChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.hass.config.state === "RUNNING" &&
|
||||
oldHass.config.state !== "RUNNING"
|
||||
) {
|
||||
this._setup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
await this.hass.loadFragmentTranslation("lovelace");
|
||||
|
||||
try {
|
||||
this._config =
|
||||
(await fetchFrontendSystemData(this.hass.connection, "maintenance")) ||
|
||||
{};
|
||||
} catch (_err) {
|
||||
this._config = {};
|
||||
}
|
||||
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
private _debounceRegistriesChanged = debounce(
|
||||
() => this._registriesChanged(),
|
||||
200
|
||||
);
|
||||
|
||||
private _registriesChanged = async () => {
|
||||
this._setLovelace();
|
||||
};
|
||||
|
||||
private _back(ev) {
|
||||
ev.stopPropagation();
|
||||
goBack();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar">
|
||||
${this._searchParms.has("historyBack")
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@click=${this._back}
|
||||
slot="navigationIcon"
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-menu-button
|
||||
slot="navigationIcon"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ha-menu-button>
|
||||
`}
|
||||
<div class="main-title">
|
||||
${this.hass.localize("panel.maintenance")}
|
||||
</div>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.panel.maintenance.editor.title")}
|
||||
.path=${mdiPencil}
|
||||
@click=${this._editMaintenance}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
${this._lovelace
|
||||
? html`
|
||||
<hui-view-container .hass=${this.hass}>
|
||||
<hui-view-background .hass=${this.hass}></hui-view-background>
|
||||
<hui-view
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.lovelace=${this._lovelace}
|
||||
.index=${this._viewIndex}
|
||||
></hui-view>
|
||||
</hui-view-container>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private async _setLovelace() {
|
||||
const rawViewConfig: LovelaceStrategyViewConfig = {
|
||||
strategy: {
|
||||
type: "maintenance",
|
||||
battery_attention_threshold: this._config.battery_attention_threshold,
|
||||
},
|
||||
};
|
||||
|
||||
const viewConfig = await generateLovelaceViewStrategy(
|
||||
rawViewConfig,
|
||||
this.hass
|
||||
);
|
||||
|
||||
const config = { views: [viewConfig] };
|
||||
const rawConfig = { views: [rawViewConfig] };
|
||||
|
||||
if (deepEqual(config, this._lovelace?.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lovelace = {
|
||||
config,
|
||||
rawConfig,
|
||||
editMode: false,
|
||||
urlPath: "maintenance",
|
||||
mode: "generated",
|
||||
locale: this.hass.locale,
|
||||
enableFullEditMode: () => undefined,
|
||||
saveConfig: async () => undefined,
|
||||
deleteConfig: async () => undefined,
|
||||
setEditMode: () => undefined,
|
||||
showToast: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private _editMaintenance = () => {
|
||||
showEditMaintenanceDialog(this, {
|
||||
config: this._config,
|
||||
saveConfig: async (config) => {
|
||||
await this._saveConfig(config);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private async _saveConfig(
|
||||
config: MaintenanceFrontendSystemData
|
||||
): Promise<void> {
|
||||
try {
|
||||
await saveFrontendSystemData(this.hass.connection, "maintenance", config);
|
||||
this._config = config;
|
||||
} catch (_err) {
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.panel.maintenance.editor.save_failed"),
|
||||
duration: 0,
|
||||
dismissable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_saved"),
|
||||
});
|
||||
this._setLovelace();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: var(--app-header-background-color);
|
||||
color: var(--app-header-text-color, white);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: calc(
|
||||
var(--mdc-top-app-bar-width, 100%) - var(
|
||||
--safe-area-inset-right,
|
||||
0px
|
||||
)
|
||||
);
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
|
||||
backdrop-filter: var(--app-header-backdrop-filter, none);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
:host([narrow]) .header {
|
||||
width: calc(
|
||||
var(--mdc-top-app-bar-width, 100%) - var(
|
||||
--safe-area-inset-left,
|
||||
0px
|
||||
) - var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
:host([scrolled]) .header {
|
||||
box-shadow: var(
|
||||
--mdc-top-app-bar-fixed-box-shadow,
|
||||
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
|
||||
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
padding: 0 12px;
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
box-sizing: border-box;
|
||||
border-bottom: var(--app-header-border-bottom, none);
|
||||
}
|
||||
|
||||
:host([narrow]) .toolbar {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.narrow .main-title {
|
||||
margin-inline-start: var(--ha-space-2);
|
||||
}
|
||||
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-panel-maintenance": PanelMaintenance;
|
||||
}
|
||||
}
|
||||
104
src/panels/maintenance/maintenance-battery-data.ts
Normal file
104
src/panels/maintenance/maintenance-battery-data.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { generateEntityFilter } from "../../common/entity/entity_filter";
|
||||
import { clamp } from "../../common/number/clamp";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import {
|
||||
findBatteryEntity,
|
||||
type EntityRegistryDisplayEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export const DEFAULT_BATTERY_ATTENTION_THRESHOLD = 30;
|
||||
|
||||
export interface MaintenanceBatteryDevice {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
entityId: string;
|
||||
level: number;
|
||||
needsAttention: boolean;
|
||||
}
|
||||
|
||||
export const normalizeBatteryAttentionThreshold = (
|
||||
threshold?: number
|
||||
): number =>
|
||||
typeof threshold === "number" && !Number.isNaN(threshold)
|
||||
? clamp(Math.round(threshold), 0, 100)
|
||||
: DEFAULT_BATTERY_ATTENTION_THRESHOLD;
|
||||
|
||||
export const getMaintenanceBatteryDevices = (
|
||||
hass: HomeAssistant,
|
||||
attentionThreshold?: number
|
||||
): MaintenanceBatteryDevice[] => {
|
||||
const normalizedThreshold =
|
||||
normalizeBatteryAttentionThreshold(attentionThreshold);
|
||||
|
||||
const batteryFilter = generateEntityFilter(hass, {
|
||||
domain: "sensor",
|
||||
device_class: "battery",
|
||||
});
|
||||
|
||||
const entitiesByDevice: Record<string, EntityRegistryDisplayEntry[]> = {};
|
||||
|
||||
for (const entry of Object.values(hass.entities)) {
|
||||
if (!entry.device_id || !(entry.entity_id in hass.states)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!batteryFilter(entry.entity_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(entry.device_id in entitiesByDevice)) {
|
||||
entitiesByDevice[entry.device_id] = [];
|
||||
}
|
||||
|
||||
entitiesByDevice[entry.device_id].push(entry);
|
||||
}
|
||||
|
||||
return Object.entries(entitiesByDevice)
|
||||
.flatMap(([deviceId, entities]) => {
|
||||
const batteryEntity = findBatteryEntity(hass, entities);
|
||||
|
||||
if (!batteryEntity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stateObj = hass.states[batteryEntity.entity_id];
|
||||
const level = Number(stateObj?.state);
|
||||
|
||||
if (!stateObj || !Number.isFinite(level)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const device = hass.devices[deviceId];
|
||||
const deviceName =
|
||||
(device && computeDeviceName(device)) ||
|
||||
computeStateName(stateObj) ||
|
||||
hass.localize("ui.panel.lovelace.strategy.home.unnamed_device");
|
||||
|
||||
return [
|
||||
{
|
||||
deviceId,
|
||||
deviceName,
|
||||
entityId: batteryEntity.entity_id,
|
||||
level,
|
||||
needsAttention: level < normalizedThreshold,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.needsAttention) - Number(a.needsAttention) ||
|
||||
a.level - b.level ||
|
||||
caseInsensitiveStringCompare(a.deviceName, b.deviceName)
|
||||
);
|
||||
};
|
||||
|
||||
export const countMaintenanceDevicesNeedingAttention = (
|
||||
hass: HomeAssistant,
|
||||
attentionThreshold?: number
|
||||
): number =>
|
||||
getMaintenanceBatteryDevices(hass, attentionThreshold).filter(
|
||||
(device) => device.needsAttention
|
||||
).length;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type {
|
||||
EmptyStateCardConfig,
|
||||
HeadingCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../lovelace/cards/types";
|
||||
import { getMaintenanceBatteryDevices } from "../maintenance-battery-data";
|
||||
|
||||
export interface MaintenanceViewStrategyConfig {
|
||||
type: "maintenance";
|
||||
battery_attention_threshold?: number;
|
||||
}
|
||||
|
||||
@customElement("maintenance-view-strategy")
|
||||
export class MaintenanceViewStrategy extends ReactiveElement {
|
||||
static async generate(
|
||||
config: MaintenanceViewStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceViewConfig> {
|
||||
const batteryDevices = getMaintenanceBatteryDevices(
|
||||
hass,
|
||||
config.battery_attention_threshold
|
||||
);
|
||||
|
||||
if (batteryDevices.length === 0) {
|
||||
return {
|
||||
type: "panel",
|
||||
cards: [
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:battery-outline",
|
||||
icon_color: "primary",
|
||||
content_only: true,
|
||||
title: hass.localize("ui.panel.maintenance.empty_title"),
|
||||
content: hass.localize("ui.panel.maintenance.empty_content"),
|
||||
} as EmptyStateCardConfig,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const section: LovelaceSectionRawConfig = {
|
||||
type: "grid",
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize("ui.panel.maintenance.devices"),
|
||||
heading_style: "title",
|
||||
icon: "mdi:battery-heart-variant",
|
||||
} satisfies HeadingCardConfig,
|
||||
...batteryDevices.map(
|
||||
(device) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: device.entityId,
|
||||
name: device.deviceName,
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: `/config/devices/device/${device.deviceId}`,
|
||||
},
|
||||
features: [
|
||||
{
|
||||
type: "bar-gauge",
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
],
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
type: "sections",
|
||||
max_columns: 1,
|
||||
sections: [section],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"maintenance-view-strategy": MaintenanceViewStrategy;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"light": "Lights",
|
||||
"security": "Security",
|
||||
"climate": "Climate",
|
||||
"maintenance": "Maintenance",
|
||||
"home": "Overview",
|
||||
"notfound": "Page not found"
|
||||
},
|
||||
@@ -214,7 +215,9 @@
|
||||
"count_alarms_disarmed": "{count} {count, plural,\n one {disarmed}\n other {disarmed}\n}",
|
||||
"all_secure": "All secure",
|
||||
"no_media_playing": "No media playing",
|
||||
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}"
|
||||
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
|
||||
"count_devices_needing_attention": "{count} {count, plural,\n one {needs attention}\n other {need attention}\n}",
|
||||
"no_devices_needing_attention": "No devices need attention"
|
||||
},
|
||||
"toggle-group": {
|
||||
"all_off": "All off",
|
||||
@@ -2442,6 +2445,18 @@
|
||||
"learn_more": "Learn more"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"devices": "Devices",
|
||||
"empty_title": "No battery devices found",
|
||||
"empty_content": "Home Assistant could not find any devices with numeric battery sensors.",
|
||||
"editor": {
|
||||
"title": "Edit maintenance dashboard",
|
||||
"description": "Choose when a battery-powered device should be marked as needing attention.",
|
||||
"battery_attention_threshold": "Battery attention threshold",
|
||||
"battery_attention_threshold_helper": "Devices below this battery level are shown as needing attention.",
|
||||
"save_failed": "Failed to save maintenance dashboard configuration"
|
||||
}
|
||||
},
|
||||
"my": {
|
||||
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
|
||||
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
|
||||
@@ -8187,6 +8202,7 @@
|
||||
"home": {
|
||||
"summary_list": {
|
||||
"media_players": "Media players",
|
||||
"maintenance": "Maintenance",
|
||||
"other_devices": "Other devices",
|
||||
"weather": "Weather",
|
||||
"energy": "Today's energy"
|
||||
|
||||
Reference in New Issue
Block a user