mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-27 07:21:55 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1ac2161f | ||
|
|
182563d943 | ||
|
|
e5f1cb0b1f | ||
|
|
b31da04913 | ||
|
|
d35c398bfd | ||
|
|
71f9285e70 |
@@ -6,6 +6,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
narrowViewportContext,
|
||||
uiContext,
|
||||
@@ -32,6 +33,10 @@ class HaMenuButton extends LitElement {
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui?: ContextType<typeof uiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _hasNotifications = false;
|
||||
|
||||
@state() private _show = false;
|
||||
@@ -82,7 +87,8 @@ class HaMenuButton extends LitElement {
|
||||
if (
|
||||
!changedProps.has("_narrow") &&
|
||||
!changedProps.has("_ui") &&
|
||||
!changedProps.has("_connection")
|
||||
!changedProps.has("_connection") &&
|
||||
!changedProps.has("_config")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +100,7 @@ class HaMenuButton extends LitElement {
|
||||
|
||||
const showButton =
|
||||
this._ui?.kioskMode === false &&
|
||||
this._config?.auth.external?.config.hasSidebar !== true &&
|
||||
(this._narrow || this._ui.dockedSidebar === "always_hidden");
|
||||
|
||||
this._show = showButton || this._alwaysVisible;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { showAutomationEditor } from "../data/automation";
|
||||
import type { HomeAssistantMain } from "../layouts/home-assistant-main";
|
||||
import { handleNativeBackButtonPressed } from "./external_back_button";
|
||||
import type {
|
||||
EMIncomingMessageBarCodeScanAborted,
|
||||
EMIncomingMessageBarCodeScanResult,
|
||||
@@ -100,16 +99,6 @@ export const handleExternalMessage = (
|
||||
barCodeListeners.forEach((listener) => listener(msg));
|
||||
} else if (msg.command === "kiosk_mode/set") {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
|
||||
} else if (msg.command === "back_button/pressed") {
|
||||
if (!handleNativeBackButtonPressed()) {
|
||||
bus.fireMessage({
|
||||
id: msg.id,
|
||||
type: "result",
|
||||
success: false,
|
||||
error: { code: "not_allowed", message: "no back button shown" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { ExternalMessaging } from "./external_messaging";
|
||||
|
||||
/*
|
||||
Apps that draw their own toolbar tell us so with `hasNativeBackButton`. We then
|
||||
hide our own back arrow and instead report whether the current top bar offers a
|
||||
back action, so the app can show or hide its native button. Tapping that button
|
||||
sends `back_button/pressed` back to us, so the navigation stays ours.
|
||||
*/
|
||||
|
||||
interface Registration {
|
||||
bus: ExternalMessaging;
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
// Top bars that currently offer a back action. The last one to register owns
|
||||
// the app's back button, so a page mounted on top of another one wins.
|
||||
const registrations: Registration[] = [];
|
||||
|
||||
// The bus we last told to show the button, so we only report changes.
|
||||
let shownOn: ExternalMessaging | undefined;
|
||||
|
||||
const sync = (): void => {
|
||||
const active = registrations[registrations.length - 1];
|
||||
|
||||
if (active) {
|
||||
if (shownOn !== active.bus) {
|
||||
active.bus.fireMessage({ type: "back_button/show" });
|
||||
shownOn = active.bus;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (shownOn) {
|
||||
shownOn.fireMessage({ type: "back_button/hide" });
|
||||
shownOn = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the back action of the top bar that currently owns the app's back
|
||||
* button. Returns false when no top bar claims one, so the app can be told
|
||||
* that its button was out of date.
|
||||
*/
|
||||
export const handleNativeBackButtonPressed = (): boolean => {
|
||||
const active = registrations[registrations.length - 1];
|
||||
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
active.back();
|
||||
return true;
|
||||
};
|
||||
|
||||
interface NativeBackButtonHost extends ReactiveControllerHost {
|
||||
hass?: HomeAssistant;
|
||||
}
|
||||
|
||||
interface NativeBackButtonOptions {
|
||||
/** Whether the top bar wants to offer a back action right now. */
|
||||
visible: () => boolean;
|
||||
/** Navigates back. Called for both our own arrow and the app's button. */
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the back button of a top bar over to the external app when it renders
|
||||
* one itself. Hosts must not render their own arrow while `native` is true.
|
||||
*/
|
||||
export class NativeBackButtonController implements ReactiveController {
|
||||
private _registration?: Registration;
|
||||
|
||||
constructor(
|
||||
private _host: NativeBackButtonHost,
|
||||
private _options: NativeBackButtonOptions
|
||||
) {
|
||||
_host.addController(this);
|
||||
}
|
||||
|
||||
/** True while the app renders the back button instead of us. */
|
||||
public get native(): boolean {
|
||||
return this._bus !== undefined;
|
||||
}
|
||||
|
||||
private get _bus(): ExternalMessaging | undefined {
|
||||
// Demo and gallery hosts get by with a partial hass, so tread carefully.
|
||||
const external = this._host.hass?.auth?.external;
|
||||
return external?.config.hasNativeBackButton ? external : undefined;
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._unregister();
|
||||
}
|
||||
|
||||
private _sync(): void {
|
||||
const bus = this._bus;
|
||||
|
||||
if (!bus || !this._options.visible()) {
|
||||
this._unregister();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._registration) {
|
||||
this._registration.bus = bus;
|
||||
this._registration.back = this._options.back;
|
||||
} else {
|
||||
this._registration = { bus, back: this._options.back };
|
||||
registrations.push(this._registration);
|
||||
}
|
||||
|
||||
sync();
|
||||
}
|
||||
|
||||
private _unregister(): void {
|
||||
if (!this._registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = registrations.indexOf(this._registration);
|
||||
if (index !== -1) {
|
||||
registrations.splice(index, 1);
|
||||
}
|
||||
this._registration = undefined;
|
||||
|
||||
sync();
|
||||
}
|
||||
}
|
||||
@@ -218,14 +218,6 @@ interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
|
||||
type: "frontend/reload_and_clear_cache";
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageBackButtonShow extends EMMessage {
|
||||
type: "back_button/show"; // The top bar offers a back action; only sent with hasNativeBackButton
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageBackButtonHide extends EMMessage {
|
||||
type: "back_button/hide";
|
||||
}
|
||||
|
||||
// These types are handled internally by the Android app via postMessage.
|
||||
// They are not sent by the frontend and should not be used directly.
|
||||
// They are intentionally listed here to prevent anyone from using them unintentionally.
|
||||
@@ -236,8 +228,6 @@ type EMOutgoingMessageWithoutAnswer =
|
||||
| EMMessageResultSuccess
|
||||
| EMOutgoingMessageAppConfiguration
|
||||
| EMOutgoingMessageAssistShow
|
||||
| EMOutgoingMessageBackButtonHide
|
||||
| EMOutgoingMessageBackButtonShow
|
||||
| EMOutgoingMessageBarCodeClose
|
||||
| EMOutgoingMessageBarCodeNotify
|
||||
| EMOutgoingMessageBarCodeScan
|
||||
@@ -357,12 +347,6 @@ export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
|
||||
command: "improv/device_setup_done";
|
||||
}
|
||||
|
||||
export interface EMIncomingMessageBackButtonPressed {
|
||||
id: number;
|
||||
type: "command";
|
||||
command: "back_button/pressed";
|
||||
}
|
||||
|
||||
export interface EMIncomingMessageKioskModeSet {
|
||||
id: number;
|
||||
type: "command";
|
||||
@@ -385,7 +369,6 @@ export interface EMIncomingMessageMatterCommissionFinish extends EMMessage {
|
||||
}
|
||||
|
||||
export type EMIncomingMessageCommands =
|
||||
| EMIncomingMessageBackButtonPressed
|
||||
| EMIncomingMessageRestart
|
||||
| EMIncomingMessageNavigate
|
||||
| EMIncomingMessageShowNotifications
|
||||
@@ -420,7 +403,6 @@ export interface ExternalConfig {
|
||||
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
|
||||
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
|
||||
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
|
||||
hasNativeBackButton?: boolean; // App draws the back button of the top bar itself, driven by back_button/show and back_button/hide
|
||||
}
|
||||
|
||||
export interface ExternalEntityAddToAction {
|
||||
|
||||
@@ -2,22 +2,6 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
|
||||
/**
|
||||
* Navigate back the way the toolbar back arrow does, without a click to go by.
|
||||
* Used by the external app when it renders the back button itself.
|
||||
*/
|
||||
export const navigateBack = (
|
||||
backPath?: string,
|
||||
backCallback?: () => void
|
||||
): void => {
|
||||
if (backCallback) {
|
||||
backCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
goBack(sanitizeNavigationPath(backPath));
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared behavior of the toolbar back arrow. The arrow is a link to the
|
||||
* declared parent page so it can be opened in a new tab, but a plain click
|
||||
@@ -28,12 +12,19 @@ export const handleBackClick = (
|
||||
backPath?: string,
|
||||
backCallback?: () => void
|
||||
): void => {
|
||||
const path = sanitizeNavigationPath(backPath);
|
||||
|
||||
// Ctrl, cmd and shift click open the parent in a new tab or window: let
|
||||
// the anchor handle those. A plain click is handled here instead, and
|
||||
// isNavigationClick calls preventDefault so the anchor stays inert.
|
||||
if (sanitizeNavigationPath(backPath) && !isNavigationClick(ev)) {
|
||||
if (path && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateBack(backPath, backCallback);
|
||||
if (backCallback) {
|
||||
backCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
goBack(path);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { NativeBackButtonController } from "../external_app/external_back_button";
|
||||
import { handleBackClick, navigateBack } from "./back-navigation";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -32,18 +31,6 @@ class HassSubpage extends LitElement {
|
||||
// @ts-ignore
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
private _nativeBackButton = new NativeBackButtonController(this, {
|
||||
visible: () => this._showsBackButton,
|
||||
back: () => navigateBack(this.backPath, this.backCallback),
|
||||
});
|
||||
|
||||
private get _showsBackButton(): boolean {
|
||||
return (
|
||||
!this.mainPage &&
|
||||
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
|
||||
);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const backPath = sanitizeNavigationPath(this.backPath);
|
||||
|
||||
@@ -51,16 +38,14 @@ class HassSubpage extends LitElement {
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
!this._showsBackButton
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this._nativeBackButton.native
|
||||
? nothing
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="main-title">
|
||||
|
||||
@@ -17,8 +17,7 @@ import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { getHistoryState, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { NativeBackButtonController } from "../external_app/external_back_button";
|
||||
import { handleBackClick, navigateBack } from "./back-navigation";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -95,18 +94,6 @@ export class HassTabsSubpage extends LitElement {
|
||||
// @ts-ignore
|
||||
@restoreScroll(".content") private _savedScrollPos?: number;
|
||||
|
||||
private _nativeBackButton = new NativeBackButtonController(this, {
|
||||
visible: () => this._showsBackButton,
|
||||
back: () => navigateBack(this.backPath, this.backCallback),
|
||||
});
|
||||
|
||||
private get _showsBackButton(): boolean {
|
||||
return (
|
||||
!this.mainPage &&
|
||||
!(!sanitizeNavigationPath(this.backPath) && getHistoryState()?.root)
|
||||
);
|
||||
}
|
||||
|
||||
private _getTabs = memoizeOne(
|
||||
(
|
||||
tabs: PageNavigation[],
|
||||
@@ -187,16 +174,14 @@ export class HassTabsSubpage extends LitElement {
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
!this._showsBackButton
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: this._nativeBackButton.native
|
||||
? nothing
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
${
|
||||
this._narrow || !this.showTabs
|
||||
|
||||
@@ -168,10 +168,6 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: var(--secondary-text-color);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
|
||||
@@ -178,6 +178,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
<span slot="headerTitle">${title}</span>
|
||||
<ha-icon-button
|
||||
id="copy"
|
||||
autofocus
|
||||
@click=${this._copyLog}
|
||||
slot="headerActionItems"
|
||||
.label=${this._i18n.localize("ui.panel.config.logs.copy")}
|
||||
@@ -237,7 +238,7 @@ class DialogSystemLogDetail extends LitElement {
|
||||
}
|
||||
</ha-alert>`
|
||||
}
|
||||
<div class="contents" tabindex="-1" autofocus>
|
||||
<div class="contents">
|
||||
<p>
|
||||
${this._i18n.localize("ui.panel.config.logs.detail.logger")}:
|
||||
${item.name}<br />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { round } from "../../../../common/number/round";
|
||||
|
||||
/**
|
||||
* In periods where part of the sources' energy did not reach the home (grid
|
||||
* charging a battery, a battery exporting), per-source attribution is
|
||||
* ambiguous if multiple sources have data in the same period. Values that
|
||||
* round to 0 Wh are float noise from subtracting sums.
|
||||
* Rewrites single-source periods in place on `bySource`, and returns a
|
||||
* combined used map for multi-source periods. Returns undefined when no
|
||||
* combined series is needed so the chart does not add an empty legend item.
|
||||
*/
|
||||
export function buildCombinedUsed(
|
||||
bySource: Record<string, Record<number, number>>,
|
||||
notUsed: Record<number, number>,
|
||||
used: Record<number, number>
|
||||
): Record<number, number> | undefined {
|
||||
const combined: Record<number, number> = {};
|
||||
for (const [start, notUsedInPeriod] of Object.entries(notUsed)) {
|
||||
if (!round(notUsedInPeriod, 3)) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string | undefined;
|
||||
for (const [key, stats] of Object.entries(bySource)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1 && source) {
|
||||
bySource[source][start] = used[start];
|
||||
} else {
|
||||
Object.values(bySource).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
if (round(used[start], 3)) {
|
||||
combined[start] = used[start];
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(combined).length > 0 ? combined : undefined;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* When battery is charging from grid, per-source import attribution is
|
||||
* ambiguous if multiple grid sources have data in the same period.
|
||||
* Rewrites single-source periods in place on `fromGridBySource`, and returns
|
||||
* a combined used-grid map for multi-source periods. Returns undefined when
|
||||
* no combined series is needed so the chart does not add an empty legend item.
|
||||
*/
|
||||
export function buildCombinedUsedGrid(
|
||||
fromGridBySource: Record<string, Record<number, number>>,
|
||||
gridToBattery: Record<number, number>,
|
||||
usedGrid: Record<number, number>
|
||||
): Record<number, number> | undefined {
|
||||
const used_grid: Record<number, number> = {};
|
||||
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
|
||||
if (!grid_to_battery) {
|
||||
continue;
|
||||
}
|
||||
let noOfSources = 0;
|
||||
let source: string | undefined;
|
||||
for (const [key, stats] of Object.entries(fromGridBySource)) {
|
||||
if (stats[start]) {
|
||||
source = key;
|
||||
noOfSources++;
|
||||
}
|
||||
if (noOfSources > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOfSources === 1 && source) {
|
||||
fromGridBySource[source][start] = usedGrid[start];
|
||||
} else {
|
||||
Object.values(fromGridBySource).forEach((stats) => {
|
||||
delete stats[start];
|
||||
});
|
||||
used_grid[start] = usedGrid[start];
|
||||
}
|
||||
}
|
||||
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "./common/energy-chart-options";
|
||||
import type { HaECOption } from "../../../../resources/echarts/echarts";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
|
||||
import { buildCombinedUsed } from "./energy-usage-graph-combined-used";
|
||||
|
||||
const colorPropertyMap = {
|
||||
to_grid: "--energy-grid-return-color",
|
||||
@@ -52,6 +52,7 @@ const colorPropertyMap = {
|
||||
from_grid: "--energy-grid-consumption-color",
|
||||
used_grid: "--energy-grid-consumption-color",
|
||||
used_solar: "--energy-solar-color",
|
||||
from_battery: "--energy-battery-out-color",
|
||||
used_battery: "--energy-battery-out-color",
|
||||
};
|
||||
|
||||
@@ -59,6 +60,7 @@ const stackOrder = {
|
||||
to_battery: 1,
|
||||
to_grid: 2,
|
||||
used_solar: 3,
|
||||
from_battery: 4,
|
||||
used_battery: 4,
|
||||
from_grid: 5,
|
||||
used_grid: 5,
|
||||
@@ -297,10 +299,12 @@ export class HuiEnergyUsageGraphCard
|
||||
to_grid: Record<string, string>;
|
||||
from_grid: Record<string, string>;
|
||||
to_battery: Record<string, string>;
|
||||
from_battery: Record<string, string>;
|
||||
} = {
|
||||
to_grid: {},
|
||||
from_grid: {},
|
||||
to_battery: {},
|
||||
from_battery: {},
|
||||
};
|
||||
|
||||
// Grid sources can be import-only or export-only; assign color indices by
|
||||
@@ -335,6 +339,10 @@ export class HuiEnergyUsageGraphCard
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
|
||||
{ name: source.name }
|
||||
);
|
||||
statLabels.from_battery[source.stat_energy_from] = this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
|
||||
{ name: source.name }
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -543,6 +551,7 @@ export class HuiEnergyUsageGraphCard
|
||||
to_grid: Record<string, string>;
|
||||
from_grid: Record<string, string>;
|
||||
to_battery: Record<string, string>;
|
||||
from_battery: Record<string, string>;
|
||||
},
|
||||
trackY: (v: number) => void,
|
||||
compare = false
|
||||
@@ -553,13 +562,16 @@ export class HuiEnergyUsageGraphCard
|
||||
to_grid?: Record<string, Record<number, number>>;
|
||||
to_battery?: Record<string, Record<number, number>>;
|
||||
from_grid?: Record<string, Record<number, number>>;
|
||||
from_battery?: Record<string, Record<number, number>>;
|
||||
used_grid?: Record<string, Record<number, number>>;
|
||||
used_solar?: Record<string, Record<number, number>>;
|
||||
used_battery?: Record<string, Record<number, number>>;
|
||||
} = {};
|
||||
|
||||
Object.entries(statIdsByCat).forEach(([key, statIds]) => {
|
||||
if (!["to_grid", "from_grid", "to_battery"].includes(key)) {
|
||||
if (
|
||||
!["to_grid", "from_grid", "to_battery", "from_battery"].includes(key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sets: Record<string, Record<number, number>> = {};
|
||||
@@ -584,21 +596,34 @@ export class HuiEnergyUsageGraphCard
|
||||
combinedData[key] = sets;
|
||||
});
|
||||
|
||||
// Only add solar/battery consumption series when such a source is
|
||||
// actually configured, otherwise the legend shows empty solar/battery
|
||||
// entries for grid-only setups. Combined used_grid is a fallback for
|
||||
// multi-source battery charging; skip it when it has no points.
|
||||
// Only add the solar consumption series when solar is configured,
|
||||
// otherwise the legend shows an empty solar entry for grid-only setups.
|
||||
// Combined used_grid and used_battery are fallbacks for periods that
|
||||
// can't be split per source; skip them when they have no points.
|
||||
if (statIdsByCat.solar) {
|
||||
combinedData.used_solar = { used_solar: consumptionData.used_solar };
|
||||
}
|
||||
if (statIdsByCat.from_battery) {
|
||||
combinedData.used_battery = {
|
||||
used_battery: consumptionData.used_battery,
|
||||
};
|
||||
|
||||
if (combinedData.from_battery) {
|
||||
// Discharge that did not reach the home
|
||||
const batteryNotUsed: Record<number, number> = {};
|
||||
for (const start of summedData.timestamps) {
|
||||
batteryNotUsed[start] =
|
||||
(summedData.from_battery?.[start] ?? 0) -
|
||||
consumptionData.used_battery[start];
|
||||
}
|
||||
const used_battery = buildCombinedUsed(
|
||||
combinedData.from_battery,
|
||||
batteryNotUsed,
|
||||
consumptionData.used_battery
|
||||
);
|
||||
if (used_battery) {
|
||||
combinedData.used_battery = { used_battery };
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedData.from_grid && summedData.to_battery) {
|
||||
const used_grid = buildCombinedUsedGrid(
|
||||
const used_grid = buildCombinedUsed(
|
||||
combinedData.from_grid,
|
||||
consumptionData.grid_to_battery,
|
||||
consumptionData.used_grid
|
||||
|
||||
@@ -16,7 +16,6 @@ import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-icon-button-arrow-prev";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import { NativeBackButtonController } from "../../external_app/external_back_button";
|
||||
import "../../components/media-player/ha-media-manage-button";
|
||||
import "../../components/media-player/ha-media-player-browse";
|
||||
import type {
|
||||
@@ -90,16 +89,11 @@ class PanelMediaBrowser extends LitElement {
|
||||
|
||||
@query("ha-bar-media-player") private _player!: BarMediaPlayer;
|
||||
|
||||
private _nativeBackButton = new NativeBackButtonController(this, {
|
||||
visible: () => this._navigateIds.length > 1,
|
||||
back: () => this._goBack(),
|
||||
});
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed .narrow=${this.narrow}>
|
||||
${
|
||||
this._navigateIds.length > 1 && !this._nativeBackButton.native
|
||||
this._navigateIds.length > 1
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
slot="navigationIcon"
|
||||
|
||||
@@ -264,7 +264,8 @@ export async function openOnboarding(page: Page, baseURL: string) {
|
||||
|
||||
export async function createOwner(page: Page) {
|
||||
await page
|
||||
.locator("onboarding-welcome ha-button.start")
|
||||
.locator("onboarding-welcome")
|
||||
.getByRole("button", { name: "Create my smart home", exact: true })
|
||||
.click({ timeout: SHELL_TIMEOUT });
|
||||
|
||||
const inputs = page.locator("onboarding-create-user ha-input >> input");
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
handleExternalMessage,
|
||||
addExternalBarCodeListener,
|
||||
} from "../../src/external_app/external_app_entrypoint";
|
||||
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
|
||||
import { showAutomationEditor } from "../../src/data/automation";
|
||||
import type {
|
||||
EMIncomingMessageRestart,
|
||||
@@ -20,7 +19,6 @@ import type {
|
||||
EMIncomingMessageImprovDeviceSetupDone,
|
||||
EMIncomingMessageBarCodeScanResult,
|
||||
EMIncomingMessageBarCodeScanAborted,
|
||||
EMIncomingMessageBackButtonPressed,
|
||||
} from "../../src/external_app/external_messaging";
|
||||
|
||||
vi.mock("../../src/common/dom/fire_event", () => ({
|
||||
@@ -32,9 +30,6 @@ vi.mock("../../src/common/navigate", () => ({
|
||||
vi.mock("../../src/data/automation", () => ({
|
||||
showAutomationEditor: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../src/external_app/external_back_button", () => ({
|
||||
handleNativeBackButtonPressed: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("handleExternalMessage", () => {
|
||||
let hassMainEl: any;
|
||||
@@ -290,38 +285,4 @@ describe("handleExternalMessage", () => {
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
it("handles back_button/pressed command", () => {
|
||||
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(true);
|
||||
const msg: EMIncomingMessageBackButtonPressed = {
|
||||
type: "command",
|
||||
command: "back_button/pressed",
|
||||
id: 13,
|
||||
};
|
||||
const result = handleExternalMessage(hassMainEl, msg);
|
||||
expect(handleNativeBackButtonPressed).toHaveBeenCalledOnce();
|
||||
expect(fireMessage).toHaveBeenCalledWith({
|
||||
id: 13,
|
||||
type: "result",
|
||||
success: true,
|
||||
result: null,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("reports back_button/pressed without a back button as an error", () => {
|
||||
vi.mocked(handleNativeBackButtonPressed).mockReturnValue(false);
|
||||
const msg: EMIncomingMessageBackButtonPressed = {
|
||||
type: "command",
|
||||
command: "back_button/pressed",
|
||||
id: 14,
|
||||
};
|
||||
const result = handleExternalMessage(hassMainEl, msg);
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
id: 14,
|
||||
type: "result",
|
||||
success: false,
|
||||
error: { code: "not_allowed", message: "no back button shown" },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleNativeBackButtonPressed } from "../../src/external_app/external_back_button";
|
||||
import type { ExternalMessaging } from "../../src/external_app/external_messaging";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
// The real back button pulls in the localize context, which is not provided here.
|
||||
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
|
||||
vi.mock("../../src/components/ha-menu-button", () => ({}));
|
||||
vi.mock("../../src/common/navigate", () => ({
|
||||
getHistoryState: () => undefined,
|
||||
goBack: vi.fn(),
|
||||
}));
|
||||
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
|
||||
customElements.define("ha-menu-button", class extends LitElement {});
|
||||
await import("../../src/layouts/hass-subpage");
|
||||
|
||||
const { goBack } = await import("../../src/common/navigate");
|
||||
|
||||
let fireMessage: ReturnType<typeof vi.fn>;
|
||||
let nativeBus: ExternalMessaging;
|
||||
|
||||
// The app has a single external bus, so every page shares one.
|
||||
const makeHass = (hasNativeBackButton: boolean): HomeAssistant =>
|
||||
({
|
||||
auth: {
|
||||
external: hasNativeBackButton
|
||||
? nativeBus
|
||||
: ({
|
||||
config: {},
|
||||
fireMessage,
|
||||
} as unknown as ExternalMessaging),
|
||||
},
|
||||
}) as HomeAssistant;
|
||||
|
||||
let host: HTMLDivElement | undefined;
|
||||
|
||||
const mount = async (hass: HomeAssistant, backPath = "/config") => {
|
||||
const element = document.createElement("hass-subpage");
|
||||
Object.assign(element, { hass, backPath });
|
||||
host!.append(element);
|
||||
await (element as LitElement).updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
const arrowOf = (element: Element) =>
|
||||
element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
|
||||
|
||||
beforeEach(() => {
|
||||
fireMessage = vi.fn();
|
||||
nativeBus = {
|
||||
config: { hasNativeBackButton: true },
|
||||
fireMessage,
|
||||
} as unknown as ExternalMessaging;
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
host?.remove();
|
||||
host = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("native back button", () => {
|
||||
it("keeps rendering the arrow when the app has no native back button", async () => {
|
||||
const element = await mount(makeHass(false));
|
||||
|
||||
expect(arrowOf(element)).not.toBeNull();
|
||||
expect(fireMessage).not.toHaveBeenCalled();
|
||||
expect(handleNativeBackButtonPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the arrow and reports the back button to the app", async () => {
|
||||
const element = await mount(makeHass(true));
|
||||
|
||||
expect(arrowOf(element)).toBeNull();
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
type: "back_button/show",
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the app back button once the page is gone", async () => {
|
||||
const element = await mount(makeHass(true));
|
||||
fireMessage.mockClear();
|
||||
|
||||
element.remove();
|
||||
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
type: "back_button/hide",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report a back button on a main page", async () => {
|
||||
const hass = makeHass(true);
|
||||
const element = document.createElement("hass-subpage");
|
||||
Object.assign(element, { hass, mainPage: true });
|
||||
host!.append(element);
|
||||
await (element as LitElement).updateComplete;
|
||||
|
||||
expect(fireMessage).not.toHaveBeenCalled();
|
||||
expect(handleNativeBackButtonPressed()).toBe(false);
|
||||
});
|
||||
|
||||
it("navigates back when the app reports a press", async () => {
|
||||
await mount(makeHass(true), "/config/areas");
|
||||
|
||||
expect(handleNativeBackButtonPressed()).toBe(true);
|
||||
expect(goBack).toHaveBeenCalledWith("/config/areas");
|
||||
});
|
||||
|
||||
it("uses the back callback of the page when it has one", async () => {
|
||||
const backCallback = vi.fn();
|
||||
const element = await mount(makeHass(true));
|
||||
Object.assign(element, { backCallback });
|
||||
await (element as LitElement).updateComplete;
|
||||
|
||||
expect(handleNativeBackButtonPressed()).toBe(true);
|
||||
expect(backCallback).toHaveBeenCalledOnce();
|
||||
expect(goBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the page mounted last own the back button", async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const firstPage = await mount(makeHass(true));
|
||||
Object.assign(firstPage, { backCallback: first });
|
||||
await (firstPage as LitElement).updateComplete;
|
||||
|
||||
const secondPage = await mount(makeHass(true));
|
||||
Object.assign(secondPage, { backCallback: second });
|
||||
await (secondPage as LitElement).updateComplete;
|
||||
|
||||
handleNativeBackButtonPressed();
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
|
||||
// Back on the first page, it owns the button again.
|
||||
secondPage.remove();
|
||||
handleNativeBackButtonPressed();
|
||||
expect(first).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("only reports the back button once while pages come and go", async () => {
|
||||
const firstPage = await mount(makeHass(true));
|
||||
const secondPage = await mount(makeHass(true));
|
||||
firstPage.remove();
|
||||
|
||||
expect(fireMessage).toHaveBeenCalledExactlyOnceWith({
|
||||
type: "back_button/show",
|
||||
});
|
||||
|
||||
secondPage.remove();
|
||||
|
||||
expect(fireMessage).toHaveBeenLastCalledWith({ type: "back_button/hide" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Protects the energy usage graph from adding an empty combined Grid or
|
||||
* Battery legend item, while still combining sources when multiple grid
|
||||
* imports share a battery-charging period or multiple batteries share an
|
||||
* exporting period.
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { buildCombinedUsed } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-combined-used";
|
||||
|
||||
const t = 1_700_000_000_000;
|
||||
|
||||
describe("buildCombinedUsed", () => {
|
||||
it("does not add a combined series for a single grid source charging a battery", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
|
||||
});
|
||||
|
||||
it("combines overlapping grid sources and removes per-source points", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import_a": { [t]: 6 },
|
||||
"sensor.grid_import_b": { [t]: 4 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
|
||||
|
||||
assert.deepEqual(result, { [t]: 7 });
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
|
||||
});
|
||||
|
||||
it("does not add a combined series when battery is present but not charging from grid", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsed(fromGridBySource, {}, { [t]: 10 });
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
|
||||
});
|
||||
|
||||
it("does not add a combined series when the home used none of the energy", () => {
|
||||
const fromBatteryBySource = {
|
||||
"sensor.battery_a_out": { [t]: 2 },
|
||||
"sensor.battery_b_out": { [t]: 1 },
|
||||
};
|
||||
|
||||
// All discharge exported; the model leaves 2.8e-17 as "used"
|
||||
const result = buildCombinedUsed(
|
||||
fromBatteryBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 2.7755575615628914e-17 }
|
||||
);
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.isUndefined(fromBatteryBySource["sensor.battery_a_out"][t]);
|
||||
assert.isUndefined(fromBatteryBySource["sensor.battery_b_out"][t]);
|
||||
});
|
||||
|
||||
it("treats float noise in the unused energy as zero", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import_a": { [t]: 0.06 },
|
||||
"sensor.grid_import_b": { [t]: 0.04 },
|
||||
};
|
||||
|
||||
// 0.06 + 0.04 + 0.4 solar - 0.4 to battery leaves 2.8e-17 "grid to battery"
|
||||
const result = buildCombinedUsed(
|
||||
fromGridBySource,
|
||||
{ [t]: 2.7755575615628914e-17 },
|
||||
{ [t]: 0.1 }
|
||||
);
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import_a"][t], 0.06);
|
||||
assert.equal(fromGridBySource["sensor.grid_import_b"][t], 0.04);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Protects the energy usage graph from adding an empty combined Grid
|
||||
* legend item for single-source + battery setups, while still combining
|
||||
* sources when multiple grid imports share a battery-charging period.
|
||||
*/
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { buildCombinedUsedGrid } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-used-grid";
|
||||
|
||||
const t = 1_700_000_000_000;
|
||||
|
||||
describe("buildCombinedUsedGrid", () => {
|
||||
it("does not add a combined series for a single grid source charging a battery", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(
|
||||
fromGridBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 7 }
|
||||
);
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
|
||||
});
|
||||
|
||||
it("combines overlapping grid sources and removes per-source points", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import_a": { [t]: 6 },
|
||||
"sensor.grid_import_b": { [t]: 4 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(
|
||||
fromGridBySource,
|
||||
{ [t]: 3 },
|
||||
{ [t]: 7 }
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { [t]: 7 });
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
|
||||
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
|
||||
});
|
||||
|
||||
it("does not add a combined series when battery is present but not charging from grid", () => {
|
||||
const fromGridBySource = {
|
||||
"sensor.grid_import": { [t]: 10 },
|
||||
};
|
||||
|
||||
const result = buildCombinedUsedGrid(fromGridBySource, {}, { [t]: 10 });
|
||||
|
||||
assert.isUndefined(result);
|
||||
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
|
||||
});
|
||||
});
|
||||
@@ -11443,13 +11443,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.1.0":
|
||||
version: 4.3.1
|
||||
resolution: "js-yaml@npm:4.3.1"
|
||||
version: 4.3.2
|
||||
resolution: "js-yaml@npm:4.3.2"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.js
|
||||
checksum: 10/2ce71b5d632abbd77da80447bf860e8a0264e54bffe94840984887d58b023761495b523727547904517a6107a1ef189854b361e0fc44995ee13a84f222d7bd42
|
||||
checksum: 10/05c44b9c73e4901d92703b155e76518df64bf01ac62e4c036b47de4b391e19b72e32656e8954d51b436307f08cc9d0c0d4ec617d061cf2f65fffee9f3114bee7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user