1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

Add all hardware table (#30312)

* add all hardware table

* copilot review

* Updated tab names

* update localize keys

* Fix translations

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Wendelin
2026-03-26 08:10:09 +01:00
committed by GitHub
parent 5c6dd2a697
commit 50b727393d
10 changed files with 799 additions and 815 deletions

View File

@@ -15,7 +15,7 @@ interface HassioHardwareAudioList {
};
}
interface HardwareDevice {
export interface HardwareDevice {
attributes: Record<string, string>;
by_id: null | string;
dev_path: string;

View File

@@ -73,7 +73,6 @@ class DialogBox extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
type=${confirmPrompt ? "alert" : "standard"}
?prevent-scrim-close=${confirmPrompt}
@@ -104,6 +103,9 @@ class DialogBox extends LitElement {
: nothing}
${dialogTitle}
</span>
${this._params.subtitle
? html`<span slot="subtitle">${this._params.subtitle}</span>`
: nothing}
</ha-dialog-header>
<div id="dialog-box-description">
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}

View File

@@ -5,6 +5,7 @@ interface BaseDialogBoxParams {
confirmText?: string;
text?: string | TemplateResult;
title?: string;
subtitle?: string;
warning?: boolean;
}

View File

@@ -1,226 +0,0 @@
import { dump } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-dialog";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-next";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHardwareInfo } from "../../../data/hassio/hardware";
import { fetchHassioHardwareInfo } from "../../../data/hassio/hardware";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
const _filterDevices = memoizeOne(
(
showAdvanced: boolean,
hardware: HassioHardwareInfo,
filter: string,
language: string
) =>
hardware.devices
.filter(
(device) =>
(showAdvanced ||
["tty", "gpio", "input"].includes(device.subsystem)) &&
(device.by_id?.toLowerCase().includes(filter) ||
device.name.toLowerCase().includes(filter) ||
device.dev_path.toLocaleLowerCase().includes(filter) ||
JSON.stringify(device.attributes)
.toLocaleLowerCase()
.includes(filter))
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
@customElement("ha-dialog-hardware-available")
class DialogHardwareAvailable extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _hardware?: HassioHardwareInfo;
@state() private _filter?: string;
@state() private _open = false;
public async showDialog(): Promise<Promise<void>> {
try {
this._hardware = await fetchHassioHardwareInfo(this.hass);
this._open = true;
} catch (err: any) {
await showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.hardware.available_hardware.failed_to_get"
),
text: extractApiErrorMessage(err),
});
}
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed() {
this._open = false;
this._hardware = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._hardware) {
return nothing;
}
const devices = _filterDevices(
this.hass.userData?.showAdvanced || false,
this._hardware,
(this._filter || "").toLowerCase(),
this.hass.locale.language
);
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
header-title=${this.hass.localize(
"ui.panel.config.hardware.available_hardware.title"
)}
@closed=${this._dialogClosed}
>
<div class="content-container">
<ha-input-search
appearance="outlined"
autofocus
.value=${this._filter}
@input=${this._handleSearchChange}
.placeholder=${this.hass.localize(
"ui.panel.config.hardware.available_hardware.search"
)}
>
</ha-input-search>
<div class="devices-container ha-scrollbar">
${devices.map(
(device) => html`
<ha-expansion-panel
.header=${device.name}
.secondary=${device.by_id || undefined}
outlined
>
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.subsystem"
)}:
</span>
<span>${device.subsystem}</span>
</div>
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.device_path"
)}:
</span>
<code>${device.dev_path}</code>
</div>
${device.by_id
? html`
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.id"
)}:
</span>
<code>${device.by_id}</code>
</div>
`
: nothing}
<div class="attributes">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.attributes"
)}:
</span>
<pre>${dump(device.attributes, { indent: 2 })}</pre>
</div>
</ha-expansion-panel>
`
)}
</div>
</div>
</ha-dialog>
`;
}
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.content-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.devices-container {
padding: var(--ha-space-6);
overflow-y: auto;
flex: 1;
min-height: 0;
}
ha-expansion-panel {
flex: 1;
margin: 4px 0;
}
pre,
code {
background-color: var(--markdown-code-background-color, none);
border-radius: var(--ha-border-radius-sm);
}
pre {
padding: 16px;
overflow: auto;
line-height: var(--ha-line-height-normal);
font-family: var(--ha-font-family-code);
}
code {
font-size: var(--ha-font-size-s);
padding: 0.2em 0.4em;
}
ha-input-search {
padding: 0 var(--ha-space-5);
}
.device-property {
display: flex;
justify-content: space-between;
}
.attributes {
margin-top: 12px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-hardware-available": DialogHardwareAvailable;
}
}

View File

@@ -0,0 +1,166 @@
import { dump } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
HardwareDevice,
HassioHardwareInfo,
} from "../../../data/hassio/hardware";
import { fetchHassioHardwareInfo } from "../../../data/hassio/hardware";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { hardwareTabs } from "./ha-config-hardware";
interface HardwareDeviceRow extends HardwareDevice {
id: string;
attributes_string: string;
}
@customElement("ha-config-hardware-all")
class HaConfigHardwareAll extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _hardware?: HassioHardwareInfo;
@state() private _error?: string;
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<HardwareDeviceRow> => ({
name: {
title: localize("ui.panel.config.hardware.system_hardware.name"),
main: true,
sortable: true,
filterable: true,
flex: 2,
},
dev_path: {
title: localize("ui.panel.config.hardware.system_hardware.device_path"),
sortable: true,
filterable: true,
flex: 2,
},
by_id: {
title: localize("ui.panel.config.hardware.system_hardware.id"),
sortable: true,
filterable: true,
flex: 2,
},
subsystem: {
title: localize("ui.panel.config.hardware.system_hardware.subsystem"),
sortable: true,
filterable: true,
flex: 1,
},
attributes_string: {
title: "",
filterable: true,
hidden: true,
},
})
);
private _data = memoizeOne(
(showAdvanced: boolean, hardware: HassioHardwareInfo): DataTableRowData[] =>
hardware.devices
.filter(
(device) =>
showAdvanced || ["tty", "gpio", "input"].includes(device.subsystem)
)
.map((device) => ({
...device,
id: device.dev_path,
attributes_string: Object.entries(device.attributes)
.map(([key, value]) => `${key}: ${value}`)
.join(" "),
}))
);
protected firstUpdated(): void {
this._load();
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
.route=${this.route}
.tabs=${hardwareTabs(this.hass)}
clickable
.columns=${this._columns(this.hass.localize)}
.data=${this._hardware
? this._data(
this.hass.userData?.showAdvanced || false,
this._hardware
)
: []}
.noDataText=${this._error ||
this.hass.localize("ui.panel.config.hardware.loading_system_data")}
@row-click=${this._handleRowClicked}
></hass-tabs-subpage-data-table>
`;
}
private async _load() {
try {
this._hardware = await fetchHassioHardwareInfo(this.hass);
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private _handleRowClicked(ev: CustomEvent<RowClickedEvent>) {
const id = ev.detail.id;
const device = this._hardware?.devices.find((dev) => dev.dev_path === id);
if (!device) {
return;
}
showAlertDialog(this, {
title: device.name,
subtitle: this.hass.localize(
"ui.panel.config.hardware.system_hardware.attributes"
),
text: html`${until(this._renderHaCodeEditor(device))}`,
});
}
private async _renderHaCodeEditor(device: HardwareDevice) {
await import("../../../components/ha-code-editor");
return html`<ha-code-editor
mode="yaml"
.hass=${this.hass}
.value=${dump(device.attributes, { indent: 2 })}
read-only
></ha-code-editor>`;
}
static styles: CSSResultGroup = css`
ha-code-editor {
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-hardware-all": HaConfigHardwareAll;
}
}

View File

@@ -0,0 +1,571 @@
import { mdiPower } from "@mdi/js";
import type { SeriesOption } from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-fade-in";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import type { ConfigEntry } from "../../../data/config_entries";
import { subscribeConfigEntries } from "../../../data/config_entries";
import type {
HardwareInfo,
SystemStatusStreamMessage,
} from "../../../data/hardware";
import { BOARD_NAMES } from "../../../data/hardware";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHassOSInfo } from "../../../data/hassio/host";
import { fetchHassioHassOsInfo } from "../../../data/hassio/host";
import { scanUSBDevices } from "../../../data/usb";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { ECOption } from "../../../resources/echarts/echarts";
import { haStyle } from "../../../resources/styles";
import { DefaultPrimaryColor } from "../../../resources/theme/color/color.globals";
import type { HomeAssistant, Route } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { hardwareTabs } from "./ha-config-hardware";
const DATASAMPLES = 60;
const DATA_SET_CONFIG: SeriesOption = {
type: "line",
color: DefaultPrimaryColor,
areaStyle: {
color: DefaultPrimaryColor + "2B",
},
symbolSize: 0,
lineStyle: {
width: 1,
},
smooth: 0.25,
};
@customElement("ha-config-hardware-overview")
class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _error?: string;
@state() private _OSData?: HassioHassOSInfo;
@state() private _hardwareInfo?: HardwareInfo;
@state() private _chartOptions?: ECOption;
@state() private _systemStatusData?: SystemStatusStreamMessage;
@state() private _configEntries?: Record<string, ConfigEntry>;
private _memoryEntries: [number, number | null][] = [];
private _cpuEntries: [number, number | null][] = [];
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
const subs = [
subscribeConfigEntries(
this.hass,
(messages) => {
let fullUpdate = false;
const newEntries: ConfigEntry[] = [];
messages.forEach((message) => {
if (message.type === null || message.type === "added") {
newEntries.push(message.entry);
if (message.type === null) {
fullUpdate = true;
}
} else if (message.type === "removed") {
if (this._configEntries) {
delete this._configEntries[message.entry.entry_id];
}
} else if (message.type === "updated") {
if (this._configEntries) {
const newEntry = message.entry;
this._configEntries[message.entry.entry_id] = newEntry;
}
}
});
if (!newEntries.length && !fullUpdate) {
return;
}
const entries = [
...(fullUpdate ? [] : Object.values(this._configEntries || {})),
...newEntries,
];
const configEntries: Record<string, ConfigEntry> = {};
for (const entry of entries) {
configEntries[entry.entry_id] = entry;
}
this._configEntries = configEntries;
},
{ type: ["hardware"] }
),
];
if (isComponentLoaded(this.hass, "hardware")) {
subs.push(
this.hass.connection.subscribeMessage<SystemStatusStreamMessage>(
(message) => {
// Only store the last 60 entries
this._memoryEntries.shift();
this._cpuEntries.shift();
this._memoryEntries.push([
new Date(message.timestamp).getTime(),
message.memory_used_percent,
]);
this._cpuEntries.push([
new Date(message.timestamp).getTime(),
message.cpu_percent,
]);
this._systemStatusData = message;
},
{
type: "hardware/subscribe_system_status",
}
)
);
}
return subs;
}
protected willUpdate(): void {
if (!this.hasUpdated && !this._chartOptions) {
this._chartOptions = {
xAxis: {
type: "time",
},
yAxis: {
type: "value",
min: 0,
max: 100,
splitLine: {
show: true,
},
axisLabel: {
formatter: (value: number) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
axisLine: {
show: false,
},
scale: true,
},
grid: {
top: 10,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
valueFormatter: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
};
}
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._load();
const date = new Date();
// Force graph to start drawing from the right
for (let i = 0; i < DATASAMPLES; i++) {
const t = new Date(date);
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
this._memoryEntries.push([t.getTime(), null]);
this._cpuEntries.push([t.getTime(), null]);
}
}
protected render() {
let boardId: string | undefined;
let boardName: string | undefined;
let imageURL: string | undefined;
let documentationURL: string | undefined;
let boardConfigEntries: ConfigEntry[] = [];
const boardData = this._hardwareInfo?.hardware.find(
(hw) => hw.board !== null
);
const dongles = this._hardwareInfo?.hardware.filter(
(hw) =>
hw.dongle !== null &&
(!hw.config_entries.length ||
hw.config_entries.some(
(entryId) =>
this._configEntries?.[entryId] &&
!this._configEntries[entryId].disabled_by
))
);
if (boardData) {
boardConfigEntries = boardData.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
imageURL = hardwareBrandsUrl(
{
category: "boards",
manufacturer: boardData.board!.manufacturer,
model: boardData.board!.model,
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
} else if (this._OSData?.board) {
boardId = this._OSData.board;
boardName = BOARD_NAMES[this._OSData.board];
}
return html`
<hass-tabs-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${hardwareTabs(this.hass)}
>
${isComponentLoaded(this.hass, "hassio")
? html`
<ha-icon-button
slot="toolbar-icon"
.path=${mdiPower}
.label=${this.hass.localize(
"ui.panel.config.hardware.restart_homeassistant"
)}
@click=${this._showRestartDialog}
></ha-icon-button>
`
: nothing}
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="content">
${boardName || isComponentLoaded(this.hass, "hassio")
? html`
<ha-card outlined>
<div class="card-content">
${imageURL
? html`<img
alt=""
src=${imageURL}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: nothing}
<div class="board-info">
<p class="primary-text">
${boardName ||
this.hass.localize(
"ui.panel.config.hardware.generic_hardware"
)}
</p>
${boardId
? html`<p class="secondary-text">${boardId}</p>`
: nothing}
</div>
</div>
${documentationURL
? html`
<ha-md-list-item
.href=${documentationURL}
type="link"
target="_blank"
rel="noopener noreferrer"
>
<span
>${this.hass.localize(
"ui.panel.config.hardware.documentation"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.hardware.documentation_description"
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
: nothing}
${boardConfigEntries.length
? html`<div class="card-actions">
<ha-button
.entry=${boardConfigEntries[0]}
@click=${this._openOptionsFlow}
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`
: nothing}
${dongles?.length
? html`<ha-card outlined>
${dongles.map((dongle) => {
const configEntry = dongle.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
)[0];
return html`<div class="row">
${dongle.name}${configEntry
? html`<ha-button
.entry=${configEntry}
@click=${this._openOptionsFlow}
appearance="filled"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>`
: nothing}
</div>`;
})}
</ha-card>`
: nothing}
${isComponentLoaded(this.hass, "hardware")
? html`<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize(
"ui.panel.config.hardware.processor"
)}
</div>
<div class="value">
${this._systemStatusData
? html`${this._systemStatusData
.cpu_percent}${blankBeforePercent(
this.hass.locale
)}%`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._cpuEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html` <ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>`
: nothing}
</div>
</ha-card>
<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize("ui.panel.config.hardware.memory")}
</div>
<div class="value">
${this._systemStatusData
? html`${round(
this._systemStatusData.memory_used_mb / 1024,
1
)}
GB /
${round(
(this._systemStatusData.memory_used_mb +
this._systemStatusData.memory_free_mb) /
1024,
0
)}
GB`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._memoryEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html`
<ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>
`
: nothing}
</div>
</ha-card>`
: nothing}
</div>
</hass-tabs-subpage>
`;
}
private async _load() {
if (isComponentLoaded(this.hass, "usb")) {
await scanUSBDevices(this.hass);
}
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
try {
if (isComponentLoaded(this.hass, "hardware")) {
this._hardwareInfo = await this.hass.callWS({ type: "hardware/info" });
}
if (isHassioLoaded && !this._hardwareInfo?.hardware.length) {
this._OSData = await fetchHassioHassOsInfo(this.hass);
}
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private async _openOptionsFlow(ev) {
const entry = ev.currentTarget.entry;
if (!entry) {
return;
}
showOptionsFlowDialog(this, entry);
}
private async _showRestartDialog() {
showRestartDialog(this);
}
private _getChartData = memoizeOne(
(entries: [number, number | null][]): SeriesOption[] => [
{
...DATA_SET_CONFIG,
id: entries === this._cpuEntries ? "cpu" : "memory",
name:
entries === this._cpuEntries
? this.hass.localize("ui.panel.config.hardware.processor")
: this.hass.localize("ui.panel.config.hardware.memory"),
data: entries,
} as SeriesOption,
]
);
static styles = [
haStyle,
css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
--mdc-list-side-padding: 24px;
--mdc-list-vertical-padding: 0;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
margin-bottom: 16px;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px;
}
.loading-container {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(var(--rgb-card-background-color), 0.75);
display: flex;
justify-content: center;
align-items: center;
}
.card-content img {
max-width: 300px;
margin: auto;
}
.board-info {
text-align: center;
}
.primary-text {
font-size: var(--ha-font-size-l);
margin: 0;
}
.secondary-text {
font-size: var(--ha-font-size-m);
margin-bottom: 0;
color: var(--secondary-text-color);
}
.header {
padding: 16px;
display: flex;
justify-content: space-between;
}
.header .title {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-l);
}
.header .value {
font-size: var(--ha-font-size-l);
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 8px 16px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
ha-alert {
--ha-alert-icon-size: 24px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-hardware-overview": HaConfigHardwareOverview;
}
}

View File

@@ -1,585 +1,66 @@
import { mdiPower } from "@mdi/js";
import type { SeriesOption } from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mdiFormatListBulletedType, mdiMemory } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-fade-in";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import type { ConfigEntry } from "../../../data/config_entries";
import { subscribeConfigEntries } from "../../../data/config_entries";
import type {
HardwareInfo,
SystemStatusStreamMessage,
} from "../../../data/hardware";
import { BOARD_NAMES } from "../../../data/hardware";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHassOSInfo } from "../../../data/hassio/host";
import { fetchHassioHassOsInfo } from "../../../data/hassio/host";
import { scanUSBDevices } from "../../../data/usb";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { ECOption } from "../../../resources/echarts/echarts";
import { haStyle } from "../../../resources/styles";
import { DefaultPrimaryColor } from "../../../resources/theme/color/color.globals";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
const DATASAMPLES = 60;
export const hardwareTabs = (hass: HomeAssistant): PageNavigation[] => {
const tabs: PageNavigation[] = [
{
path: "/config/hardware/overview",
translationKey: "ui.panel.config.hardware.overview",
iconPath: mdiMemory,
},
];
const DATA_SET_CONFIG: SeriesOption = {
type: "line",
color: DefaultPrimaryColor,
areaStyle: {
color: DefaultPrimaryColor + "2B",
},
symbolSize: 0,
lineStyle: {
width: 1,
},
smooth: 0.25,
if (isComponentLoaded(hass, "hassio")) {
tabs.push({
path: "/config/hardware/all",
translationKey: "ui.panel.config.hardware.system_hardware.title",
iconPath: mdiFormatListBulletedType,
});
}
return tabs;
};
@customElement("ha-config-hardware")
class HaConfigHardware extends SubscribeMixin(LitElement) {
class HaConfigHardware extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _error?: string;
protected routerOptions: RouterOptions = {
defaultPage: "overview",
routes: {
overview: {
tag: "ha-config-hardware-overview",
load: () => import("./ha-config-hardware-overview"),
cache: true,
},
all: {
tag: "ha-config-hardware-all",
load: () => import("./ha-config-hardware-all"),
},
},
beforeRender: (page) => {
if (
page === "all" &&
(!this.hass || !isComponentLoaded(this.hass, "hassio"))
) {
return "overview";
}
return undefined;
},
};
@state() private _OSData?: HassioHassOSInfo;
@state() private _hardwareInfo?: HardwareInfo;
@state() private _chartOptions?: ECOption;
@state() private _systemStatusData?: SystemStatusStreamMessage;
@state() private _configEntries?: Record<string, ConfigEntry>;
private _memoryEntries: [number, number | null][] = [];
private _cpuEntries: [number, number | null][] = [];
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
const subs = [
subscribeConfigEntries(
this.hass,
(messages) => {
let fullUpdate = false;
const newEntries: ConfigEntry[] = [];
messages.forEach((message) => {
if (message.type === null || message.type === "added") {
newEntries.push(message.entry);
if (message.type === null) {
fullUpdate = true;
}
} else if (message.type === "removed") {
if (this._configEntries) {
delete this._configEntries[message.entry.entry_id];
}
} else if (message.type === "updated") {
if (this._configEntries) {
const newEntry = message.entry;
this._configEntries[message.entry.entry_id] = newEntry;
}
}
});
if (!newEntries.length && !fullUpdate) {
return;
}
const entries = [
...(fullUpdate ? [] : Object.values(this._configEntries || {})),
...newEntries,
];
const configEntries: Record<string, ConfigEntry> = {};
for (const entry of entries) {
configEntries[entry.entry_id] = entry;
}
this._configEntries = configEntries;
},
{ type: ["hardware"] }
),
];
if (isComponentLoaded(this.hass, "hardware")) {
subs.push(
this.hass.connection.subscribeMessage<SystemStatusStreamMessage>(
(message) => {
// Only store the last 60 entries
this._memoryEntries.shift();
this._cpuEntries.shift();
this._memoryEntries.push([
new Date(message.timestamp).getTime(),
message.memory_used_percent,
]);
this._cpuEntries.push([
new Date(message.timestamp).getTime(),
message.cpu_percent,
]);
this._systemStatusData = message;
},
{
type: "hardware/subscribe_system_status",
}
)
);
}
return subs;
protected updatePageEl(pageEl) {
pageEl.hass = this.hass;
pageEl.narrow = this.narrow;
pageEl.route = this.routeTail;
}
protected willUpdate(): void {
if (!this.hasUpdated && !this._chartOptions) {
this._chartOptions = {
xAxis: {
type: "time",
},
yAxis: {
type: "value",
min: 0,
max: 100,
splitLine: {
show: true,
},
axisLabel: {
formatter: (value: number) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
axisLine: {
show: false,
},
scale: true,
},
grid: {
top: 10,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
valueFormatter: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
};
}
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._load();
const date = new Date();
// Force graph to start drawing from the right
for (let i = 0; i < DATASAMPLES; i++) {
const t = new Date(date);
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
this._memoryEntries.push([t.getTime(), null]);
this._cpuEntries.push([t.getTime(), null]);
}
}
protected render() {
let boardId: string | undefined;
let boardName: string | undefined;
let imageURL: string | undefined;
let documentationURL: string | undefined;
let boardConfigEntries: ConfigEntry[] = [];
const boardData = this._hardwareInfo?.hardware.find(
(hw) => hw.board !== null
);
const dongles = this._hardwareInfo?.hardware.filter(
(hw) =>
hw.dongle !== null &&
(!hw.config_entries.length ||
hw.config_entries.some(
(entryId) =>
this._configEntries?.[entryId] &&
!this._configEntries[entryId].disabled_by
))
);
if (boardData) {
boardConfigEntries = boardData.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
imageURL = hardwareBrandsUrl(
{
category: "boards",
manufacturer: boardData.board!.manufacturer,
model: boardData.board!.model,
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
} else if (this._OSData?.board) {
boardId = this._OSData.board;
boardName = BOARD_NAMES[this._OSData.board];
}
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.hardware.caption")}
>
${isComponentLoaded(this.hass, "hassio")
? html`
<ha-icon-button
slot="toolbar-icon"
.path=${mdiPower}
.label=${this.hass.localize(
"ui.panel.config.hardware.restart_homeassistant"
)}
@click=${this._showRestartDialog}
></ha-icon-button>
`
: nothing}
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="content">
${boardName || isComponentLoaded(this.hass, "hassio")
? html`
<ha-card outlined>
<div class="card-content">
${imageURL
? html`<img
alt=""
src=${imageURL}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: nothing}
<div class="board-info">
<p class="primary-text">
${boardName ||
this.hass.localize(
"ui.panel.config.hardware.generic_hardware"
)}
</p>
${boardId
? html`<p class="secondary-text">${boardId}</p>`
: nothing}
</div>
</div>
${documentationURL
? html`
<ha-md-list-item
.href=${documentationURL}
type="link"
target="_blank"
rel="noopener noreferrer"
>
<span
>${this.hass.localize(
"ui.panel.config.hardware.documentation"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.hardware.documentation_description"
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`
: nothing}
${boardConfigEntries.length ||
isComponentLoaded(this.hass, "hassio")
? html`<div class="card-actions">
${boardConfigEntries.length
? html`
<ha-button
.entry=${boardConfigEntries[0]}
@click=${this._openOptionsFlow}
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>
`
: nothing}
${isComponentLoaded(this.hass, "hassio")
? html`
<ha-button
@click=${this._openHardware}
appearance="plain"
>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.title"
)}
</ha-button>
`
: nothing}
</div>`
: nothing}
</ha-card>
`
: nothing}
${dongles?.length
? html`<ha-card outlined>
${dongles.map((dongle) => {
const configEntry = dongle.config_entries
.map((id) => this._configEntries?.[id])
.filter(
(entry) => entry?.supports_options && !entry.disabled_by
)[0];
return html`<div class="row">
${dongle.name}${configEntry
? html`<ha-button
.entry=${configEntry}
@click=${this._openOptionsFlow}
appearance="filled"
>
${this.hass.localize(
"ui.panel.config.hardware.configure"
)}
</ha-button>`
: nothing}
</div>`;
})}
</ha-card>`
: nothing}
${isComponentLoaded(this.hass, "hardware")
? html`<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize(
"ui.panel.config.hardware.processor"
)}
</div>
<div class="value">
${this._systemStatusData
? html`${this._systemStatusData
.cpu_percent}${blankBeforePercent(
this.hass.locale
)}%`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._cpuEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html` <ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>`
: nothing}
</div>
</ha-card>
<ha-card outlined>
<div class="header">
<div class="title">
${this.hass.localize("ui.panel.config.hardware.memory")}
</div>
<div class="value">
${this._systemStatusData
? html`${round(
this._systemStatusData.memory_used_mb / 1024,
1
)}
GB /
${round(
(this._systemStatusData.memory_used_mb +
this._systemStatusData.memory_free_mb) /
1024,
0
)}
GB`
: "-"}
</div>
</div>
<div class="card-content loading-container">
<ha-chart-base
.hass=${this.hass}
.data=${this._getChartData(this._memoryEntries)}
.options=${this._chartOptions}
></ha-chart-base>
${!this._systemStatusData
? html`
<ha-fade-in delay="1000" class="loading-overlay">
<ha-spinner size="large"></ha-spinner>
</ha-fade-in>
`
: nothing}
</div>
</ha-card>`
: nothing}
</div>
</hass-subpage>
`;
}
private async _load() {
if (isComponentLoaded(this.hass, "usb")) {
await scanUSBDevices(this.hass);
}
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
try {
if (isComponentLoaded(this.hass, "hardware")) {
this._hardwareInfo = await this.hass.callWS({ type: "hardware/info" });
}
if (isHassioLoaded && !this._hardwareInfo?.hardware.length) {
this._OSData = await fetchHassioHassOsInfo(this.hass);
}
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
private async _openOptionsFlow(ev) {
const entry = ev.currentTarget.entry;
if (!entry) {
return;
}
showOptionsFlowDialog(this, entry);
}
private async _openHardware() {
showhardwareAvailableDialog(this);
}
private async _showRestartDialog() {
showRestartDialog(this);
}
private _getChartData = memoizeOne(
(entries: [number, number | null][]): SeriesOption[] => [
{
...DATA_SET_CONFIG,
id: entries === this._cpuEntries ? "cpu" : "memory",
name:
entries === this._cpuEntries
? this.hass.localize("ui.panel.config.hardware.processor")
: this.hass.localize("ui.panel.config.hardware.memory"),
data: entries,
} as SeriesOption,
]
);
static styles = [
haStyle,
css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
--mdc-list-side-padding: 24px;
--mdc-list-vertical-padding: 0;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
margin-bottom: 16px;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px;
}
.loading-container {
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(var(--rgb-card-background-color), 0.75);
display: flex;
justify-content: center;
align-items: center;
}
.card-content img {
max-width: 300px;
margin: auto;
}
.board-info {
text-align: center;
}
.primary-text {
font-size: var(--ha-font-size-l);
margin: 0;
}
.secondary-text {
font-size: var(--ha-font-size-m);
margin-bottom: 0;
color: var(--secondary-text-color);
}
.header {
padding: 16px;
display: flex;
justify-content: space-between;
}
.header .title {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-l);
}
.header .value {
font-size: var(--ha-font-size-l);
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 8px 16px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
ha-alert {
--ha-alert-icon-size: 24px;
}
`,
];
}
declare global {

View File

@@ -1,12 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
export const loadHardwareAvailableDialog = () =>
import("./dialog-hardware-available");
export const showhardwareAvailableDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-hardware-available",
dialogImport: loadHardwareAvailableDialog,
dialogParams: {},
});
};

View File

@@ -4,11 +4,11 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../components/ha-select";
import "../../../components/ha-spinner";
import "../../../components/ha-dialog";
import {
extractApiErrorMessage,
ignoreSupervisorError,
@@ -79,7 +79,7 @@ class MoveDatadiskDialog extends LitElement {
this.closeDialog();
await showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.hardware.available_hardware.failed_to_get"
"ui.panel.config.hardware.system_hardware.failed_to_get"
),
text: extractApiErrorMessage(err),
});

View File

@@ -1489,7 +1489,7 @@
"network": "[%key:ui::panel::config::network::caption%]",
"updates": "[%key:ui::panel::config::updates::caption%]",
"repairs": "[%key:ui::panel::config::repairs::caption%]",
"hardware": "[%key:ui::panel::config::hardware::caption%]",
"hardware": "Hardware",
"storage": "[%key:ui::panel::config::storage::caption%]",
"general": "[%key:ui::panel::config::core::caption%]",
"backups": "[%key:ui::panel::config::backup::caption%]",
@@ -4222,12 +4222,13 @@
"invalid_url": "Invalid URL"
},
"hardware": {
"caption": "Hardware",
"overview": "Overview",
"description": "Configure your hub and connected hardware",
"available_hardware": {
"system_hardware": {
"failed_to_get": "Failed to get available hardware",
"title": "All hardware",
"title": "System hardware",
"search": "Search hardware",
"name": "Name",
"subsystem": "Subsystem",
"device_path": "Device path",
"id": "ID",