Compare commits

...

91 Commits

Author SHA1 Message Date
Paul Bottein
ce8cabbad9 Bumped version to 20251203.2 2025-12-08 17:29:01 +01:00
karwosts
0802841606 More unsafe description_placeholders fixes (#28416) 2025-12-08 17:28:52 +01:00
Nils Schönwald
cb93e1b741 Update snowflake to 6 sides (#28406) 2025-12-08 17:28:51 +01:00
dcapslock
30c383a2fc Energy strategies to refresh energy collection which allows to be used in custom dashboards (#28400)
* Energy strategies to refresh energy collection which allows to be used in custom dashboards

* Update src/panels/energy/strategies/energy-overview-view-strategy.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Only refresh if no prefs

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-08 17:28:50 +01:00
karwosts
73ee235fef Fix for undefined description_placeholders (#28395)
Another fix for undefined description_placeholders
2025-12-08 17:28:49 +01:00
Paul Bottein
17c1043cfc Bumped version to 20251203.1 2025-12-05 20:51:48 +01:00
Timothy
da255dce40 Add add to button in more info topbar for non admin users (#28365) 2025-12-05 20:51:20 +01:00
Paul Bottein
0c68072f8f Use non-admin endpoint to subscribe to one lab feature (#28352) 2025-12-05 20:51:19 +01:00
Petar Petrov
d197fd8f76 Fix calendar card not showing different colors for multiple calendars (#28338) 2025-12-05 20:51:18 +01:00
Paul Bottein
a961a87872 Move reorder areas and floors to floor overflow (#28335) 2025-12-05 20:51:17 +01:00
Petar Petrov
cc96c707b9 Fix markdown sections and styling (#28333) 2025-12-05 20:51:16 +01:00
Petar Petrov
4b73713f2a Fix gauge severity using entity state instead of attribute value (#28331) 2025-12-05 20:51:15 +01:00
Petar Petrov
c001102f15 Append current state to power-sources-graph (#28330) 2025-12-05 20:51:14 +01:00
Preet Patel
c1e5e0bfcb Fix energy dashboard redirect for device-consumption-only configs (#28322)
When users configure energy with only device consumption (no
grid/solar/battery/gas/water sources), the dashboard would redirect
to /config/energy instead of displaying. This occurred because
_generateLovelaceConfig() returned an empty views array.

The fix adds hasDeviceConsumption check and includes ENERGY_VIEW
when device consumption is configured, since energy-view-strategy
already supports device consumption cards.
2025-12-05 20:51:13 +01:00
Bram Kragten
a1412e90fd Add more info to the energy demo (#28316)
* Add more info to the energy demo

* Also add battery power
2025-12-05 20:51:12 +01:00
Petar Petrov
f6f40c1679 Always show energy-sources-table in overview (#28315) 2025-12-05 20:48:59 +01:00
Bram Kragten
d77bebe96b Bumped version to 20251203.0 2025-12-03 15:38:49 +01:00
Bram Kragten
1260af0b45 Fix add matter device my link (#28313) 2025-12-03 15:36:05 +01:00
Petar Petrov
1d37eec411 Fix label filter losing selections when searching (#28312) 2025-12-03 15:36:04 +01:00
Bram Kragten
5a52f83358 Fix sticky headers in TCA dialog when target is selected (#28310) 2025-12-03 15:36:03 +01:00
Aidan Timson
60724eb952 Add subscribeLabFeature function (#28309)
* Add subscribe to lab feature function

* Add docstrings to exported functions
2025-12-03 15:36:02 +01:00
Aidan Timson
de5778079e Add small rotation to snowflakes (#28308) 2025-12-03 15:36:01 +01:00
Wendelin
f3710650f2 Hide disabled devices in automation target tree (#28307) 2025-12-03 15:36:00 +01:00
Paul Bottein
feb35dbc4f Use svg for snowflakes (#28306) 2025-12-03 15:35:59 +01:00
Paul Bottein
ee9e101fa6 Rename unassigned areas to other areas (#28305) 2025-12-03 15:35:58 +01:00
Paul Bottein
24b16360a6 Use core area sorting everywhere (#28304) 2025-12-03 15:35:57 +01:00
Wendelin
109c81a00d Revert "Migrate updates dropdown to ha-dropdown" (#28303)
Revert "Migrate updates dropdown to ha-dropdown (#28039)"

This reverts commit ba9bab38c9.
2025-12-03 15:35:56 +01:00
Wendelin
eaa1ddbf61 Fix filtering of floors in getAreasAndFloorsItems function (#28302) 2025-12-03 15:35:55 +01:00
Paul Bottein
b11cb57a1e Always set ha-wa-dialog position to fixed (#28301) 2025-12-03 15:35:55 +01:00
Petar Petrov
87b5f58779 Add Y-axis label formatter to energy charts (#28298) 2025-12-03 15:35:53 +01:00
Petar Petrov
8dac53c672 Fix binary sensor history timeline not rendering properly (#28297) 2025-12-03 15:35:52 +01:00
Petar Petrov
d0966bf35a Hide empty System message in assist debug view (#28296) 2025-12-03 15:35:51 +01:00
Paul Bottein
6ba4fc0808 Handle not existing panels in dashboard config (#28292) 2025-12-03 15:35:50 +01:00
ildar170975
bd582ff816 computeLovelaceEntityName(): allow "number" names to be processed (#28231)
* allow "number" names to be processed

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-03 15:35:49 +01:00
Bram Kragten
d34bf83da0 Bumped version to 20251202.0 2025-12-02 16:02:32 +01:00
Wendelin
b0cfb31bf3 Automation add TCA: fix narrow subtitles & icons (#28291) 2025-12-02 16:02:25 +01:00
Wendelin
6c39e5d2c5 Use history to manage back button click in automations add TCA (#28289) 2025-12-02 16:02:24 +01:00
Paul Bottein
7b51e71092 Only show current weather in home overview (#28288) 2025-12-02 16:02:23 +01:00
Paul Bottein
8a82483685 Fix container alignment in section view (#28287) 2025-12-02 16:02:23 +01:00
Bram Kragten
bb691fa7a2 fix paste in add tca dialog (#28286) 2025-12-02 16:02:22 +01:00
Petar Petrov
2232db9c0f Update Energy dashboard layout (#28283) 2025-12-02 16:02:21 +01:00
Petar Petrov
5375665dc6 Fix index value for grid return in power sankey card (#28281) 2025-12-02 16:02:20 +01:00
Silas Krause
480122f40a Revert custom markdown styles (#28277) 2025-12-02 16:02:18 +01:00
karwosts
ee5c54030a Safer lookup of description_placeholders when service is invalid (#28273) 2025-12-02 16:02:17 +01:00
Paul Bottein
b73f50e864 Add dialog to reorder areas and floors (#28272) 2025-12-02 16:02:16 +01:00
eringerli
b9836073b7 fix stacking of multiple power sources (#28243) 2025-12-02 16:02:15 +01:00
Bram Kragten
a40512e0b5 Bumped version to 20251201.0 2025-12-01 16:35:54 +01:00
Paul Bottein
b2122570fb Clean reference to floor compare (#28269)
Fix floor compare
2025-12-01 16:35:34 +01:00
Paul Bottein
885f9333d2 Add helper for floor level (#28268)
* Add helper for floor level

* Update src/translations/en.json

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-01 16:35:33 +01:00
Aidan Timson
f812e7e9fb Match more-info-update backup preferences (#28266) 2025-12-01 16:35:32 +01:00
Wendelin
64dad39f6e Fix automation trigger ha icon (#28265) 2025-12-01 16:35:31 +01:00
Simon Lamon
df0fb423ed Include background in light, climate and security views (#28264)
* Include background

* Remove background key

* Add imports
2025-12-01 16:35:30 +01:00
Wendelin
4c3156f290 Respect system area sort in automation target tree (#28263) 2025-12-01 16:35:29 +01:00
Petar Petrov
ecdf374902 Reduce the duration of init animation for charts to 500ms (#28262)
Reduce the duration of init animation for charts
2025-12-01 16:35:29 +01:00
Aidan Timson
3e924e0cde Add missing key for labs to show in quick bar (#28261) 2025-12-01 16:35:27 +01:00
Bram Kragten
6fb71e12c8 Use name instead of description_configured for triggers and conditions (#28260) 2025-12-01 16:35:27 +01:00
Wendelin
6138aa5489 Fix ha-bottom-sheet closed event (#28257) 2025-12-01 16:35:26 +01:00
Aidan Timson
61e865d3a6 Fix 1px padding for subpage titles (#28256) 2025-12-01 16:35:24 +01:00
Aidan Timson
febcbf6242 Make labs toolbar icon use default color (#28255) 2025-12-01 16:35:23 +01:00
Petar Petrov
6a2fac6a9e Fix refresh in energy panel subviews (#28252) 2025-12-01 16:35:22 +01:00
karwosts
b60c5467fc Add water devices to energy data download (#28242) 2025-12-01 16:35:21 +01:00
Petar Petrov
ecd563406e Add power view and restructure energy dashboard layout (#28240) 2025-12-01 16:35:19 +01:00
Silas Krause
d5b66315e2 Fix markdown rendering for cached html (#28229)
* Render markdown table in wrapper.

* Fix markdown styles

* Fix formatting

* fix rendering for cache
2025-12-01 16:35:18 +01:00
karwosts
5b1719fc6e Add missing helper to language selector (#28218) 2025-12-01 16:35:17 +01:00
Silas Krause
add22cf2e9 Fix markdown styles regression (#28202)
* Render markdown table in wrapper.

* Fix markdown styles

* Fix formatting
2025-12-01 16:35:16 +01:00
Paul Bottein
21509191fa Fix ha icon size (#28201) 2025-12-01 16:35:15 +01:00
Paul Bottein
1a73cccf0d Fix safe area for sidebar section views in Android (#28194) 2025-12-01 16:35:14 +01:00
Aidan Timson
407d68250a Fix ha-wa-dialog fullscreen and make alerts not fullscreen (#28175) 2025-12-01 16:35:13 +01:00
Bram Kragten
38b7bd18bb Bumped version to 20251127.0 2025-11-27 17:06:57 +01:00
Wendelin
a00e944a35 Add TCA by target sort like item collections (#28192) 2025-11-27 17:06:30 +01:00
Petar Petrov
481569804e Fix water sankey calculation to include total supply from sources (#28191) 2025-11-27 17:06:29 +01:00
Paul Bottein
a1d7e270ff Add hint to reorder areas and floors (#28189) 2025-11-27 17:06:28 +01:00
Wendelin
225ccf1d2f Fix lab automations icons and sidebar width (#28184)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-27 17:06:27 +01:00
Wendelin
4a5e1f9f3f "Add TCA" dialog desktop height to 800px (#28182) 2025-11-27 17:06:26 +01:00
Wendelin
b27b7210fd Show hidden entities in target tree (#28181)
* Show hidden entities in target tree

* Fix types
2025-11-27 17:06:25 +01:00
Petar Petrov
acd5181449 Fix sankey chart resizing (#28180) 2025-11-27 17:06:24 +01:00
Bram Kragten
b6b2d03a80 Always store token when using develop and serve (#28179) 2025-11-27 17:06:22 +01:00
Paul Bottein
7aee2b7cb7 Fix labs back button (#28174) 2025-11-27 17:06:21 +01:00
Paul Bottein
df1914cb7a Fix disabled dashboard picker when no custom dashboard (#28172) 2025-11-27 17:06:20 +01:00
Paul Bottein
6706d5904d Fix box shadow for sidebar tabs (#28170) 2025-11-27 17:06:19 +01:00
Wendelin
221aefd764 Fix automation add TCA autofocus (#28168)
Fix automation add tca autofocus
2025-11-27 17:06:18 +01:00
Paul Bottein
670057e8e6 Restore sidebar view when clicking back (#28167) 2025-11-27 17:06:17 +01:00
Wendelin
427e46201c Fix add condition default tab and blank styles (#28166) 2025-11-27 17:06:16 +01:00
Petar Petrov
fd1240f335 Refactor power sankey hierarchy to handle devices with not power sensor (#28164) 2025-11-27 17:06:15 +01:00
Petar Petrov
aa7670cb59 Disable axis pointer on the energy devices bar chart to fix refresh issues on touch devices (#28163) 2025-11-27 17:06:14 +01:00
Petar Petrov
468139229c Handle grouping by floor and area in power sankey card (#28162) 2025-11-27 17:06:13 +01:00
Simon Lamon
39752f0e3f Don't show more info for untracked consumption (#28151) 2025-11-27 17:06:12 +01:00
Petar Petrov
4d850d067f Replace gauges with energy usage graph in energy overview (#28150) 2025-11-27 17:06:10 +01:00
Paul Bottein
bcae64df88 Use hui-root for panel energy (#28149)
* Use hui-root for panel energy

* Review feedback

* Set empty prefs
2025-11-27 17:06:09 +01:00
Iván Pereira
690fd5a061 Fix hide sidebar tooltip on touchend events (#28042)
* fix: hide sidebar tooltip on touchend events

* Add a comment recommended by Copilot

* Clear timeouts id in disconnectedCallback
2025-11-27 17:06:08 +01:00
Bram Kragten
ac56c6df9a Bumped version to 20251126.0 2025-11-26 16:11:20 +01:00
92 changed files with 2366 additions and 1377 deletions

View File

@@ -156,7 +156,9 @@ const createTestTranslation = () =>
*/ */
const createMasterTranslation = () => const createMasterTranslation = () =>
gulp gulp
.src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])]) .src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])], {
allowEmpty: true,
})
.pipe(new CustomJSON(lokaliseTransform)) .pipe(new CustomJSON(lokaliseTransform))
.pipe(new MergeJSON("en")) .pipe(new MergeJSON("en"))
.pipe(gulp.dest(workDir)); .pipe(gulp.dest(workDir));

View File

@@ -44,18 +44,24 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
number_energy_price: null, number_energy_price: null,
}, },
], ],
power: [
{ stat_rate: "sensor.power_grid" },
{ stat_rate: "sensor.power_grid_return" },
],
cost_adjustment_day: 0, cost_adjustment_day: 0,
}, },
{ {
type: "solar", type: "solar",
stat_energy_from: "sensor.solar_production", stat_energy_from: "sensor.solar_production",
stat_rate: "sensor.power_solar",
config_entry_solar_forecast: ["solar_forecast"], config_entry_solar_forecast: ["solar_forecast"],
}, },
/* { {
type: "battery", type: "battery",
stat_energy_from: "sensor.battery_output", stat_energy_from: "sensor.battery_output",
stat_energy_to: "sensor.battery_input", stat_energy_to: "sensor.battery_input",
}, */ stat_rate: "sensor.power_battery",
},
{ {
type: "gas", type: "gas",
stat_energy_from: "sensor.energy_gas", stat_energy_from: "sensor.energy_gas",
@@ -63,28 +69,48 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
entity_energy_price: null, entity_energy_price: null,
number_energy_price: null, number_energy_price: null,
}, },
{
type: "water",
stat_energy_from: "sensor.energy_water",
stat_cost: "sensor.energy_water_cost",
entity_energy_price: null,
number_energy_price: null,
},
], ],
device_consumption: [ device_consumption: [
{ {
stat_consumption: "sensor.energy_car", stat_consumption: "sensor.energy_car",
stat_rate: "sensor.power_car",
}, },
{ {
stat_consumption: "sensor.energy_ac", stat_consumption: "sensor.energy_ac",
stat_rate: "sensor.power_ac",
}, },
{ {
stat_consumption: "sensor.energy_washing_machine", stat_consumption: "sensor.energy_washing_machine",
stat_rate: "sensor.power_washing_machine",
}, },
{ {
stat_consumption: "sensor.energy_dryer", stat_consumption: "sensor.energy_dryer",
stat_rate: "sensor.power_dryer",
}, },
{ {
stat_consumption: "sensor.energy_heat_pump", stat_consumption: "sensor.energy_heat_pump",
stat_rate: "sensor.power_heat_pump",
}, },
{ {
stat_consumption: "sensor.energy_boiler", stat_consumption: "sensor.energy_boiler",
stat_rate: "sensor.power_boiler",
},
],
device_consumption_water: [
{
stat_consumption: "sensor.water_kitchen",
},
{
stat_consumption: "sensor.water_garden",
}, },
], ],
device_consumption_water: [],
}) })
); );
hass.mockWS( hass.mockWS(

View File

@@ -154,6 +154,38 @@ export const energyEntities = () =>
unit_of_measurement: "EUR", unit_of_measurement: "EUR",
}, },
}, },
"sensor.power_grid": {
entity_id: "sensor.power_grid",
state: "500",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.power_grid_return": {
entity_id: "sensor.power_grid_return",
state: "-100",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.power_solar": {
entity_id: "sensor.power_solar",
state: "200",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.power_battery": {
entity_id: "sensor.power_battery",
state: "100",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.energy_gas_cost": { "sensor.energy_gas_cost": {
entity_id: "sensor.energy_gas_cost", entity_id: "sensor.energy_gas_cost",
state: "2", state: "2",
@@ -171,6 +203,15 @@ export const energyEntities = () =>
unit_of_measurement: "m³", unit_of_measurement: "m³",
}, },
}, },
"sensor.energy_water": {
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Water",
unit_of_measurement: "L",
},
},
"sensor.energy_car": { "sensor.energy_car": {
entity_id: "sensor.energy_car", entity_id: "sensor.energy_car",
state: "4", state: "4",
@@ -225,4 +266,58 @@ export const energyEntities = () =>
unit_of_measurement: "kWh", unit_of_measurement: "kWh",
}, },
}, },
"sensor.power_car": {
entity_id: "sensor.power_car",
state: "40",
attributes: {
state_class: "measurement",
friendly_name: "Electric car",
unit_of_measurement: "W",
},
},
"sensor.power_ac": {
entity_id: "sensor.power_ac",
state: "30",
attributes: {
state_class: "measurement",
friendly_name: "Air conditioning",
unit_of_measurement: "W",
},
},
"sensor.power_washing_machine": {
entity_id: "sensor.power_washing_machine",
state: "60",
attributes: {
state_class: "measurement",
friendly_name: "Washing machine",
unit_of_measurement: "W",
},
},
"sensor.power_dryer": {
entity_id: "sensor.power_dryer",
state: "55",
attributes: {
state_class: "measurement",
friendly_name: "Dryer",
unit_of_measurement: "W",
},
},
"sensor.power_heat_pump": {
entity_id: "sensor.power_heat_pump",
state: "60",
attributes: {
state_class: "measurement",
friendly_name: "Heat pump",
unit_of_measurement: "W",
},
},
"sensor.power_boiler": {
entity_id: "sensor.power_boiler",
state: "70",
attributes: {
state_class: "measurement",
friendly_name: "Boiler",
unit_of_measurement: "W",
},
},
}); });

View File

@@ -17,17 +17,15 @@ const generateMeanStatistics = (
end: Date, end: Date,
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
period: "5minute" | "hour" | "day" | "month" = "hour", period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number maxDiff: number
): StatisticValue[] => { ): StatisticValue[] => {
const statistics: StatisticValue[] = []; const statistics: StatisticValue[] = [];
let currentDate = new Date(start); let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0); currentDate.setMinutes(0, 0, 0);
let lastVal = initValue;
const now = new Date(); const now = new Date();
while (end > currentDate && currentDate < now) { while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff; const delta = Math.random() * maxDiff;
const mean = lastVal + delta; const mean = delta;
statistics.push({ statistics.push({
start: currentDate.getTime(), start: currentDate.getTime(),
end: currentDate.getTime(), end: currentDate.getTime(),
@@ -38,7 +36,6 @@ const generateMeanStatistics = (
state: mean, state: mean,
sum: null, sum: null,
}); });
lastVal = mean;
currentDate = currentDate =
period === "day" period === "day"
? addDays(currentDate, 1) ? addDays(currentDate, 1)
@@ -336,7 +333,6 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
start, start,
end, end,
period, period,
state,
state * (state > 80 ? 0.05 : 0.1) state * (state > 80 ? 0.05 : 0.1)
); );
} }

View File

@@ -381,10 +381,6 @@ export class DemoHaWaDialog extends LitElement {
<td><code>--dialog-z-index</code></td> <td><code>--dialog-z-index</code></td>
<td>Z-index for the dialog.</td> <td>Z-index for the dialog.</td>
</tr> </tr>
<tr>
<td><code>--dialog-surface-position</code></td>
<td>CSS position of the dialog surface.</td>
</tr>
<tr> <tr>
<td><code>--dialog-surface-margin-top</code></td> <td><code>--dialog-surface-margin-top</code></td>
<td>Top margin for the dialog surface.</td> <td>Top margin for the dialog surface.</td>

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20251029.0" version = "20251203.2"
license = "Apache-2.0" license = "Apache-2.0"
license-files = ["LICENSE*"] license-files = ["LICENSE*"]
description = "The Home Assistant frontend" description = "The Home Assistant frontend"

View File

@@ -1,5 +1,6 @@
import type { AuthData } from "home-assistant-js-websocket"; import type { AuthData } from "home-assistant-js-websocket";
import { extractSearchParam } from "../url/search-params"; import { extractSearchParam } from "../url/search-params";
import { hassUrl } from "../../data/auth";
declare global { declare global {
interface Window { interface Window {
@@ -30,7 +31,11 @@ export function askWrite() {
export function saveTokens(tokens: AuthData | null) { export function saveTokens(tokens: AuthData | null) {
tokenCache.tokens = tokens; tokenCache.tokens = tokens;
if (!tokenCache.writeEnabled && extractSearchParam("storeToken") === "true") { if (
!tokenCache.writeEnabled &&
(extractSearchParam("storeToken") === "true" ||
hassUrl !== `${location.protocol}//${location.host}`)
) {
tokenCache.writeEnabled = true; tokenCache.writeEnabled = true;
} }

View File

@@ -593,6 +593,7 @@ export class HaChartBase extends LitElement {
} }
const options = { const options = {
animation: !this._reducedMotion, animation: !this._reducedMotion,
animationDuration: 500,
darkMode: this._themes.darkMode ?? false, darkMode: this._themes.darkMode ?? false,
aria: { show: true }, aria: { show: true },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),

View File

@@ -167,6 +167,7 @@ export class HaSankeyChart extends LitElement {
curveness: 0.5, curveness: 0.5,
}, },
layoutIterations: 0, layoutIterations: 0,
animationDuration: 500,
label: { label: {
formatter: (params) => formatter: (params) =>
data.nodes.find((node) => node.id === (params.data as Node).id) data.nodes.find((node) => node.id === (params.data as Node).id)
@@ -279,6 +280,7 @@ export class HaSankeyChart extends LitElement {
:host { :host {
display: block; display: block;
flex: 1; flex: 1;
max-width: 100%;
background: var(--ha-card-background, var(--card-background-color)); background: var(--ha-card-background, var(--card-background-color));
} }
ha-chart-base { ha-chart-base {

View File

@@ -373,6 +373,7 @@ export class StateHistoryChartTimeline extends LitElement {
itemName: 3, itemName: 3,
}, },
renderItem: this._renderItem, renderItem: this._renderItem,
progressive: 0,
}); });
}); });

View File

@@ -4,7 +4,6 @@ import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { getAreaContext } from "../common/entity/context/get_area_context"; import { getAreaContext } from "../common/entity/context/get_area_context";
import { areaCompare } from "../data/area_registry";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-expansion-panel"; import "./ha-expansion-panel";
import "./ha-items-display-editor"; import "./ha-items-display-editor";
@@ -37,11 +36,7 @@ export class HaAreasDisplayEditor extends LitElement {
public showNavigationButton = false; public showNavigationButton = false;
protected render(): TemplateResult { protected render(): TemplateResult {
const compare = areaCompare(this.hass.areas); const areas = Object.values(this.hass.areas);
const areas = Object.values(this.hass.areas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const items: DisplayItem[] = areas.map((area) => { const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area, this.hass.floors); const { floor } = getAreaContext(area, this.hass.floors);

View File

@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name"; import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context"; import { getAreaContext } from "../common/entity/context/get_area_context";
import { areaCompare } from "../data/area_registry";
import type { FloorRegistryEntry } from "../data/floor_registry"; import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper"; import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -131,11 +130,8 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
// update items if floors change // update items if floors change
_hassFloors: HomeAssistant["floors"] _hassFloors: HomeAssistant["floors"]
): Record<string, DisplayItem[]> => { ): Record<string, DisplayItem[]> => {
const compare = areaCompare(hassAreas); const areas = Object.values(hassAreas);
const areas = Object.values(hassAreas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce( const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => { (acc, area) => {
const { floor } = getAreaContext(area, this.hass.floors); const { floor } = getAreaContext(area, this.hass.floors);

View File

@@ -659,6 +659,7 @@ export class HaAssistChat extends LitElement {
--markdown-table-border-color: var(--divider-color); --markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color); --markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color); --markdown-code-text-color: var(--primary-text-color);
--markdown-list-indent: 1rem;
&:not(:has(ha-markdown-element)) { &:not(:has(ha-markdown-element)) {
min-height: 1lh; min-height: 1lh;
min-width: 1lh; min-width: 1lh;

View File

@@ -21,7 +21,8 @@ export class HaBottomSheet extends LitElement {
private _isDragging = false; private _isDragging = false;
private _handleAfterHide() { private _handleAfterHide(afterHideEvent: Event) {
afterHideEvent.stopPropagation();
this.open = false; this.open = false;
const ev = new Event("closed", { const ev = new Event("closed", {
bubbles: true, bubbles: true,

View File

@@ -202,6 +202,7 @@ export class HaControlSelect extends LitElement {
color: var(--primary-text-color); color: var(--primary-text-color);
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
border-radius: var(--control-select-border-radius);
} }
:host([vertical]) { :host([vertical]) {
width: var(--control-select-thickness); width: var(--control-select-thickness);
@@ -211,7 +212,6 @@ export class HaControlSelect extends LitElement {
position: relative; position: relative;
height: 100%; height: 100%;
width: 100%; width: 100%;
border-radius: var(--control-select-border-radius);
transform: translateZ(0); transform: translateZ(0);
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -167,30 +167,33 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
} }
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) { private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
const filteredLabels = this._filteredLabels( const filteredLabels = this._filteredLabels(
this._labels, this._labels,
this._filter, this._filter,
this.value this.value
); );
const filteredLabelIds = new Set(filteredLabels.map((l) => l.label_id));
// Keep previously selected labels that are not in the current filtered view
const preservedLabels = (this.value || []).filter(
(id) => !filteredLabelIds.has(id)
);
// Build the new selection from the filtered labels based on selected indices
const newlySelectedLabels: string[] = [];
for (const index of ev.detail.index) { for (const index of ev.detail.index) {
const labelId = filteredLabels[index].label_id; const labelId = filteredLabels[index]?.label_id;
value.push(labelId); if (labelId) {
newlySelectedLabels.push(labelId);
}
} }
this.value = value;
const value = [...preservedLabels, ...newlySelectedLabels];
this.value = value.length ? value : [];
fireEvent(this, "data-table-filter-changed", { fireEvent(this, "data-table-filter-changed", {
value, value: value.length ? value : undefined,
items: undefined, items: undefined,
}); });
} }

View File

@@ -248,7 +248,7 @@ export class HaGenericPicker extends LitElement {
}); });
}; };
private _hidePicker(ev) { private _hidePicker(ev: Event) {
ev.stopPropagation(); ev.stopPropagation();
if (this._newValue) { if (this._newValue) {
fireEvent(this, "value-changed", { value: this._newValue }); fireEvent(this, "value-changed", { value: this._newValue });

View File

@@ -73,6 +73,8 @@ export class HaLanguagePicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@property() public helper?: string;
@property({ attribute: "native-name", type: Boolean }) @property({ attribute: "native-name", type: Boolean })
public nativeName = false; public nativeName = false;
@@ -135,6 +137,7 @@ export class HaLanguagePicker extends LitElement {
.value=${value} .value=${value}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.disabled=${this.disabled} .disabled=${this.disabled}
.helper=${this.helper}
.getItems=${this._getItems} .getItems=${this._getItems}
@value-changed=${this._changed} @value-changed=${this._changed}
hide-clear-icon hide-clear-icon

View File

@@ -71,7 +71,7 @@ class HaMarkdownElement extends ReactiveElement {
if (!this.innerHTML && this.cache) { if (!this.innerHTML && this.cache) {
const key = this._computeCacheKey(); const key = this._computeCacheKey();
if (markdownCache.has(key)) { if (markdownCache.has(key)) {
render(markdownCache.get(key)!, this.renderRoot); render(h(unsafeHTML(markdownCache.get(key))), this.renderRoot);
this._resize(); this._resize();
} }
} }
@@ -99,10 +99,7 @@ class HaMarkdownElement extends ReactiveElement {
} }
); );
render( render(h(unsafeHTML(elements.join(""))), this.renderRoot);
elements.map((e) => h(unsafeHTML(e))),
this.renderRoot
);
this._resize(); this._resize();

View File

@@ -25,11 +25,11 @@ export class HaMarkdown extends LitElement {
@property({ type: Boolean }) public cache = false; @property({ type: Boolean }) public cache = false;
@query("ha-markdown-element") private _markdownElement!: ReactiveElement; @query("ha-markdown-element") private _markdownElement?: ReactiveElement;
protected async getUpdateComplete() { protected async getUpdateComplete() {
const result = await super.getUpdateComplete(); const result = await super.getUpdateComplete();
await this._markdownElement.updateComplete; await this._markdownElement?.updateComplete;
return result; return result;
} }
@@ -71,13 +71,11 @@ export class HaMarkdown extends LitElement {
color: var(--markdown-link-color, var(--primary-color)); color: var(--markdown-link-color, var(--primary-color));
} }
img { img {
background-color: rgba(10, 10, 10, 0.15); background-color: var(--markdown-image-background-color);
border-radius: var(--markdown-image-border-radius); border-radius: var(--markdown-image-border-radius);
max-width: 100%; max-width: 100%;
min-height: 2lh;
height: auto; height: auto;
width: auto; width: auto;
text-indent: 4px;
transition: height 0.2s ease-in-out; transition: height 0.2s ease-in-out;
} }
p:first-child > img:first-child { p:first-child > img:first-child {
@@ -86,10 +84,9 @@ export class HaMarkdown extends LitElement {
p:first-child > img:last-child { p:first-child > img:last-child {
vertical-align: top; vertical-align: top;
} }
ol, :host > ul,
ul { :host > ol {
list-style-position: inside; padding-inline-start: var(--markdown-list-indent, revert);
padding-inline-start: 0;
} }
li { li {
&:has(input[type="checkbox"]) { &:has(input[type="checkbox"]) {
@@ -140,16 +137,19 @@ export class HaMarkdown extends LitElement {
margin: var(--ha-space-4) 0; margin: var(--ha-space-4) 0;
} }
table { table {
border-collapse: collapse; border-collapse: var(--markdown-table-border-collapse, collapse);
display: block; }
overflow-x: auto; div:has(> table) {
overflow: auto;
} }
th { th {
text-align: start; text-align: start;
} }
td, td,
th { th {
border: 1px solid var(--markdown-table-border-color, transparent); border-width: var(--markdown-table-border-width, 1px);
border-style: var(--markdown-table-border-style, solid);
border-color: var(--markdown-table-border-color, var(--divider-color));
padding: 0.25em 0.5em; padding: 0.25em 0.5em;
} }
blockquote { blockquote {

View File

@@ -467,7 +467,7 @@ export class HaServiceControl extends LitElement {
const descriptionPlaceholders = const descriptionPlaceholders =
domain && serviceName domain && serviceName
? this.hass.services[domain][serviceName].description_placeholders ? this.hass.services[domain]?.[serviceName]?.description_placeholders
: undefined; : undefined;
const description = const description =

View File

@@ -197,6 +197,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _mouseLeaveTimeout?: number; private _mouseLeaveTimeout?: number;
private _touchendTimeout?: number;
private _tooltipHideTimeout?: number; private _tooltipHideTimeout?: number;
private _recentKeydownActiveUntil = 0; private _recentKeydownActiveUntil = 0;
@@ -237,6 +239,18 @@ class HaSidebar extends SubscribeMixin(LitElement) {
]; ];
} }
public disconnectedCallback() {
super.disconnectedCallback();
// clear timeouts
clearTimeout(this._mouseLeaveTimeout);
clearTimeout(this._tooltipHideTimeout);
clearTimeout(this._touchendTimeout);
// set undefined values
this._mouseLeaveTimeout = undefined;
this._tooltipHideTimeout = undefined;
this._touchendTimeout = undefined;
}
protected render() { protected render() {
if (!this.hass) { if (!this.hass) {
return nothing; return nothing;
@@ -406,6 +420,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
class="ha-scrollbar" class="ha-scrollbar"
@focusin=${this._listboxFocusIn} @focusin=${this._listboxFocusIn}
@focusout=${this._listboxFocusOut} @focusout=${this._listboxFocusOut}
@touchend=${this._listboxTouchend}
@scroll=${this._listboxScroll} @scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown} @keydown=${this._listboxKeydown}
> >
@@ -620,6 +635,14 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this._hideTooltip(); this._hideTooltip();
} }
private _listboxTouchend() {
clearTimeout(this._touchendTimeout);
this._touchendTimeout = window.setTimeout(() => {
// Allow 1 second for users to read the tooltip on touch devices
this._hideTooltip();
}, 1000);
}
@eventOptions({ @eventOptions({
passive: true, passive: true,
}) })

View File

@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { subscribeLabFeatures } from "../data/labs"; import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
interface Snowflake { interface Snowflake {
@@ -10,7 +10,7 @@ interface Snowflake {
size: number; size: number;
duration: number; duration: number;
delay: number; delay: number;
blur: number; rotation: number;
} }
@customElement("ha-snowflakes") @customElement("ha-snowflakes")
@@ -27,13 +27,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
public hassSubscribe() { public hassSubscribe() {
return [ return [
subscribeLabFeatures(this.hass!.connection, (features) => { subscribeLabFeature(
this._enabled = this.hass!.connection,
features.find( "frontend",
(f) => "winter_mode",
f.domain === "frontend" && f.preview_feature === "winter_mode" (feature) => {
)?.enabled ?? false; this._enabled = feature.enabled;
}), }
),
]; ];
} }
@@ -51,7 +52,7 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
size: Math.random() * 12 + 8, // Random size between 8-20px size: Math.random() * 12 + 8, // Random size between 8-20px
duration: Math.random() * 8 + 8, // Random duration between 8-16s duration: Math.random() * 8 + 8, // Random duration between 8-16s
delay: Math.random() * 8, // Random delay between 0-8s delay: Math.random() * 8, // Random delay between 0-8s
blur: Math.random() * 1, // Random blur between 0-1px rotation: Math.random() * 720 - 360, // Random starting rotation -360 to 360deg
}); });
} }
this._snowflakes = snowflakes; this._snowflakes = snowflakes;
@@ -75,20 +76,27 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
<div class="snowflakes ${isDark ? "dark" : "light"}" aria-hidden="true"> <div class="snowflakes ${isDark ? "dark" : "light"}" aria-hidden="true">
${this._snowflakes.map( ${this._snowflakes.map(
(flake) => html` (flake) => html`
<div <svg
class="snowflake ${this.narrow && flake.id >= 30 class="snowflake ${this.narrow && flake.id >= 30
? "hide-narrow" ? "hide-narrow"
: ""}" : ""}"
style=" style="
left: ${flake.left}%; left: ${flake.left}%;
font-size: ${flake.size}px; width: ${flake.size}px;
height: ${flake.size}px;
animation-duration: ${flake.duration}s; animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s; animation-delay: ${flake.delay}s;
filter: blur(${flake.blur}px); --rotation: ${flake.rotation}deg;
" "
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
> >
<path
</div> d="M7.991 0a.644.644 0 0 1 .283 1.221v2.553l.986-.988a.645.645 0 0 1 .612-.839.644.644 0 1 1-.222 1.247l-1.376 1.38V7.52l1.65-.954.466-1.879a.645.645 0 0 1 .1-1.042.643.643 0 1 1 .445 1.189l-.363 1.356 3.145-1.82a.643.643 0 1 1 .282.49l-2.205 1.277 1.347.361a.643.643 0 1 1-.158.543l-1.88-.505L8.573 8l1.632.945 1.858-.535a.64.64 0 0 1 .95-.434.643.643 0 1 1-.805.98l-1.354.364L14 11.14a.641.641 0 0 1 .914.855.643.643 0 0 1-1.197-.366l-2.205-1.276.36 1.35a.642.642 0 0 1 .419.95.643.643 0 1 1-.967-.816l-.503-1.884L8.273 8.48v1.909l1.39 1.344a.644.644 0 1 1 .208 1.252.644.644 0 0 1-.606-.852l-.991-.994v3.64A.644.644 0 0 1 7.99 16a.644.644 0 0 1-.282-1.221v-2.553l-.986.988a.645.645 0 0 1-.612.839.644.644 0 1 1 .222-1.247l1.376-1.38V8.5l-1.632.945-.467 1.879q.079.068.134.163a.643.643 0 1 1-.68-.31l.364-1.357-3.145 1.82A.643.643 0 1 1 2 11.15l2.205-1.276-1.347-.361a.643.643 0 1 1 .158-.543l1.88.505L7.444 8l-1.65-.954-1.857.534a.64.64 0 0 1-.95.434.643.643 0 1 1 .805-.98l1.354-.364L2 4.85a.641.641 0 0 1-.914-.855.643.643 0 0 1 1.197.366l2.205 1.276-.36-1.35a.642.642 0 0 1-.419-.95.643.643 0 1 1 .967.816l.503 1.884L7.71 7.5V5.611L6.32 4.267a.644.644 0 1 1-.208-1.252.644.644 0 0 1 .607.852l.991.994V1.22A.644.644 0 0 1 7.991 0"
fill="currentColor"
/>
</svg>
` `
)} )}
</div> </div>
@@ -128,16 +136,10 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
.light .snowflake { .light .snowflake {
color: #00bcd4; color: #00bcd4;
text-shadow:
0 0 5px #00bcd4,
0 0 10px #00e5ff;
} }
.dark .snowflake { .dark .snowflake {
color: #fff; color: #fff;
text-shadow:
0 0 5px rgba(255, 255, 255, 0.8),
0 0 10px rgba(255, 255, 255, 0.5);
} }
.snowflake.hide-narrow { .snowflake.hide-narrow {
@@ -146,19 +148,23 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
@keyframes fall { @keyframes fall {
0% { 0% {
transform: translateY(-10vh) translateX(0); transform: translateY(-10vh) translateX(0) rotate(var(--rotation));
} }
25% { 25% {
transform: translateY(30vh) translateX(10px); transform: translateY(30vh) translateX(10px)
rotate(calc(var(--rotation) + 25deg));
} }
50% { 50% {
transform: translateY(60vh) translateX(-10px); transform: translateY(60vh) translateX(-10px)
rotate(calc(var(--rotation) + 50deg));
} }
75% { 75% {
transform: translateY(85vh) translateX(10px); transform: translateY(85vh) translateX(10px)
rotate(calc(var(--rotation) + 75deg));
} }
100% { 100% {
transform: translateY(120vh) translateX(0); transform: translateY(120vh) translateX(0)
rotate(calc(var(--rotation) + 100deg));
} }
} }

View File

@@ -6,7 +6,6 @@ import {
mdiDevices, mdiDevices,
mdiFormatListBulleted, mdiFormatListBulleted,
mdiGestureDoubleTap, mdiGestureDoubleTap,
mdiHomeAssistant,
mdiMapMarker, mdiMapMarker,
mdiMapMarkerRadius, mdiMapMarkerRadius,
mdiMessageAlert, mdiMessageAlert,
@@ -23,6 +22,7 @@ import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until"; import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons"; import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-icon"; import "./ha-icon";
import "./ha-svg-icon"; import "./ha-svg-icon";

View File

@@ -1,3 +1,5 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { import {
customElement, customElement,
@@ -7,8 +9,6 @@ import {
state, state,
} from "lit/decorators"; } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { mdiClose } from "@mdi/js";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -49,10 +49,10 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @cssprop --ha-dialog-surface-background - Dialog background color. * @cssprop --ha-dialog-surface-background - Dialog background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface. * @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
* @cssprop --dialog-z-index - Z-index for the dialog. * @cssprop --dialog-z-index - Z-index for the dialog.
* @cssprop --dialog-surface-position - CSS position of the dialog surface.
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface. * @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
* *
* @attr {boolean} open - Controls the dialog open state. * @attr {boolean} open - Controls the dialog open state.
* @attr {("alert"|"standard")} type - Dialog type. Defaults to "standard".
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium". * @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false. * @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used. * @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
@@ -84,6 +84,9 @@ export class HaWaDialog extends LitElement {
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
public open = false; public open = false;
@property({ reflect: true })
public type: "alert" | "standard" = "standard";
@property({ type: String, reflect: true, attribute: "width" }) @property({ type: String, reflect: true, attribute: "width" })
public width: DialogWidth = "medium"; public width: DialogWidth = "medium";
@@ -172,7 +175,9 @@ export class HaWaDialog extends LitElement {
await this.updateComplete; await this.updateComplete;
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); requestAnimationFrame(() => {
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
}; };
private _handleAfterShow = () => { private _handleAfterShow = () => {
@@ -198,18 +203,7 @@ export class HaWaDialog extends LitElement {
haStyleScrollbar, haStyleScrollbar,
css` css`
wa-dialog { wa-dialog {
--full-width: var( --full-width: var(--ha-dialog-width-full, min(95vw, var(--safe-width)));
--ha-dialog-width-full,
min(
95vw,
calc(
100vw - var(--safe-area-inset-left, var(--ha-space-0)) - var(
--safe-area-inset-right,
var(--ha-space-0)
)
)
)
);
--width: min(var(--ha-dialog-width-md, 580px), var(--full-width)); --width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
--spacing: var(--dialog-content-padding, var(--ha-space-6)); --spacing: var(--dialog-content-padding, var(--ha-space-6));
--show-duration: var(--ha-dialog-show-duration, 200ms); --show-duration: var(--ha-dialog-show-duration, 200ms);
@@ -226,8 +220,7 @@ export class HaWaDialog extends LitElement {
--ha-dialog-border-radius, --ha-dialog-border-radius,
var(--ha-border-radius-3xl) var(--ha-border-radius-3xl)
); );
max-width: var(--ha-dialog-max-width, 100vw); max-width: var(--ha-dialog-max-width, var(--safe-width));
max-width: var(--ha-dialog-max-width, 100svw);
} }
:host([width="small"]) wa-dialog { :host([width="small"]) wa-dialog {
@@ -247,34 +240,56 @@ export class HaWaDialog extends LitElement {
max-width: var(--width, var(--full-width)); max-width: var(--width, var(--full-width));
max-height: var( max-height: var(
--ha-dialog-max-height, --ha-dialog-max-height,
calc(100% - var(--ha-space-20)) calc(var(--safe-height) - var(--ha-space-20))
); );
min-height: var(--ha-dialog-min-height); min-height: var(--ha-dialog-min-height);
position: var(--dialog-surface-position, relative);
margin-top: var(--dialog-surface-margin-top, auto); margin-top: var(--dialog-surface-margin-top, auto);
/* Used to offset the dialog from the safe areas when space is limited */
transform: translate(
calc(
var(--safe-area-offset-left, var(--ha-space-0)) - var(
--safe-area-offset-right,
var(--ha-space-0)
)
),
calc(
var(--safe-area-offset-top, var(--ha-space-0)) - var(
--safe-area-offset-bottom,
var(--ha-space-0)
)
)
);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
:host { :host([type="standard"]) {
--ha-dialog-border-radius: var(--ha-space-0); --ha-dialog-border-radius: var(--ha-space-0);
}
wa-dialog { wa-dialog {
--full-width: var(--ha-dialog-width-full, 100vw); /* Make the container fill the whole screen width and not the safe width */
} --full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
wa-dialog::part(dialog) { wa-dialog::part(dialog) {
min-height: var(--ha-dialog-min-height, 100vh); /* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100svh); min-height: var(--ha-dialog-min-height, 100vh);
max-height: var(--ha-dialog-max-height, 100vh); min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100svh); max-height: var(--ha-dialog-max-height, 100vh);
padding-top: var(--safe-area-inset-top, var(--ha-space-0)); max-height: var(--ha-dialog-max-height, 100dvh);
padding-bottom: var(--safe-area-inset-bottom, var(--ha-space-0)); margin-top: 0;
padding-left: var(--safe-area-inset-left, var(--ha-space-0)); margin-bottom: 0;
padding-right: var(--safe-area-inset-right, var(--ha-space-0)); /* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
} }
} }

View File

@@ -223,6 +223,7 @@ const getAreasAndFloorsItems = (
} }
let outputAreas = areas; let outputAreas = areas;
let outputFloors = floors;
let areaIds: string[] | undefined; let areaIds: string[] | undefined;
@@ -254,9 +255,29 @@ const getAreasAndFloorsItems = (
outputAreas = outputAreas.filter( outputAreas = outputAreas.filter(
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id) (area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
); );
outputFloors = outputFloors.filter(
(floor) => !excludeFloors!.includes(floor.floor_id)
);
} }
const hierarchy = getAreasFloorHierarchy(floors, outputAreas); if (
entityFilter ||
deviceFilter ||
includeDomains ||
excludeDomains ||
includeDeviceClasses
) {
// Ensure we only include floors that have areas with the filtered entities/devices
const validFloorIds = new Set(
outputAreas.map((area) => area.floor_id).filter((id) => id)
);
outputFloors = outputFloors.filter((floor) =>
validFloorIds.has(floor.floor_id)
);
}
const hierarchy = getAreasFloorHierarchy(outputFloors, outputAreas);
const items: ( const items: (
| FloorComboBoxItem | FloorComboBoxItem

View File

@@ -1,4 +1,3 @@
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { DeviceRegistryEntry } from "./device_registry"; import type { DeviceRegistryEntry } from "./device_registry";
import type { import type {
@@ -75,17 +74,11 @@ export const reorderAreaRegistryEntries = (
}); });
export const getAreaEntityLookup = ( export const getAreaEntityLookup = (
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[], entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[]
filterHidden = false
): AreaEntityLookup => { ): AreaEntityLookup => {
const areaEntityLookup: AreaEntityLookup = {}; const areaEntityLookup: AreaEntityLookup = {};
for (const entity of entities) { for (const entity of entities) {
if ( if (!entity.area_id) {
!entity.area_id ||
(filterHidden &&
((entity as EntityRegistryDisplayEntry).hidden ||
(entity as EntityRegistryEntry).hidden_by))
) {
continue; continue;
} }
if (!(entity.area_id in areaEntityLookup)) { if (!(entity.area_id in areaEntityLookup)) {
@@ -111,22 +104,3 @@ export const getAreaDeviceLookup = (
} }
return areaDeviceLookup; return areaDeviceLookup;
}; };
export const areaCompare =
(entries?: HomeAssistant["areas"], order?: string[]) =>
(a: string, b: string) => {
const indexA = order ? order.indexOf(a) : -1;
const indexB = order ? order.indexOf(b) : -1;
if (indexA === -1 && indexB === -1) {
const nameA = entries?.[a]?.name ?? a;
const nameB = entries?.[b]?.name ?? b;
return stringCompare(nameA, nameB);
}
if (indexA === -1) {
return 1;
}
if (indexB === -1) {
return -1;
}
return indexA - indexB;
};

View File

@@ -144,9 +144,7 @@ const tryDescribeTrigger = (
const type = getTriggerObjectId(trigger.trigger); const type = getTriggerObjectId(trigger.trigger);
return ( return (
hass.localize( hass.localize(`component.${domain}.triggers.${type}.name`) ||
`component.${domain}.triggers.${type}.description_configured`
) ||
hass.localize( hass.localize(
`ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label` `ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label`
) || ) ||
@@ -919,9 +917,7 @@ const tryDescribeCondition = (
const type = getConditionObjectId(condition.condition); const type = getConditionObjectId(condition.condition);
return ( return (
hass.localize( hass.localize(`component.${domain}.conditions.${type}.name`) ||
`component.${domain}.conditions.${type}.description_configured`
) ||
hass.localize( hass.localize(
`ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label` `ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label`
) || ) ||

View File

@@ -111,17 +111,11 @@ export const sortDeviceRegistryByName = (
); );
export const getDeviceEntityLookup = ( export const getDeviceEntityLookup = (
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[], entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[]
filterHidden = false
): DeviceEntityLookup => { ): DeviceEntityLookup => {
const deviceEntityLookup: DeviceEntityLookup = {}; const deviceEntityLookup: DeviceEntityLookup = {};
for (const entity of entities) { for (const entity of entities) {
if ( if (!entity.device_id) {
!entity.device_id ||
(filterHidden &&
((entity as EntityRegistryDisplayEntry).hidden ||
(entity as EntityRegistryEntry).hidden_by))
) {
continue; continue;
} }
if (!(entity.device_id in deviceEntityLookup)) { if (!(entity.device_id in deviceEntityLookup)) {

View File

@@ -11,7 +11,7 @@ import {
isLastDayOfMonth, isLastDayOfMonth,
addYears, addYears,
} from "date-fns"; } from "date-fns";
import type { Collection } from "home-assistant-js-websocket"; import type { Collection, HassEntity } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket"; import { getCollection } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { import {
@@ -1361,3 +1361,37 @@ export const calculateSolarConsumedGauge = (
} }
return undefined; return undefined;
}; };
/**
* Get current power value from entity state, normalized to kW
* @param stateObj - The entity state object to get power value from
* @returns Power value in kW, or 0 if entity not found or invalid
*/
export const getPowerFromState = (stateObj: HassEntity): number | undefined => {
if (!stateObj) {
return undefined;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return undefined;
}
// Normalize to kW based on unit of measurement (case-sensitive)
// Supported units: GW, kW, MW, mW, TW, W
const unit = stateObj.attributes.unit_of_measurement;
switch (unit) {
case "W":
return value / 1000;
case "mW":
return value / 1000000;
case "MW":
return value * 1000;
case "GW":
return value * 1000000;
case "TW":
return value * 1000000000;
default:
// Assume kW if no unit or unit is kW
return value;
}
};

View File

@@ -1,4 +1,3 @@
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry"; import type { AreaRegistryEntry } from "./area_registry";
import type { RegistryEntry } from "./registry"; import type { RegistryEntry } from "./registry";
@@ -75,27 +74,3 @@ export const getFloorAreaLookup = (
} }
return floorAreaLookup; return floorAreaLookup;
}; };
export const floorCompare =
(entries?: HomeAssistant["floors"], order?: string[]) =>
(a: string, b: string) => {
const indexA = order ? order.indexOf(a) : -1;
const indexB = order ? order.indexOf(b) : -1;
if (indexA === -1 && indexB === -1) {
const floorA = entries?.[a];
const floorB = entries?.[b];
if (floorA && floorB && floorA.level !== floorB.level) {
return (floorB.level ?? -9999) - (floorA.level ?? -9999);
}
const nameA = floorA?.name ?? a;
const nameB = floorB?.name ?? b;
return stringCompare(nameA, nameB);
}
if (indexA === -1) {
return 1;
}
if (indexB === -1) {
return -1;
}
return indexA - indexB;
};

View File

@@ -18,6 +18,11 @@ export interface LabPreviewFeaturesResponse {
features: LabPreviewFeature[]; features: LabPreviewFeature[];
} }
/**
* Fetch all lab features
* @param hass - The Home Assistant instance
* @returns A promise to fetch the lab features
*/
export const fetchLabFeatures = async ( export const fetchLabFeatures = async (
hass: HomeAssistant hass: HomeAssistant
): Promise<LabPreviewFeature[]> => { ): Promise<LabPreviewFeature[]> => {
@@ -27,6 +32,15 @@ export const fetchLabFeatures = async (
return response.features; return response.features;
}; };
/**
* Update a specific lab feature
* @param hass - The Home Assistant instance
* @param domain - The domain of the lab feature
* @param preview_feature - The preview feature of the lab feature
* @param enabled - Whether the lab feature is enabled
* @param create_backup - Whether to create a backup of the lab feature
* @returns A promise to update the lab feature
*/
export const labsUpdatePreviewFeature = ( export const labsUpdatePreviewFeature = (
hass: HomeAssistant, hass: HomeAssistant,
domain: string, domain: string,
@@ -65,6 +79,12 @@ const subscribeLabUpdates = (
"labs_updated" "labs_updated"
); );
/**
* Subscribe to a collection of lab features
* @param conn - The connection to the Home Assistant instance
* @param onChange - The function to call when the lab features change
* @returns The unsubscribe function
*/
export const subscribeLabFeatures = ( export const subscribeLabFeatures = (
conn: Connection, conn: Connection,
onChange: (features: LabPreviewFeature[]) => void onChange: (features: LabPreviewFeature[]) => void
@@ -76,3 +96,23 @@ export const subscribeLabFeatures = (
conn, conn,
onChange onChange
); );
/**
* Subscribe to a specific lab feature
* @param conn - The connection to the Home Assistant instance
* @param domain - The domain of the lab feature
* @param previewFeature - The preview feature identifier
* @param onChange - The function to call when the lab feature changes
* @returns A promise that resolves to the unsubscribe function
*/
export const subscribeLabFeature = (
conn: Connection,
domain: string,
previewFeature: string,
onChange: (feature: LabPreviewFeature) => void
): Promise<() => void> =>
conn.subscribeMessage<LabPreviewFeature>(onChange, {
type: "labs/subscribe",
domain,
preview_feature: previewFeature,
});

View File

@@ -220,12 +220,12 @@ const tryDescribeAction = <T extends ActionType>(
if (config.action) { if (config.action) {
const [domain, serviceName] = config.action.split(".", 2); const [domain, serviceName] = config.action.split(".", 2);
const descriptionPlaceholders = const descriptionPlaceholders =
hass.services[domain][serviceName].description_placeholders; hass.services[domain]?.[serviceName]?.description_placeholders;
const service = const service =
hass.localize( hass.localize(
`component.${domain}.services.${serviceName}.name`, `component.${domain}.services.${serviceName}.name`,
descriptionPlaceholders descriptionPlaceholders
) || hass.services[domain][serviceName]?.name; ) || hass.services[domain]?.[serviceName]?.name;
if (config.metadata) { if (config.metadata) {
return hass.localize( return hass.localize(

View File

@@ -1,6 +1,7 @@
import { mdiAlertOutline, mdiClose } from "@mdi/js"; import { mdiAlertOutline, mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button"; import "../../components/ha-button";
@@ -64,6 +65,7 @@ class DialogBox extends LitElement {
<ha-wa-dialog <ha-wa-dialog
.hass=${this.hass} .hass=${this.hass}
.open=${this._open} .open=${this._open}
type=${confirmPrompt ? "alert" : "standard"}
?prevent-scrim-close=${confirmPrompt} ?prevent-scrim-close=${confirmPrompt}
@closed=${this._dialogClosed} @closed=${this._dialogClosed}
aria-labelledby="dialog-box-title" aria-labelledby="dialog-box-title"
@@ -79,7 +81,11 @@ class DialogBox extends LitElement {
></ha-icon-button ></ha-icon-button
></slot>` ></slot>`
: nothing} : nothing}
<span slot="title" id="dialog-box-title"> <span
class=${classMap({ title: true, alert: confirmPrompt })}
slot="title"
id="dialog-box-title"
>
${this._params.warning ${this._params.warning
? html`<ha-svg-icon ? html`<ha-svg-icon
.path=${mdiAlertOutline} .path=${mdiAlertOutline}
@@ -199,6 +205,14 @@ class DialogBox extends LitElement {
ha-textfield { ha-textfield {
width: 100%; width: 100%;
} }
.title.alert {
padding: 0 var(--ha-space-2);
}
@media all and (min-width: 450px) and (min-height: 500px) {
.title.alert {
padding: 0 var(--ha-space-1);
}
}
`; `;
} }

View File

@@ -1,9 +1,9 @@
import type { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import { ancestorsWithProperty } from "../common/dom/ancestors-with-property"; import { ancestorsWithProperty } from "../common/dom/ancestors-with-property";
import { deepActiveElement } from "../common/dom/deep-active-element"; import { deepActiveElement } from "../common/dom/deep-active-element";
import type { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { nextRender } from "../common/util/render-status"; import { nextRender } from "../common/util/render-status";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
declare global { declare global {
// for fire event // for fire event
@@ -22,7 +22,7 @@ declare global {
export interface HassDialog<T = HASSDomEvents[ValidHassDomEvent]> export interface HassDialog<T = HASSDomEvents[ValidHassDomEvent]>
extends HTMLElement { extends HTMLElement {
showDialog(params: T); showDialog(params: T);
closeDialog?: () => boolean; closeDialog?: (historyState?: any) => boolean;
} }
interface ShowDialogParams<T> { interface ShowDialogParams<T> {
@@ -143,27 +143,32 @@ export const showDialog = async (
return true; return true;
}; };
export const closeDialog = async (dialogTag: string): Promise<boolean> => { export const closeDialog = async (
dialogTag: string,
historyState?: any
): Promise<boolean> => {
if (!(dialogTag in LOADED)) { if (!(dialogTag in LOADED)) {
return true; return true;
} }
const dialogElement = await LOADED[dialogTag].element; const dialogElement = await LOADED[dialogTag].element;
if (dialogElement.closeDialog) { if (dialogElement.closeDialog) {
return dialogElement.closeDialog() !== false; return dialogElement.closeDialog(historyState) !== false;
} }
return true; return true;
}; };
// called on back() // called on back()
export const closeLastDialog = async () => { export const closeLastDialog = async (historyState?: any) => {
if (OPEN_DIALOG_STACK.length) { if (OPEN_DIALOG_STACK.length) {
const lastDialog = OPEN_DIALOG_STACK.pop(); const lastDialog = OPEN_DIALOG_STACK.pop() as DialogState;
const closed = await closeDialog(lastDialog!.dialogTag); const closed = await closeDialog(lastDialog.dialogTag, historyState);
if (!closed) { if (!closed) {
// if the dialog was not closed, put it back on the stack // if the dialog was not closed, put it back on the stack
OPEN_DIALOG_STACK.push(lastDialog!); OPEN_DIALOG_STACK.push(lastDialog);
} } else if (
if (OPEN_DIALOG_STACK.length && mainWindow.history.state?.opensDialog) { OPEN_DIALOG_STACK.length &&
mainWindow.history.state?.opensDialog
) {
// if there are more dialogs open, push a new state so back() will close the next top dialog // if there are more dialogs open, push a new state so back() will close the next top dialog
mainWindow.history.pushState( mainWindow.history.pushState(
{ dialog: OPEN_DIALOG_STACK[OPEN_DIALOG_STACK.length - 1].dialogTag }, { dialog: OPEN_DIALOG_STACK[OPEN_DIALOG_STACK.length - 1].dialogTag },

View File

@@ -302,7 +302,9 @@ export class MoreInfoDialog extends LitElement {
} }
private _goToAddEntityTo(ev) { private _goToAddEntityTo(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) return; // Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
return;
this._setView("add_to"); this._setView("add_to");
} }
@@ -550,7 +552,18 @@ export class MoreInfoDialog extends LitElement {
: nothing} : nothing}
</ha-button-menu> </ha-button-menu>
` `
: nothing} : !__DEMO__ && this._shouldShowAddEntityTo()
? html`
<ha-icon-button
slot="actionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.add_entity_to"
)}
.path=${mdiPlusBoxMultipleOutline}
@click=${this._goToAddEntityTo}
></ha-icon-button>
`
: nothing}
` `
: isSpecificInitialView : isSpecificInitialView
? html` ? html`

View File

@@ -143,7 +143,6 @@ class HassSubpage extends LitElement {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding-bottom: 1px;
} }
.content { .content {

View File

@@ -13,6 +13,7 @@ import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strateg
import type { Lovelace } from "../lovelace/types"; import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container"; import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = { const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: { strategy: {
@@ -115,6 +116,7 @@ class PanelClimate extends LitElement {
this._lovelace this._lovelace
? html` ? html`
<hui-view-container .hass=${this.hass}> <hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view <hui-view
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}

View File

@@ -0,0 +1,496 @@
import { mdiClose, mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import {
type AreasFloorHierarchy,
getAreasFloorHierarchy,
getAreasOrder,
getFloorOrder,
} from "../../../common/areas/areas-floor-hierarchy";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-dialog-header";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import {
reorderAreaRegistryEntries,
updateAreaRegistryEntry,
} from "../../../data/area_registry";
import { reorderFloorRegistryEntries } from "../../../data/floor_registry";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import type { AreasFloorsOrderDialogParams } from "./show-dialog-areas-floors-order";
const UNASSIGNED_FLOOR = "__unassigned__";
interface FloorChange {
areaId: string;
floorId: string | null;
}
@customElement("dialog-areas-floors-order")
class DialogAreasFloorsOrder extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _hierarchy?: AreasFloorHierarchy;
@state() private _saving = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(
_params: AreasFloorsOrderDialogParams
): Promise<void> {
this._open = true;
this._computeHierarchy();
}
private _computeHierarchy(): void {
this._hierarchy = getAreasFloorHierarchy(
Object.values(this.hass.floors),
Object.values(this.hass.areas)
);
}
public closeDialog(): void {
this._dialog?.close();
}
private _dialogClosed(): void {
this._open = false;
this._hierarchy = undefined;
this._saving = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._open || !this._hierarchy) {
return nothing;
}
const hasFloors = this._hierarchy.floors.length > 0;
const dialogTitle = this.hass.localize(
hasFloors
? "ui.panel.config.areas.dialog.reorder_floors_areas_title"
: "ui.panel.config.areas.dialog.reorder_areas_title"
);
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
</ha-dialog-header>
<div slot="content" class="content">
<ha-sortable
handle-selector=".floor-handle"
draggable-selector=".floor"
@item-moved=${this._floorMoved}
invert-swap
>
<div class="floors">
${repeat(
this._hierarchy.floors,
(floor) => floor.id,
(floor) => this._renderFloor(floor)
)}
</div>
</ha-sortable>
${this._renderUnassignedAreas()}
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog} appearance="plain">
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._save} .disabled=${this._saving}>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _renderFloor(floor: { id: string; areas: string[] }) {
const floorEntry = this.hass.floors[floor.id];
if (!floorEntry) {
return nothing;
}
return html`
<div class="floor">
<div class="floor-header">
<ha-floor-icon .floor=${floorEntry}></ha-floor-icon>
<span class="floor-name">${floorEntry.name}</span>
<ha-svg-icon
class="floor-handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<ha-sortable
handle-selector=".area-handle"
draggable-selector="ha-md-list-item"
@item-moved=${this._areaMoved}
@item-added=${this._areaAdded}
group="areas"
.floor=${floor.id}
>
<ha-md-list>
${floor.areas.length > 0
? floor.areas.map((areaId) => this._renderArea(areaId))
: html`<p class="empty">
${this.hass.localize(
"ui.panel.config.areas.dialog.empty_floor"
)}
</p>`}
</ha-md-list>
</ha-sortable>
</div>
`;
}
private _renderUnassignedAreas() {
const hasFloors = this._hierarchy!.floors.length > 0;
return html`
<div class="floor unassigned">
${hasFloors
? html`<div class="floor-header">
<span class="floor-name">
${this.hass.localize(
"ui.panel.config.areas.dialog.other_areas"
)}
</span>
</div>`
: nothing}
<ha-sortable
handle-selector=".area-handle"
draggable-selector="ha-md-list-item"
@item-moved=${this._areaMoved}
@item-added=${this._areaAdded}
group="areas"
.floor=${UNASSIGNED_FLOOR}
>
<ha-md-list>
${this._hierarchy!.areas.length > 0
? this._hierarchy!.areas.map((areaId) => this._renderArea(areaId))
: html`<p class="empty">
${this.hass.localize(
"ui.panel.config.areas.dialog.empty_unassigned"
)}
</p>`}
</ha-md-list>
</ha-sortable>
</div>
`;
}
private _renderArea(areaId: string) {
const area = this.hass.areas[areaId];
if (!area) {
return nothing;
}
return html`
<ha-md-list-item .sortableData=${area}>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<span slot="headline">${area.name}</span>
<ha-svg-icon
class="area-handle"
slot="end"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-md-list-item>
`;
}
private _floorMoved(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._hierarchy) {
return;
}
const { oldIndex, newIndex } = ev.detail;
const newFloors = [...this._hierarchy.floors];
const [movedFloor] = newFloors.splice(oldIndex, 1);
newFloors.splice(newIndex, 0, movedFloor);
this._hierarchy = {
...this._hierarchy,
floors: newFloors,
};
}
private _areaMoved(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._hierarchy) {
return;
}
const { floor } = ev.currentTarget as HTMLElement & { floor: string };
const { oldIndex, newIndex } = ev.detail;
const floorId = floor === UNASSIGNED_FLOOR ? null : floor;
if (floorId === null) {
// Reorder unassigned areas
const newAreas = [...this._hierarchy.areas];
const [movedArea] = newAreas.splice(oldIndex, 1);
newAreas.splice(newIndex, 0, movedArea);
this._hierarchy = {
...this._hierarchy,
areas: newAreas,
};
} else {
// Reorder areas within a floor
this._hierarchy = {
...this._hierarchy,
floors: this._hierarchy.floors.map((f) => {
if (f.id === floorId) {
const newAreas = [...f.areas];
const [movedArea] = newAreas.splice(oldIndex, 1);
newAreas.splice(newIndex, 0, movedArea);
return { ...f, areas: newAreas };
}
return f;
}),
};
}
}
private _areaAdded(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._hierarchy) {
return;
}
const { floor } = ev.currentTarget as HTMLElement & { floor: string };
const { data: area, index } = ev.detail as {
data: AreaRegistryEntry;
index: number;
};
const newFloorId = floor === UNASSIGNED_FLOOR ? null : floor;
// Update hierarchy
const newUnassignedAreas = this._hierarchy.areas.filter(
(id) => id !== area.area_id
);
if (newFloorId === null) {
// Add to unassigned at the specified index
newUnassignedAreas.splice(index, 0, area.area_id);
}
this._hierarchy = {
...this._hierarchy,
floors: this._hierarchy.floors.map((f) => {
if (f.id === newFloorId) {
// Add to new floor at the specified index
const newAreas = [...f.areas];
newAreas.splice(index, 0, area.area_id);
return { ...f, areas: newAreas };
}
// Remove from old floor
return {
...f,
areas: f.areas.filter((id) => id !== area.area_id),
};
}),
areas: newUnassignedAreas,
};
}
private _computeFloorChanges(): FloorChange[] {
if (!this._hierarchy) {
return [];
}
const changes: FloorChange[] = [];
// Check areas assigned to floors
for (const floor of this._hierarchy.floors) {
for (const areaId of floor.areas) {
const originalFloorId = this.hass.areas[areaId]?.floor_id ?? null;
if (floor.id !== originalFloorId) {
changes.push({ areaId, floorId: floor.id });
}
}
}
// Check unassigned areas
for (const areaId of this._hierarchy.areas) {
const originalFloorId = this.hass.areas[areaId]?.floor_id ?? null;
if (originalFloorId !== null) {
changes.push({ areaId, floorId: null });
}
}
return changes;
}
private async _save(): Promise<void> {
if (!this._hierarchy || this._saving) {
return;
}
this._saving = true;
try {
const areaOrder = getAreasOrder(this._hierarchy);
const floorOrder = getFloorOrder(this._hierarchy);
// Update floor assignments for areas that changed floors
const floorChanges = this._computeFloorChanges();
const floorChangePromises = floorChanges.map(({ areaId, floorId }) =>
updateAreaRegistryEntry(this.hass, areaId, {
floor_id: floorId,
})
);
await Promise.all(floorChangePromises);
// Reorder areas and floors
await reorderAreaRegistryEntries(this.hass, areaOrder);
await reorderFloorRegistryEntries(this.hass, floorOrder);
this.closeDialog();
} catch (err: any) {
showToast(this, {
message:
err.message ||
this.hass.localize("ui.panel.config.areas.dialog.reorder_failed"),
});
this._saving = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-md-dialog {
min-width: 600px;
max-height: 90%;
--dialog-content-padding: 8px 24px;
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: 0;
min-width: 100%;
min-height: 100%;
}
}
.floors {
display: flex;
flex-direction: column;
gap: 16px;
}
.floor {
border: 1px solid var(--divider-color);
border-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
overflow: hidden;
}
.floor.unassigned {
margin-top: 16px;
}
.floor-header {
display: flex;
align-items: center;
padding: 12px 16px;
background-color: var(--secondary-background-color);
gap: 12px;
}
.floor-name {
flex: 1;
font-weight: var(--ha-font-weight-medium);
}
.floor-handle {
cursor: grab;
color: var(--secondary-text-color);
}
ha-md-list {
padding: 0;
--md-list-item-leading-space: 16px;
--md-list-item-trailing-space: 16px;
display: flex;
flex-direction: column;
}
ha-md-list-item {
--md-list-item-one-line-container-height: 48px;
--md-list-item-container-shape: 0;
}
ha-md-list-item.sortable-ghost {
border-radius: calc(
var(--ha-card-border-radius, var(--ha-border-radius-lg)) - 1px
);
box-shadow: inset 0 0 0 2px var(--primary-color);
}
.area-handle {
cursor: grab;
color: var(--secondary-text-color);
}
.empty {
text-align: center;
color: var(--secondary-text-color);
font-style: italic;
margin: 0;
padding: 12px 16px;
order: 1;
}
ha-md-list:has(ha-md-list-item) .empty {
display: none;
}
.content {
padding-top: 16px;
padding-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-areas-floors-order": DialogAreasFloorsOrder;
}
}

View File

@@ -144,6 +144,10 @@ class DialogFloorDetail extends LitElement {
"ui.panel.config.floors.editor.level" "ui.panel.config.floors.editor.level"
)} )}
type="number" type="number"
.helper=${this.hass.localize(
"ui.panel.config.floors.editor.level_helper"
)}
helperPersistent
></ha-textfield> ></ha-textfield>
<ha-icon-picker <ha-icon-picker

View File

@@ -2,10 +2,10 @@ import type { ActionDetail } from "@material/mwc-list";
import { import {
mdiDelete, mdiDelete,
mdiDotsVertical, mdiDotsVertical,
mdiDragHorizontalVariant,
mdiHelpCircle, mdiHelpCircle,
mdiPencil, mdiPencil,
mdiPlus, mdiPlus,
mdiSort,
} from "@mdi/js"; } from "@mdi/js";
import { import {
css, css,
@@ -21,7 +21,6 @@ import memoizeOne from "memoize-one";
import { import {
getAreasFloorHierarchy, getAreasFloorHierarchy,
getAreasOrder, getAreasOrder,
getFloorOrder,
type AreasFloorHierarchy, type AreasFloorHierarchy,
} from "../../../common/areas/areas-floor-hierarchy"; } from "../../../common/areas/areas-floor-hierarchy";
import { formatListWithAnds } from "../../../common/string/format-list"; import { formatListWithAnds } from "../../../common/string/format-list";
@@ -42,7 +41,6 @@ import type { FloorRegistryEntry } from "../../../data/floor_registry";
import { import {
createFloorRegistryEntry, createFloorRegistryEntry,
deleteFloorRegistryEntry, deleteFloorRegistryEntry,
reorderFloorRegistryEntries,
updateFloorRegistryEntry, updateFloorRegistryEntry,
} from "../../../data/floor_registry"; } from "../../../data/floor_registry";
import { import {
@@ -58,6 +56,7 @@ import {
loadAreaRegistryDetailDialog, loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog, showAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail"; } from "./show-dialog-area-registry-detail";
import { showAreasFloorsOrderDialog } from "./show-dialog-areas-floors-order";
import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-detail"; import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-detail";
const UNASSIGNED_FLOOR = "__unassigned__"; const UNASSIGNED_FLOOR = "__unassigned__";
@@ -84,6 +83,8 @@ export class HaConfigAreasDashboard extends LitElement {
@property({ attribute: false }) public route!: Route; @property({ attribute: false }) public route!: Route;
private _searchParms = new URLSearchParams(window.location.search);
@state() private _hierarchy?: AreasFloorHierarchy; @state() private _hierarchy?: AreasFloorHierarchy;
private _blockHierarchyUpdate = false; private _blockHierarchyUpdate = false;
@@ -167,7 +168,9 @@ export class HaConfigAreasDashboard extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
.isWide=${this.isWide} .isWide=${this.isWide}
back-path="/config" .backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
.tabs=${configSections.areas} .tabs=${configSections.areas}
.route=${this.route} .route=${this.route}
has-fab has-fab
@@ -179,87 +182,84 @@ export class HaConfigAreasDashboard extends LitElement {
@click=${this._showHelp} @click=${this._showHelp}
></ha-icon-button> ></ha-icon-button>
<div class="container"> <div class="container">
<ha-sortable <div class="floors">
handle-selector=".handle" ${this._hierarchy.floors.map(({ areas, id }) => {
draggable-selector=".floor" const floor = this.hass.floors[id];
@item-moved=${this._floorMoved} if (!floor) {
.options=${SORT_OPTIONS} return nothing;
group="floors" }
invert-swap return html`
> <div class="floor">
<div class="floors"> <div class="header">
${this._hierarchy.floors.map(({ areas, id }) => { <h2>
const floor = this.hass.floors[id]; <ha-floor-icon .floor=${floor}></ha-floor-icon>
if (!floor) { ${floor.name}
return nothing; </h2>
} <div class="actions">
return html` <ha-button-menu
<div class="floor"> .floor=${floor}
<div class="header"> @action=${this._handleFloorAction}
<h2> >
<ha-floor-icon .floor=${floor}></ha-floor-icon> <ha-icon-button
${floor.name} slot="trigger"
</h2> .path=${mdiDotsVertical}
<div class="actions"> ></ha-icon-button>
<ha-svg-icon <ha-list-item graphic="icon"
class="handle" ><ha-svg-icon
.path=${mdiDragHorizontalVariant} .path=${mdiSort}
></ha-svg-icon> slot="graphic"
<ha-button-menu ></ha-svg-icon
.floor=${floor} >${this.hass.localize(
@action=${this._handleFloorAction} "ui.panel.config.areas.picker.reorder"
)}</ha-list-item
> >
<ha-icon-button <li divider role="separator"></li>
slot="trigger" <ha-list-item graphic="icon"
.path=${mdiDotsVertical} ><ha-svg-icon
></ha-icon-button> .path=${mdiPencil}
<ha-list-item graphic="icon" slot="graphic"
><ha-svg-icon ></ha-svg-icon
.path=${mdiPencil} >${this.hass.localize(
slot="graphic" "ui.panel.config.areas.picker.floor.edit_floor"
></ha-svg-icon )}</ha-list-item
>${this.hass.localize( >
"ui.panel.config.areas.picker.floor.edit_floor" <ha-list-item class="warning" graphic="icon"
)}</ha-list-item ><ha-svg-icon
> class="warning"
<ha-list-item class="warning" graphic="icon" .path=${mdiDelete}
><ha-svg-icon slot="graphic"
class="warning" ></ha-svg-icon
.path=${mdiDelete} >${this.hass.localize(
slot="graphic" "ui.panel.config.areas.picker.floor.delete_floor"
></ha-svg-icon )}</ha-list-item
>${this.hass.localize( >
"ui.panel.config.areas.picker.floor.delete_floor" </ha-button-menu>
)}</ha-list-item
>
</ha-button-menu>
</div>
</div> </div>
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-added=${this._areaAdded}
@item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS}
.floor=${floor.floor_id}
>
<div class="areas">
${areas.map((areaId) => {
const area = this.hass.areas[areaId];
if (!area) {
return nothing;
}
const stats = areasStats.get(area.area_id);
return this._renderArea(area, stats);
})}
</div>
</ha-sortable>
</div> </div>
`; <ha-sortable
})} handle-selector="a"
</div> draggable-selector="a"
</ha-sortable> @item-added=${this._areaAdded}
@item-moved=${this._areaMoved}
group="areas"
.options=${SORT_OPTIONS}
.floor=${floor.floor_id}
>
<div class="areas">
${areas.map((areaId) => {
const area = this.hass.areas[areaId];
if (!area) {
return nothing;
}
const stats = areasStats.get(area.area_id);
return this._renderArea(area, stats);
})}
</div>
</ha-sortable>
</div>
`;
})}
</div>
${this._hierarchy.areas.length ${this._hierarchy.areas.length
? html` ? html`
@@ -267,9 +267,30 @@ export class HaConfigAreasDashboard extends LitElement {
<div class="header"> <div class="header">
<h2> <h2>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.areas.picker.unassigned_areas" this._hierarchy.floors.length
? "ui.panel.config.areas.picker.other_areas"
: "ui.panel.config.areas.picker.header"
)} )}
</h2> </h2>
<div class="actions">
<ha-button-menu
@action=${this._handleUnassignedAreasAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiSort}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.reorder"
)}</ha-list-item
>
</ha-button-menu>
</div>
</div> </div>
<ha-sortable <ha-sortable
handle-selector="a" handle-selector="a"
@@ -391,51 +412,6 @@ export class HaConfigAreasDashboard extends LitElement {
}); });
} }
private async _floorMoved(ev) {
ev.stopPropagation();
if (!this.hass || !this._hierarchy) {
return;
}
const { oldIndex, newIndex } = ev.detail;
const reorderFloors = (
floors: AreasFloorHierarchy["floors"],
oldIdx: number,
newIdx: number
) => {
const newFloors = [...floors];
const [movedFloor] = newFloors.splice(oldIdx, 1);
newFloors.splice(newIdx, 0, movedFloor);
return newFloors;
};
// Optimistically update UI
this._hierarchy = {
...this._hierarchy,
floors: reorderFloors(this._hierarchy.floors, oldIndex, newIndex),
};
const areaOrder = getAreasOrder(this._hierarchy);
const floorOrder = getFloorOrder(this._hierarchy);
// Block hierarchy updates for 500ms to avoid flickering
// because of multiple async updates
this._blockHierarchyUpdateFor(500);
try {
await reorderAreaRegistryEntries(this.hass, areaOrder);
await reorderFloorRegistryEntries(this.hass, floorOrder);
} catch {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.areas.picker.floor_reorder_failed"
),
});
// Revert on error
this._computeHierarchy();
}
}
private async _areaMoved(ev) { private async _areaMoved(ev) {
ev.stopPropagation(); ev.stopPropagation();
if (!this.hass || !this._hierarchy) { if (!this.hass || !this._hierarchy) {
@@ -561,14 +537,23 @@ export class HaConfigAreasDashboard extends LitElement {
const floor = (ev.currentTarget as any).floor; const floor = (ev.currentTarget as any).floor;
switch (ev.detail.index) { switch (ev.detail.index) {
case 0: case 0:
this._editFloor(floor); this._showReorderDialog();
break; break;
case 1: case 1:
this._editFloor(floor);
break;
case 2:
this._deleteFloor(floor); this._deleteFloor(floor);
break; break;
} }
} }
private _handleUnassignedAreasAction(ev: CustomEvent<ActionDetail>) {
if (ev.detail.index === 0) {
this._showReorderDialog();
}
}
private _createFloor() { private _createFloor() {
this._openFloorDialog(); this._openFloorDialog();
} }
@@ -598,6 +583,10 @@ export class HaConfigAreasDashboard extends LitElement {
this._openAreaDialog(); this._openAreaDialog();
} }
private _showReorderDialog() {
showAreasFloorsOrderDialog(this, {});
}
private _showHelp() { private _showHelp() {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.areas.caption"), title: this.hass.localize("ui.panel.config.areas.caption"),

View File

@@ -0,0 +1,17 @@
import { fireEvent } from "../../../common/dom/fire_event";
export interface AreasFloorsOrderDialogParams {}
export const loadAreasFloorsOrderDialog = () =>
import("./dialog-areas-floors-order");
export const showAreasFloorsOrderDialog = (
element: HTMLElement,
params: AreasFloorsOrderDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-areas-floors-order",
dialogImport: loadAreasFloorsOrderDialog,
dialogParams: params,
});
};

View File

@@ -16,6 +16,7 @@ import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name"; import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../common/entity/compute_device_name"; import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
@@ -96,7 +97,7 @@ import {
fetchIntegrationManifests, fetchIntegrationManifests,
} from "../../../data/integration"; } from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label_registry"; import type { LabelRegistryEntry } from "../../../data/label_registry";
import { subscribeLabFeatures } from "../../../data/labs"; import { subscribeLabFeature } from "../../../data/labs";
import { import {
TARGET_SEPARATOR, TARGET_SEPARATOR,
getConditionsForTarget, getConditionsForTarget,
@@ -118,7 +119,6 @@ import type { HomeAssistant } from "../../../types";
import { isMac } from "../../../util/is_mac"; import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast"; import { showToast } from "../../../util/toast";
import "./add-automation-element/ha-automation-add-from-target"; import "./add-automation-element/ha-automation-add-from-target";
import type HaAutomationAddFromTarget from "./add-automation-element/ha-automation-add-from-target";
import "./add-automation-element/ha-automation-add-items"; import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search"; import "./add-automation-element/ha-automation-add-search";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog"; import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
@@ -216,10 +216,6 @@ class DialogAddAutomationElement
// #endregion state // #endregion state
// #region queries // #region queries
@query("ha-automation-add-from-target")
private _targetPickerElement?: HaAutomationAddFromTarget;
@query("ha-automation-add-items") @query("ha-automation-add-items")
private _itemsListElement?: HTMLDivElement; private _itemsListElement?: HTMLDivElement;
@@ -232,7 +228,7 @@ class DialogAddAutomationElement
private _unsub?: Promise<UnsubscribeFunc>; private _unsub?: Promise<UnsubscribeFunc>;
private _unsubscribeLabFeatures?: UnsubscribeFunc; private _unsubscribeLabFeatures?: Promise<UnsubscribeFunc>;
private _configEntryLookup: Record<string, ConfigEntry> = {}; private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -285,22 +281,24 @@ class DialogAddAutomationElement
this._fetchManifests(); this._fetchManifests();
this._calculateUsedDomains(); this._calculateUsedDomains();
this._unsubscribeLabFeatures = subscribeLabFeatures( this._unsubscribeLabFeatures = subscribeLabFeature(
this.hass.connection, this.hass.connection,
(features) => { "automation",
this._newTriggersAndConditions = "new_triggers_conditions",
features.find( (feature) => {
(feature) => this._newTriggersAndConditions = feature.enabled;
feature.domain === "automation" && this._tab = this._newTriggersAndConditions ? "targets" : "groups";
feature.preview_feature === "new_triggers_conditions"
)?.enabled ?? false;
this._tab =
this._newTriggersAndConditions && this._params?.type !== "condition"
? "targets"
: "groups";
} }
); );
// add initial dialog view state to history
mainWindow.history.pushState(
{
dialogData: {},
},
""
);
if (this._params?.type === "action") { if (this._params?.type === "action") {
this.hass.loadBackendTranslation("services"); this.hass.loadBackendTranslation("services");
getServiceIcons(this.hass); getServiceIcons(this.hass);
@@ -321,7 +319,41 @@ class DialogAddAutomationElement
this._bottomSheetMode = this._narrow; this._bottomSheetMode = this._narrow;
} }
public closeDialog() { public closeDialog(historyState?: any) {
// prevent closing when come from popstate event and root level isn't active
if (
this._open &&
historyState &&
(this._selectedTarget || this._selectedGroup)
) {
if (historyState.dialogData?.target) {
this._selectedTarget = historyState.dialogData.target;
this._getItemsByTarget();
this._tab = "targets";
return false;
}
if (historyState.dialogData?.group) {
this._selectedCollectionIndex = historyState.dialogData.collectionIndex;
this._selectedGroup = historyState.dialogData.group;
this._tab = "groups";
return false;
}
// return to home on mobile
if (this._narrow) {
this._selectedTarget = undefined;
this._selectedGroup = undefined;
return false;
}
}
// if dialog is closed, but root level isn't active, clean up history state
if (mainWindow.history.state?.dialogData) {
this._open = false;
mainWindow.history.back();
return false;
}
this.removeKeyboardShortcuts(); this.removeKeyboardShortcuts();
this._unsubscribe(); this._unsubscribe();
if (this._params) { if (this._params) {
@@ -390,7 +422,7 @@ class DialogAddAutomationElement
this._unsub = undefined; this._unsub = undefined;
} }
if (this._unsubscribeLabFeatures) { if (this._unsubscribeLabFeatures) {
this._unsubscribeLabFeatures(); this._unsubscribeLabFeatures.then((unsub) => unsub());
this._unsubscribeLabFeatures = undefined; this._unsubscribeLabFeatures = undefined;
} }
} }
@@ -408,7 +440,7 @@ class DialogAddAutomationElement
return html` return html`
<ha-bottom-sheet <ha-bottom-sheet
.open=${this._open} .open=${this._open}
@closed=${this.closeDialog} @closed=${this._handleClosed}
flexcontent flexcontent
> >
${this._renderContent()} ${this._renderContent()}
@@ -420,7 +452,7 @@ class DialogAddAutomationElement
<ha-wa-dialog <ha-wa-dialog
width="large" width="large"
.open=${this._open} .open=${this._open}
@closed=${this.closeDialog} @closed=${this._handleClosed}
flexcontent flexcontent
> >
${this._renderContent()} ${this._renderContent()}
@@ -558,8 +590,7 @@ class DialogAddAutomationElement
interactive interactive
type="button" type="button"
class="paste" class="paste"
.value=${PASTE_VALUE} @click=${this._paste}
@click=${this._selected}
> >
<div class="shortcut-label"> <div class="shortcut-label">
<div class="label"> <div class="label">
@@ -652,6 +683,7 @@ class DialogAddAutomationElement
<ha-automation-add-items <ha-automation-add-items
.hass=${this.hass} .hass=${this.hass}
.items=${this._getItems()} .items=${this._getItems()}
.scrollable=${!this._narrow}
.error=${this._tab === "targets" && this._loadItemsError .error=${this._tab === "targets" && this._loadItemsError
? this.hass.localize( ? this.hass.localize(
"ui.panel.config.automation.editor.load_target_items_failed" "ui.panel.config.automation.editor.load_target_items_failed"
@@ -730,15 +762,26 @@ class DialogAddAutomationElement
); );
if (targetId) { if (targetId) {
if (targetType === "area" && this.hass.areas[targetId]?.floor_id) { if (targetType === "area") {
const floorId = this.hass.areas[targetId].floor_id; const floorId = this.hass.areas[targetId]?.floor_id;
subtitle = computeFloorName(this.hass.floors[floorId]) || floorId; if (floorId) {
} subtitle = computeFloorName(this.hass.floors[floorId]) || floorId;
if (targetType === "device" && this.hass.devices[targetId]?.area_id) { } else {
const areaId = this.hass.devices[targetId].area_id; subtitle = this.hass.localize(
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId; "ui.panel.config.automation.editor.other_areas"
} );
if (targetType === "entity" && this.hass.states[targetId]) { }
} else if (targetType === "device") {
const areaId = this.hass.devices[targetId]?.area_id;
if (areaId) {
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
} else {
const device = this.hass.devices[targetId];
subtitle = this.hass.localize(
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
);
}
} else if (targetType === "entity" && this.hass.states[targetId]) {
const entity = this.hass.entities[targetId]; const entity = this.hass.entities[targetId];
if (entity && !entity.device_id && !entity.area_id) { if (entity && !entity.device_id && !entity.area_id) {
const domain = targetId.split(".", 2)[0]; const domain = targetId.split(".", 2)[0];
@@ -763,10 +806,10 @@ class DialogAddAutomationElement
.join(computeRTL(this.hass) ? " ◂ " : " ▸ "); .join(computeRTL(this.hass) ? " ◂ " : " ▸ ");
} }
} }
}
if (subtitle) { if (subtitle) {
return html`<span slot="subtitle">${subtitle}</span>`; return html`<span slot="subtitle">${subtitle}</span>`;
}
} }
} }
@@ -1353,6 +1396,61 @@ class DialogAddAutomationElement
this._labelRegistry?.find(({ label_id }) => label_id === labelId) this._labelRegistry?.find(({ label_id }) => label_id === labelId)
); );
private _getDomainType(domain: string) {
return ENTITY_DOMAINS_MAIN.has(domain) ||
(this._manifests?.[domain].integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain))
? "dynamicGroups"
: this._manifests?.[domain].integration_type === "helper"
? "helpers"
: "other";
}
private _sortDomainsByCollection(
type: AddAutomationElementDialogParams["type"],
entries: [
string,
{ title: string; items: AddAutomationElementListItem[] },
][]
): { title: string; items: AddAutomationElementListItem[] }[] {
const order: string[] = [];
TYPES[type].collections.forEach((collection) => {
order.push(...Object.keys(collection.groups));
});
return entries
.sort((a, b) => {
const domainA = a[0];
const domainB = b[0];
if (order.includes(domainA) && order.includes(domainB)) {
return order.indexOf(domainA) - order.indexOf(domainB);
}
let typeA = domainA;
let typeB = domainB;
if (!order.includes(domainA)) {
typeA = this._getDomainType(domainA);
}
if (!order.includes(domainB)) {
typeB = this._getDomainType(domainB);
}
if (typeA === typeB) {
return stringCompare(
a[1].title,
b[1].title,
this.hass.locale.language
);
}
return order.indexOf(typeA) - order.indexOf(typeB);
})
.map((entry) => entry[1]);
}
// #endregion data // #endregion data
// #region data memoize // #region data memoize
@@ -1368,12 +1466,12 @@ class DialogAddAutomationElement
private _getAreaEntityLookupMemoized = memoizeOne( private _getAreaEntityLookupMemoized = memoizeOne(
(entities: HomeAssistant["entities"]) => (entities: HomeAssistant["entities"]) =>
getAreaEntityLookup(Object.values(entities), true) getAreaEntityLookup(Object.values(entities))
); );
private _getDeviceEntityLookupMemoized = memoizeOne( private _getDeviceEntityLookupMemoized = memoizeOne(
(entities: HomeAssistant["entities"]) => (entities: HomeAssistant["entities"]) =>
getDeviceEntityLookup(Object.values(entities), true) getDeviceEntityLookup(Object.values(entities))
); );
private _extractTypeAndIdFromTarget = memoizeOne( private _extractTypeAndIdFromTarget = memoizeOne(
@@ -1438,8 +1536,9 @@ class DialogAddAutomationElement
); );
}); });
return Object.values(items).sort((a, b) => return this._sortDomainsByCollection(
stringCompare(a.title, b.title, this.hass.locale.language) this._params!.type,
Object.entries(items)
); );
} }
@@ -1548,8 +1647,9 @@ class DialogAddAutomationElement
); );
}); });
return Object.values(items).sort((a, b) => return this._sortDomainsByCollection(
stringCompare(a.title, b.title, this.hass.locale.language) this._params!.type,
Object.entries(items)
); );
} }
@@ -1580,8 +1680,9 @@ class DialogAddAutomationElement
); );
}); });
return Object.values(items).sort((a, b) => return this._sortDomainsByCollection(
stringCompare(a.title, b.title, this.hass.locale.language) this._params!.type,
Object.entries(items)
); );
} }
@@ -1594,11 +1695,7 @@ class DialogAddAutomationElement
} }
private _back() { private _back() {
if (this._selectedTarget) { mainWindow.history.back();
this._targetPickerElement?.navigateBack();
return;
}
this._selectedGroup = undefined;
} }
private _groupSelected(ev) { private _groupSelected(ev) {
@@ -1610,11 +1707,26 @@ class DialogAddAutomationElement
} }
this._selectedGroup = group.value; this._selectedGroup = group.value;
this._selectedCollectionIndex = ev.currentTarget.index; this._selectedCollectionIndex = ev.currentTarget.index;
mainWindow.history.pushState(
{
dialogData: {
group: this._selectedGroup,
collectionIndex: this._selectedCollectionIndex,
},
},
""
);
requestAnimationFrame(() => { requestAnimationFrame(() => {
this._itemsListElement?.scrollTo(0, 0); this._itemsListElement?.scrollTo(0, 0);
}); });
} }
private _paste() {
this._params!.add(PASTE_VALUE);
this.closeDialog();
}
private _selected(ev: CustomEvent<{ value: string }>) { private _selected(ev: CustomEvent<{ value: string }>) {
let target: HassServiceTarget | undefined; let target: HassServiceTarget | undefined;
if ( if (
@@ -1634,6 +1746,14 @@ class DialogAddAutomationElement
this._targetItems = undefined; this._targetItems = undefined;
this._loadItemsError = false; this._loadItemsError = false;
this._selectedTarget = ev.detail.value; this._selectedTarget = ev.detail.value;
mainWindow.history.pushState(
{
dialogData: {
target: this._selectedTarget,
},
},
""
);
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this._narrow) { if (this._narrow) {
@@ -1678,14 +1798,19 @@ class DialogAddAutomationElement
} }
if (this._params!.type === "action") { if (this._params!.type === "action") {
const items = await getServicesForTarget( const items: string[] = await getServicesForTarget(
this.hass.callWS, this.hass.callWS,
this._selectedTarget this._selectedTarget
); );
const filteredItems = items.filter(
// homeassistant services are too generic to be applied on the selected target
(service) => !service.startsWith("homeassistant.")
);
this._targetItems = this._getDomainGroupedActionListItems( this._targetItems = this._getDomainGroupedActionListItems(
this.hass.localize, this.hass.localize,
items filteredItems
); );
} }
} catch (err) { } catch (err) {
@@ -1748,6 +1873,10 @@ class DialogAddAutomationElement
this._tab = "targets"; this._tab = "targets";
} }
private _handleClosed() {
this.closeDialog();
}
// #region interaction // #region interaction
// #region render helpers // #region render helpers
@@ -1913,7 +2042,7 @@ class DialogAddAutomationElement
ha-wa-dialog { ha-wa-dialog {
--dialog-content-padding: var(--ha-space-0); --dialog-content-padding: var(--ha-space-0);
--ha-dialog-min-height: min( --ha-dialog-min-height: min(
648px, 800px,
calc( calc(
100vh - max( 100vh - max(
var(--safe-area-inset-bottom), var(--safe-area-inset-bottom),
@@ -1922,7 +2051,7 @@ class DialogAddAutomationElement
) )
); );
--ha-dialog-min-height: min( --ha-dialog-min-height: min(
648px, 800px,
calc( calc(
100dvh - max( 100dvh - max(
var(--safe-area-inset-bottom), var(--safe-area-inset-bottom),
@@ -2015,7 +2144,7 @@ class DialogAddAutomationElement
min-height: 160px; min-height: 160px;
} }
.content.column ha-automation-add-from-target { .content.column ha-automation-add-from-target {
overflow: hidden; overflow: clip;
} }
ha-wa-dialog ha-automation-add-items { ha-wa-dialog ha-automation-add-items {

View File

@@ -553,9 +553,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
area.icon, area.icon,
] as [string, string, string | undefined, string | undefined]; ] as [string, string, string | undefined, string | undefined];
}) })
.sort(([, nameA], [, nameB]) =>
stringCompare(nameA, nameB, this.hass.locale.language)
)
.map(([areaTargetId, areaName, floorId, areaIcon]) => { .map(([areaTargetId, areaName, floorId, areaIcon]) => {
const { open, devices, entities } = const { open, devices, entities } =
this._entries[`floor${TARGET_SEPARATOR}${floorId || ""}`].areas![ this._entries[`floor${TARGET_SEPARATOR}${floorId || ""}`].areas![
@@ -708,7 +705,11 @@ export default class HaAutomationAddFromTarget extends LitElement {
this.floors this.floors
); );
const label = entityName || deviceName || entityId; let label = entityName || deviceName || entityId;
if (this.entities[entityId]?.hidden) {
label += ` (${this.localize("ui.panel.config.automation.editor.entity_hidden")})`;
}
return [entityId, label, stateObj] as [string, string, HassEntity]; return [entityId, label, stateObj] as [string, string, HassEntity];
}) })
@@ -837,12 +838,12 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _getAreaEntityLookupMemoized = memoizeOne( private _getAreaEntityLookupMemoized = memoizeOne(
(entities: HomeAssistant["entities"]) => (entities: HomeAssistant["entities"]) =>
getAreaEntityLookup(Object.values(entities), true) getAreaEntityLookup(Object.values(entities))
); );
private _getDeviceEntityLookupMemoized = memoizeOne( private _getDeviceEntityLookupMemoized = memoizeOne(
(entities: HomeAssistant["entities"]) => (entities: HomeAssistant["entities"]) =>
getDeviceEntityLookup(Object.values(entities), true) getDeviceEntityLookup(Object.values(entities))
); );
private _getSelectedTargetId = memoizeOne( private _getSelectedTargetId = memoizeOne(
@@ -910,6 +911,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
const services: Record<string, Level3Entries> = {}; const services: Record<string, Level3Entries> = {};
unassignedDevices.forEach(({ id: deviceId, entry_type }) => { unassignedDevices.forEach(({ id: deviceId, entry_type }) => {
const device = this.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
const deviceEntry = { const deviceEntry = {
open: false, open: false,
entities: entities:
@@ -1011,6 +1016,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
const devices: Record<string, Level3Entries> = {}; const devices: Record<string, Level3Entries> = {};
referenced_devices.forEach(({ id: deviceId }) => { referenced_devices.forEach(({ id: deviceId }) => {
const device = this.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
devices[deviceId] = { devices[deviceId] = {
open: false, open: false,
entities: entities:
@@ -1382,92 +1391,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
); );
} }
public navigateBack() {
if (!this.value) {
return;
}
const valueType = Object.keys(this.value)[0].replace("_id", "");
const valueId = this.value[`${valueType}_id`];
if (
valueType === "floor" ||
valueType === "label" ||
(!valueId &&
(valueType === "device" ||
valueType === "helper" ||
valueType === "service" ||
valueType === "area"))
) {
fireEvent(this, "value-changed", { value: undefined });
return;
}
if (valueType === "area") {
fireEvent(this, "value-changed", {
value: { floor_id: this.areas[valueId].floor_id || undefined },
});
return;
}
if (valueType === "device") {
if (
!this.devices[valueId].area_id &&
this.devices[valueId].entry_type === "service"
) {
fireEvent(this, "value-changed", {
value: { service_id: undefined },
});
return;
}
fireEvent(this, "value-changed", {
value: { area_id: this.devices[valueId].area_id || undefined },
});
return;
}
if (valueType === "entity" && valueId) {
const deviceId = this.entities[valueId].device_id;
if (deviceId) {
fireEvent(this, "value-changed", {
value: { device_id: deviceId },
});
return;
}
const areaId = this.entities[valueId].area_id;
if (areaId) {
fireEvent(this, "value-changed", {
value: { area_id: areaId },
});
return;
}
const domain = valueId.split(".", 2)[0];
const manifest = this.manifests ? this.manifests[domain] : undefined;
if (manifest?.integration_type === "helper") {
fireEvent(this, "value-changed", {
value: { [`helper_${domain}_id`]: undefined },
});
return;
}
fireEvent(this, "value-changed", {
value: { [`entity_${domain}_id`]: undefined },
});
}
if (valueType.startsWith("helper_") || valueType.startsWith("entity_")) {
fireEvent(this, "value-changed", {
value: {
[`${valueType.startsWith("helper_") ? "helper" : "device"}_id`]:
undefined,
},
});
}
}
private _expandHeight() { private _expandHeight() {
this._fullHeight = true; this._fullHeight = true;
this.style.setProperty("--max-height", "none"); this.style.setProperty("--max-height", "none");

View File

@@ -60,6 +60,8 @@ export class HaAutomationAddItems extends LitElement {
@property({ type: Boolean, attribute: "tooltip-description" }) @property({ type: Boolean, attribute: "tooltip-description" })
public tooltipDescription = false; public tooltipDescription = false;
@property({ type: Boolean, reflect: true }) scrollable = false;
@state() private _itemsScrolled = false; @state() private _itemsScrolled = false;
@query(".items") @query(".items")
@@ -260,11 +262,12 @@ export class HaAutomationAddItems extends LitElement {
:host { :host {
display: flex; display: flex;
} }
:host([scrollable]) .items {
overflow: auto;
}
.items { .items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto;
flex: 1; flex: 1;
} }
.items.blank { .items.blank {
@@ -273,7 +276,7 @@ export class HaAutomationAddItems extends LitElement {
align-items: center; align-items: center;
color: var(--ha-color-text-secondary); color: var(--ha-color-text-secondary);
padding: var(--ha-space-0); padding: var(--ha-space-0);
margin: var(--ha-space-3) var(--ha-space-4) margin: var(--ha-space-0) var(--ha-space-4)
max(var(--safe-area-inset-bottom), var(--ha-space-3)); max(var(--safe-area-inset-bottom), var(--ha-space-3));
line-height: var(--ha-line-height-expanded); line-height: var(--ha-line-height-expanded);
justify-content: center; justify-content: center;
@@ -306,7 +309,7 @@ export class HaAutomationAddItems extends LitElement {
.items .item-headline { .items .item-headline {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--ha-space-1); gap: var(--ha-space-2);
min-height: var(--ha-space-9); min-height: var(--ha-space-9);
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -366,12 +369,16 @@ export class HaAutomationAddItems extends LitElement {
} }
.selected-target state-badge { .selected-target state-badge {
--mdc-icon-size: 20px; --mdc-icon-size: 24px;
} }
.selected-target state-badge, .selected-target state-badge,
.selected-target ha-floor-icon {
display: flex;
height: 32px;
width: 32px;
align-items: center;
}
.selected-target ha-domain-icon { .selected-target ha-domain-icon {
width: 24px;
height: 24px;
filter: grayscale(100%); filter: grayscale(100%);
} }
`; `;

View File

@@ -28,7 +28,7 @@ import {
CONDITION_BUILDING_BLOCKS, CONDITION_BUILDING_BLOCKS,
subscribeConditions, subscribeConditions,
} from "../../../../data/condition"; } from "../../../../data/condition";
import { subscribeLabFeatures } from "../../../../data/labs"; import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { import {
@@ -90,14 +90,14 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
protected hassSubscribe() { protected hassSubscribe() {
return [ return [
subscribeLabFeatures(this.hass!.connection, (features) => { subscribeLabFeature(
this._newTriggersAndConditions = this.hass!.connection,
features.find( "automation",
(feature) => "new_triggers_conditions",
feature.domain === "automation" && (feature) => {
feature.preview_feature === "new_triggers_conditions" this._newTriggersAndConditions = feature.enabled;
)?.enabled ?? false; }
}), ),
]; ];
} }

View File

@@ -393,6 +393,10 @@ export class HaPlatformCondition extends LitElement {
} }
static styles = css` static styles = css`
:host {
display: block;
margin: 0px calc(-1 * var(--ha-space-4));
}
ha-settings-row { ha-settings-row {
padding: 0 var(--ha-space-4); padding: 0 var(--ha-space-4);
} }

View File

@@ -97,9 +97,9 @@ export default class HaAutomationSidebarAction extends LitElement {
title = `${domainToName(this.hass.localize, domain)}: ${ title = `${domainToName(this.hass.localize, domain)}: ${
this.hass.localize( this.hass.localize(
`component.${domain}.services.${service}.name`, `component.${domain}.services.${service}.name`,
this.hass.services[domain][service].description_placeholders this.hass.services[domain]?.[service]?.description_placeholders
) || ) ||
this.hass.services[domain][service]?.name || this.hass.services[domain]?.[service]?.name ||
title title
}`; }`;
} }

View File

@@ -24,7 +24,7 @@ import {
type Trigger, type Trigger,
type TriggerList, type TriggerList,
} from "../../../../data/automation"; } from "../../../../data/automation";
import { subscribeLabFeatures } from "../../../../data/labs"; import { subscribeLabFeature } from "../../../../data/labs";
import type { TriggerDescriptions } from "../../../../data/trigger"; import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger"; import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
@@ -85,14 +85,14 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
protected hassSubscribe() { protected hassSubscribe() {
return [ return [
subscribeLabFeatures(this.hass!.connection, (features) => { subscribeLabFeature(
this._newTriggersAndConditions = this.hass!.connection,
features.find( "automation",
(feature) => "new_triggers_conditions",
feature.domain === "automation" && (feature) => {
feature.preview_feature === "new_triggers_conditions" this._newTriggersAndConditions = feature.enabled;
)?.enabled ?? false; }
}), ),
]; ];
} }

View File

@@ -429,6 +429,10 @@ export class HaPlatformTrigger extends LitElement {
} }
static styles = css` static styles = css`
:host {
display: block;
margin: 0px calc(-1 * var(--ha-space-4));
}
ha-settings-row { ha-settings-row {
padding: 0 var(--ha-space-4); padding: 0 var(--ha-space-4);
} }

View File

@@ -1,3 +1,4 @@
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js"; import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket"; import type { HassEntities } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
@@ -5,9 +6,13 @@ import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-bar"; import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-check-list-item";
import "../../../components/ha-list-item";
import "../../../components/ha-metric"; import "../../../components/ha-metric";
import { extractApiErrorMessage } from "../../../data/hassio/common"; import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { import type {
@@ -28,9 +33,6 @@ import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates"; import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta"; import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "@home-assistant/webawesome/dist/components/divider/divider";
@customElement("ha-config-section-updates") @customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement { class HaConfigSectionUpdates extends LitElement {
@@ -71,25 +73,24 @@ class HaConfigSectionUpdates extends LitElement {
.path=${mdiRefresh} .path=${mdiRefresh}
@click=${this._checkUpdates} @click=${this._checkUpdates}
></ha-icon-button> ></ha-icon-button>
<ha-dropdown @wa-select=${this._handleOverflowAction}> <ha-button-menu multi>
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
.label=${this.hass.localize("ui.common.menu")} .label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical} .path=${mdiDotsVertical}
></ha-icon-button> ></ha-icon-button>
<ha-check-list-item
<ha-dropdown-item left
type="checkbox" @request-selected=${this._toggleSkipped}
value="show_skipped" .selected=${this._showSkipped}
.checked=${this._showSkipped}
> >
${this.hass.localize("ui.panel.config.updates.show_skipped")} ${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-dropdown-item> </ha-check-list-item>
${this._supervisorInfo ${this._supervisorInfo
? html` ? html`
<wa-divider></wa-divider> <li divider role="separator"></li>
<ha-dropdown-item <ha-list-item
value="toggle_beta" @request-selected=${this._toggleBeta}
.disabled=${this._supervisorInfo.channel === "dev"} .disabled=${this._supervisorInfo.channel === "dev"}
> >
${this._supervisorInfo.channel === "stable" ${this._supervisorInfo.channel === "stable"
@@ -97,10 +98,10 @@ class HaConfigSectionUpdates extends LitElement {
: this.hass.localize( : this.hass.localize(
"ui.panel.config.updates.leave_beta" "ui.panel.config.updates.leave_beta"
)} )}
</ha-dropdown-item> </ha-list-item>
` `
: ""} : ""}
</ha-dropdown> </ha-button-menu>
</div> </div>
<div class="content"> <div class="content">
<ha-card outlined> <ha-card outlined>
@@ -132,19 +133,27 @@ class HaConfigSectionUpdates extends LitElement {
this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass); this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
} }
private async _handleOverflowAction( private _toggleSkipped(ev: CustomEvent<RequestSelectedDetail>): void {
ev: CustomEvent<{ item: { value: string } }> if (ev.detail.source !== "property") {
return;
}
this._showSkipped = !this._showSkipped;
}
private async _toggleBeta(
ev: CustomEvent<RequestSelectedDetail>
): Promise<void> { ): Promise<void> {
if (ev.detail.item.value === "toggle_beta") { if (!shouldHandleRequestSelectedEvent(ev)) {
if (this._supervisorInfo!.channel === "stable") { return;
showJoinBetaDialog(this, { }
join: async () => this._setChannel("beta"),
}); if (this._supervisorInfo!.channel === "stable") {
} else { showJoinBetaDialog(this, {
this._setChannel("stable"); join: async () => this._setChannel("beta"),
} });
} else if (ev.detail.item.value === "show_skipped") { } else {
this._showSkipped = !this._showSkipped; this._setChannel("stable");
} }
} }

View File

@@ -9,10 +9,10 @@ export class MatterAddDevice extends HTMLElement {
public hass!: HomeAssistant; public hass!: HomeAssistant;
connectedCallback() { connectedCallback() {
showMatterAddDeviceDialog(this); navigate("/config/devices/dashboard", {
navigate(`/config/devices`, {
replace: true, replace: true,
}); });
showMatterAddDeviceDialog(this);
} }
} }

View File

@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { relativeTime } from "../../../common/datetime/relative_time"; import { relativeTime } from "../../../common/datetime/relative_time";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button"; import "../../../components/ha-button";
@@ -11,6 +12,7 @@ import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-switch"; import "../../../components/ha-switch";
import type { BackupConfig } from "../../../data/backup"; import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup"; import { fetchBackupConfig } from "../../../data/backup";
import { getSupervisorUpdateConfig } from "../../../data/supervisor/update";
import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { LabsPreviewFeatureEnableDialogParams } from "./show-dialog-labs-preview-feature-enable"; import type { LabsPreviewFeatureEnableDialogParams } from "./show-dialog-labs-preview-feature-enable";
@@ -35,7 +37,10 @@ export class DialogLabsPreviewFeatureEnable
): Promise<void> { ): Promise<void> {
this._params = params; this._params = params;
this._createBackup = false; this._createBackup = false;
await this._fetchBackupConfig(); this._fetchBackupConfig();
if (isComponentLoaded(this.hass, "hassio")) {
this._fetchUpdateBackupConfig();
}
} }
public closeDialog(): boolean { public closeDialog(): boolean {
@@ -54,15 +59,21 @@ export class DialogLabsPreviewFeatureEnable
try { try {
const { config } = await fetchBackupConfig(this.hass); const { config } = await fetchBackupConfig(this.hass);
this._backupConfig = config; this._backupConfig = config;
} catch (err) {
// Ignore error, user will get manual backup option
// eslint-disable-next-line no-console
console.error(err);
}
}
// Default to enabled if automatic backups are configured, disabled otherwise private async _fetchUpdateBackupConfig() {
this._createBackup = try {
config.automatic_backups_configured && const config = await getSupervisorUpdateConfig(this.hass);
!!config.create_backup.password && this._createBackup = config.core_backup_before_update;
config.create_backup.agent_ids.length > 0; } catch (err) {
} catch { // Ignore error, user can still toggle the switch manually
// User will get manual backup option if fetch fails // eslint-disable-next-line no-console
this._createBackup = false; console.error(err);
} }
} }

View File

@@ -94,7 +94,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
<hass-subpage <hass-subpage
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
back-path="/config" back-path="/config/system"
.header=${this.hass.localize("ui.panel.config.labs.caption")} .header=${this.hass.localize("ui.panel.config.labs.caption")}
> >
${sortedFeatures.length ${sortedFeatures.length
@@ -385,6 +385,10 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
display: block; display: block;
} }
a[slot="toolbar-icon"] {
color: var(--sidebar-icon-color);
}
.content { .content {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;

View File

@@ -326,7 +326,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
PANEL_DASHBOARDS.forEach((panel) => { PANEL_DASHBOARDS.forEach((panel) => {
const panelInfo = this.hass.panels[panel]; const panelInfo = this.hass.panels[panel];
if (!panel) { if (!panelInfo) {
return; return;
} }
const item: DataTableItem = { const item: DataTableItem = {

View File

@@ -215,57 +215,62 @@ export class AssistPipelineDebug extends LitElement {
? html` ? html`
<div class="messages"> <div class="messages">
${messages.map((content) => ${messages.map((content) =>
content.role === "system" || content.role === "tool_result" content.role === "system"
? html` ? content.content
<ha-expansion-panel ? html`
class="content-expansion ${content.role}" <ha-expansion-panel
> class="content-expansion ${content.role}"
<div slot="header"> >
${content.role === "system" <div slot="header">System</div>
? "System" <pre>${content.content}</pre>
: `Result for ${content.tool_name}`} </ha-expansion-panel>
</div> `
${content.role === "system" : nothing
? html`<pre>${content.content}</pre>` : content.role === "tool_result"
: html` ? html`
<ha-yaml-editor <ha-expansion-panel
read-only class="content-expansion ${content.role}"
auto-update >
.value=${content} <div slot="header">
></ha-yaml-editor> Result for ${content.tool_name}
`} </div>
</ha-expansion-panel> <ha-yaml-editor
` read-only
: html` auto-update
${content.content .value=${content}
? html` ></ha-yaml-editor>
<div class=${`message ${content.role}`}> </ha-expansion-panel>
${content.content} `
</div> : html`
` ${content.content
: nothing} ? html`
${content.role === "assistant" && <div class=${`message ${content.role}`}>
content.tool_calls?.length ${content.content}
? html` </div>
<ha-expansion-panel `
class="content-expansion assistant" : nothing}
> ${content.role === "assistant" &&
<span slot="header"> content.tool_calls?.length
Call ? html`
${content.tool_calls.length === 1 <ha-expansion-panel
? content.tool_calls[0].tool_name class="content-expansion assistant"
: `${content.tool_calls.length} tools`} >
</span> <span slot="header">
Call
${content.tool_calls.length === 1
? content.tool_calls[0].tool_name
: `${content.tool_calls.length} tools`}
</span>
<ha-yaml-editor <ha-yaml-editor
read-only read-only
auto-update auto-update
.value=${content.tool_calls} .value=${content.tool_calls}
></ha-yaml-editor> ></ha-yaml-editor>
</ha-expansion-panel> </ha-expansion-panel>
` `
: nothing} : nothing}
` `
)} )}
</div> </div>
<div style="clear:both"></div> <div style="clear:both"></div>

View File

@@ -137,7 +137,7 @@ class HaPanelDevAction extends LitElement {
const descriptionPlaceholders = const descriptionPlaceholders =
domain && serviceName domain && serviceName
? this.hass.services[domain][serviceName].description_placeholders ? this.hass.services[domain]?.[serviceName]?.description_placeholders
: undefined; : undefined;
return html` return html`

View File

@@ -1,11 +1,10 @@
import { mdiDownload, mdiPencil } from "@mdi/js"; import { mdiDownload } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { goBack, navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
import "../../components/ha-alert"; import "../../components/ha-alert";
import "../../components/ha-icon-button-arrow-prev"; import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-list-item";
import "../../components/ha-menu-button"; import "../../components/ha-menu-button";
import "../../components/ha-top-app-bar-fixed"; import "../../components/ha-top-app-bar-fixed";
import type { import type {
@@ -26,40 +25,64 @@ import type { LovelaceConfig } from "../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../data/lovelace/config/view";
import type { StatisticValue } from "../../data/recorder"; import type { StatisticValue } from "../../data/recorder";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant, PanelInfo } from "../../types";
import { fileDownload } from "../../util/file_download"; import { fileDownload } from "../../util/file_download";
import "../lovelace/components/hui-energy-period-selector"; import "../lovelace/components/hui-energy-period-selector";
import "../lovelace/hui-root";
import type { ExtraActionItem } from "../lovelace/hui-root";
import type { Lovelace } from "../lovelace/types"; import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container"; import "../lovelace/views/hui-view-container";
import type { LocalizeKeys } from "../../common/translations/localize";
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard"; export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
const EMPTY_PREFERENCES: EnergyPreferences = {
energy_sources: [],
device_consumption: [],
device_consumption_water: [],
};
const OVERVIEW_VIEW = { const OVERVIEW_VIEW = {
path: "overview",
strategy: { strategy: {
type: "energy-overview", type: "energy-overview",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY, collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
}, },
} as LovelaceViewConfig; } as LovelaceViewConfig;
const ELECTRICITY_VIEW = { const ENERGY_VIEW = {
back_path: "/energy",
path: "electricity", path: "electricity",
strategy: { strategy: {
type: "energy-electricity", type: "energy",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY, collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
}, },
} as LovelaceViewConfig; } as LovelaceViewConfig;
const WATER_VIEW = { const WATER_VIEW = {
back_path: "/energy",
path: "water", path: "water",
strategy: { strategy: {
type: "energy-water", type: "water",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY, collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
}, },
} as LovelaceViewConfig; } as LovelaceViewConfig;
const GAS_VIEW = {
path: "gas",
strategy: {
type: "gas",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
},
} as LovelaceViewConfig;
const POWER_VIEW = {
path: "now",
strategy: {
type: "power",
collection_key: "energy_dashboard_now",
},
} as LovelaceViewConfig;
const WIZARD_VIEW = { const WIZARD_VIEW = {
type: "panel", type: "panel",
path: "setup", path: "setup",
@@ -72,153 +95,50 @@ class PanelEnergy extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false; @property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public panel?: PanelInfo;
@state() private _lovelace?: Lovelace; @state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _error?: string;
@property({ attribute: false }) public route?: { @property({ attribute: false }) public route?: {
path: string; path: string;
prefix: string; prefix: string;
}; };
@state() @state()
private _config?: LovelaceConfig; private _prefs?: EnergyPreferences;
get _viewPath(): string | undefined { @state()
const viewPath: string | undefined = this.route!.path.split("/")[1]; private _error?: string;
return viewPath ? decodeURI(viewPath) : undefined;
private get _extraActionItems(): ExtraActionItem[] {
return [
{
icon: mdiDownload,
labelKey: "ui.panel.energy.download_data",
action: this._dumpCSV,
},
];
} }
public connectedCallback() { public willUpdate(changedProps: PropertyValues) {
super.connectedCallback(); super.willUpdate(changedProps);
this._loadLovelaceConfig(); // Initial setup
}
public async willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) { if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace"); this.hass.loadFragmentTranslation("lovelace");
this._loadConfig();
return;
} }
if (!changedProps.has("hass")) { if (!changedProps.has("hass")) {
return; return;
} }
const oldHass = changedProps.get("hass") as this["hass"]; const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) { if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace(); this._setLovelace();
} else if (oldHass && oldHass.localize !== this.hass.localize) {
this._reloadView();
} }
} }
private async _loadLovelaceConfig() {
try {
this._config = undefined;
this._config = await this._generateLovelaceConfig();
} catch (err) {
this._error = (err as Error).message;
}
this._setLovelace();
}
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render() {
if (!this._config && !this._error) {
// Still loading
return html`
<div class="centered">
<ha-spinner size="large"></ha-spinner>
</div>
`;
}
const isSingleView = this._config?.views.length === 1;
const viewPath = this._viewPath;
const viewIndex = this._config
? Math.max(
this._config.views.findIndex((view) => view.path === viewPath),
0
)
: 0;
const showBack =
this._searchParms.has("historyBack") || (!isSingleView && viewIndex > 0);
return html`
<div class="header">
<div class="toolbar">
${showBack
? 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>
`}
${!this.narrow
? html`<div class="main-title">
${this.hass.localize("panel.energy")}
</div>`
: nothing}
<hui-energy-period-selector
.hass=${this.hass}
.collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
>
${this.hass.user?.is_admin
? html` <ha-list-item
slot="overflow-menu"
graphic="icon"
@request-selected=${this._navigateConfig}
>
<ha-svg-icon slot="graphic" .path=${mdiPencil}> </ha-svg-icon>
${this.hass!.localize("ui.panel.energy.configure")}
</ha-list-item>`
: nothing}
<ha-list-item
slot="overflow-menu"
graphic="icon"
@request-selected=${this._dumpCSV}
>
<ha-svg-icon slot="graphic" .path=${mdiDownload}> </ha-svg-icon>
${this.hass!.localize("ui.panel.energy.download_data")}
</ha-list-item>
</hui-energy-period-selector>
</div>
</div>
<hui-view-container
.hass=${this.hass}
@reload-energy-panel=${this._reloadView}
>
${this._error
? html`<div class="centered">
<ha-alert alert-type="error">
An error occurred while fetching your energy preferences:
${this._error}
</ha-alert>
</div>`
: this._lovelace
? html`<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${viewIndex}
></hui-view>`
: nothing}
</hui-view-container>
`;
}
private _fetchEnergyPrefs = async (): Promise< private _fetchEnergyPrefs = async (): Promise<
EnergyPreferences | undefined EnergyPreferences | undefined
> => { > => {
@@ -236,46 +156,37 @@ class PanelEnergy extends LitElement {
return collection.prefs; return collection.prefs;
}; };
private async _generateLovelaceConfig(): Promise<LovelaceConfig> { private async _loadConfig() {
const prefs = await this._fetchEnergyPrefs(); try {
if ( this._error = undefined;
!prefs || const prefs = await this._fetchEnergyPrefs();
(prefs.device_consumption.length === 0 && this._prefs = prefs || EMPTY_PREFERENCES;
prefs.energy_sources.length === 0) } catch (err) {
) { // eslint-disable-next-line no-console
await import("./cards/energy-setup-wizard-card"); console.error("Failed to load prefs:", err);
return { this._prefs = EMPTY_PREFERENCES;
views: [WIZARD_VIEW], this._error = (err as Error).message || "Unknown error";
};
} }
await this._setLovelace();
const isElectricityOnly = prefs.energy_sources.every((source) => // Check if current path is valid, navigate to first view if not
["grid", "solar", "battery"].includes(source.type) const views = this._lovelace!.config?.views || [];
); const validPaths = views.map((view) => view.path);
if (isElectricityOnly) { const viewPath: string | undefined = this.route!.path.split("/")[1];
return { if (!viewPath || !validPaths.includes(viewPath)) {
views: [ELECTRICITY_VIEW], navigate(`${this.route!.prefix}/${validPaths[0]}`);
}; } else {
// Force hui-root to re-process the route by creating a new route object
this.route = { ...this.route! };
} }
const hasWater =
prefs.energy_sources.some((source) => source.type === "water") ||
prefs.device_consumption_water?.length > 0;
const views: LovelaceViewConfig[] = [OVERVIEW_VIEW, ELECTRICITY_VIEW];
if (hasWater) {
views.push(WATER_VIEW);
}
return { views };
} }
private _setLovelace() { private async _setLovelace() {
if (!this._config) { const config = await this._generateLovelaceConfig();
return;
}
this._lovelace = { this._lovelace = {
config: this._config, config: config,
rawConfig: this._config, rawConfig: config,
editMode: false, editMode: false,
urlPath: "energy", urlPath: "energy",
mode: "generated", mode: "generated",
@@ -283,18 +194,116 @@ class PanelEnergy extends LitElement {
enableFullEditMode: () => undefined, enableFullEditMode: () => undefined,
saveConfig: async () => undefined, saveConfig: async () => undefined,
deleteConfig: async () => undefined, deleteConfig: async () => undefined,
setEditMode: () => undefined, setEditMode: () => this._navigateConfig(),
showToast: () => undefined, showToast: () => undefined,
}; };
} }
private _navigateConfig(ev) { protected render() {
ev.stopPropagation(); if (this._error) {
return html`
<div class="centered">
<ha-alert alert-type="error">
An error occurred loading energy preferences: ${this._error}
</ha-alert>
</div>
`;
}
if (!this._prefs) {
// Still loading
return html`
<div class="centered">
<ha-spinner size="large"></ha-spinner>
</div>
`;
}
if (!this._lovelace) {
return nothing;
}
return html`
<hui-root
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.route=${this.route}
.panel=${this.panel}
.extraActionItems=${this._extraActionItems}
@reload-energy-panel=${this._reloadConfig}
></hui-root>
`;
}
private async _generateLovelaceConfig(): Promise<LovelaceConfig> {
if (
!this._prefs ||
(this._prefs.device_consumption.length === 0 &&
this._prefs.energy_sources.length === 0)
) {
await import("./cards/energy-setup-wizard-card");
return {
views: [WIZARD_VIEW],
};
}
const hasEnergy = this._prefs.energy_sources.some((source) =>
["grid", "solar", "battery"].includes(source.type)
);
const hasPower =
this._prefs.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
) || this._prefs.device_consumption.some((device) => device.stat_rate);
const hasWater =
this._prefs.energy_sources.some((source) => source.type === "water") ||
this._prefs.device_consumption_water?.length > 0;
const hasGas = this._prefs.energy_sources.some(
(source) => source.type === "gas"
);
const hasDeviceConsumption = this._prefs.device_consumption.length > 0;
const views: LovelaceViewConfig[] = [];
if (hasEnergy || hasDeviceConsumption) {
views.push(ENERGY_VIEW);
}
if (hasGas) {
views.push(GAS_VIEW);
}
if (hasWater) {
views.push(WATER_VIEW);
}
if (hasPower) {
views.push(POWER_VIEW);
}
if (views.length > 1) {
views.unshift(OVERVIEW_VIEW);
}
return {
views: views.map((view) => ({
...view,
title:
view.title ||
this.hass.localize(
`ui.panel.energy.title.${view.path}` as LocalizeKeys
),
})),
};
}
private _navigateConfig(ev?: Event) {
ev?.stopPropagation();
navigate("/config/energy?historyBack=1"); navigate("/config/energy?historyBack=1");
} }
private async _dumpCSV(ev) { private _dumpCSV = async () => {
ev.stopPropagation();
const energyData = getEnergyDataCollection(this.hass, { const energyData = getEnergyDataCollection(this.hass, {
key: "energy_dashboard", key: "energy_dashboard",
}); });
@@ -308,6 +317,7 @@ class PanelEnergy extends LitElement {
const energy_sources = energyData.prefs.energy_sources; const energy_sources = energyData.prefs.energy_sources;
const device_consumption = energyData.prefs.device_consumption; const device_consumption = energyData.prefs.device_consumption;
const device_consumption_water = energyData.prefs.device_consumption_water;
const stats = energyData.state.stats; const stats = energyData.state.stats;
const timeSet = new Set<number>(); const timeSet = new Set<number>();
@@ -493,6 +503,20 @@ class PanelEnergy extends LitElement {
printCategory("device_consumption", devices, electricUnit); printCategory("device_consumption", devices, electricUnit);
if (device_consumption_water) {
const waterDevices: string[] = [];
device_consumption_water.forEach((source) => {
source = source as DeviceConsumptionEnergyPreference;
waterDevices.push(source.stat_consumption);
});
printCategory(
"device_consumption_water",
waterDevices,
energyData.state.waterUnit
);
}
const { summedData, compareSummedData: _ } = getSummedData( const { summedData, compareSummedData: _ } = getSummedData(
energyData.state energyData.state
); );
@@ -591,74 +615,22 @@ class PanelEnergy extends LitElement {
}); });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
fileDownload(url, "energy.csv"); fileDownload(url, "energy.csv");
} };
private _reloadView() { private _reloadConfig() {
this._loadLovelaceConfig(); this._loadConfig();
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,
css` css`
:host hui-energy-period-selector {
flex-grow: 1;
padding-left: 32px;
padding-inline-start: 32px;
padding-inline-end: initial;
--disabled-text-color: rgba(var(--rgb-text-primary-color), 0.5);
direction: var(--direction);
--date-range-picker-max-height: calc(100vh - 80px);
}
:host([narrow]) hui-energy-period-selector {
padding-left: 0px;
padding-inline-start: 0px;
padding-inline-end: initial;
}
:host { :host {
--ha-view-sections-column-max-width: 100%;
-ms-user-select: none; -ms-user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
} }
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
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;
transition: box-shadow 200ms linear;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
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 { .toolbar {
height: var(--header-height); height: var(--header-height);
display: flex; display: flex;
@@ -677,24 +649,6 @@ class PanelEnergy extends LitElement {
line-height: var(--ha-line-height-normal); line-height: var(--ha-line-height-normal);
flex-grow: 1; flex-grow: 1;
} }
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));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
.centered { .centered {
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@@ -2,23 +2,14 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import type { GridSourceTypeEnergyPreference } from "../../../data/energy"; import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy"; import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
const sourceHasCost = (source: Record<string, any>): boolean =>
Boolean(
source.stat_cost ||
source.stat_compensation ||
source.entity_energy_price ||
source.number_energy_price
);
@customElement("energy-overview-view-strategy") @customElement("energy-overview-view-strategy")
export class EnergyViewStrategy extends ReactiveElement { export class EnergyOverviewViewStrategy extends ReactiveElement {
static async generate( static async generate(
_config: LovelaceStrategyConfig, _config: LovelaceStrategyConfig,
hass: HomeAssistant hass: HomeAssistant
@@ -36,6 +27,9 @@ export class EnergyViewStrategy extends ReactiveElement {
const energyCollection = getEnergyDataCollection(hass, { const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey, key: collectionKey,
}); });
if (!energyCollection.prefs) {
await energyCollection.refresh();
}
const prefs = energyCollection.prefs; const prefs = energyCollection.prefs;
// No energy sources available // No energy sources available
@@ -52,147 +46,94 @@ export class EnergyViewStrategy extends ReactiveElement {
source.type === "grid" && source.type === "grid" &&
(source.flow_from?.length || source.flow_to?.length) (source.flow_from?.length || source.flow_to?.length)
) as GridSourceTypeEnergyPreference; ) as GridSourceTypeEnergyPreference;
const hasReturn = hasGrid && hasGrid.flow_to.length > 0;
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasGas = prefs.energy_sources.some((source) => source.type === "gas"); const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasBattery = prefs.energy_sources.some( const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery" (source) => source.type === "battery"
); );
const hasWater = prefs.energy_sources.some( const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasWaterSources = prefs.energy_sources.some(
(source) => source.type === "water" (source) => source.type === "water"
); );
const hasWaterDevices = prefs.device_consumption_water?.length;
const hasPowerSources = prefs.energy_sources.find( const hasPowerSources = prefs.energy_sources.find(
(source) => (source) =>
(source.type === "solar" && source.stat_rate) || (source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) || (source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length) (source.type === "grid" && source.power?.length)
); );
const hasPowerDevices = prefs.device_consumption.find(
(device) => device.stat_rate
);
const hasCost = prefs.energy_sources.some(
(source) =>
sourceHasCost(source) ||
(source.type === "grid" &&
(source.flow_from?.some(sourceHasCost) ||
source.flow_to?.some(sourceHasCost)))
);
const overviewSection: LovelaceSectionConfig = { const overviewSection: LovelaceSectionConfig = {
type: "grid", type: "grid",
column_span: 24, cards: [
cards: [], {
}; type: "energy-date-selection",
if (hasPowerSources && hasPowerDevices) { collection_key: collectionKey,
overviewSection.cards!.push({ allow_compare: false,
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
grid_options: {
columns: 24,
}, },
}); ],
} };
// Only include if we have a grid or battery. if (hasGrid || hasBattery || hasSolar) {
if (hasGrid || hasBattery) {
overviewSection.cards!.push({ overviewSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_distribution_title"), title: hass.localize("ui.panel.energy.cards.energy_distribution_title"),
type: "energy-distribution", type: "energy-distribution",
collection_key: collectionKey, collection_key: collectionKey,
}); });
} }
if (hasCost) {
overviewSection.cards!.push({
type: "energy-sources-table",
collection_key: collectionKey,
show_only_totals: true,
});
}
view.sections!.push(overviewSection); view.sections!.push(overviewSection);
const electricitySection: LovelaceSectionConfig = { if (prefs.energy_sources.length) {
type: "grid", view.sections!.push({
cards: [ type: "grid",
{ cards: [
type: "heading", {
heading: hass.localize("ui.panel.energy.overview.electricity"), title: hass.localize(
tap_action: { "ui.panel.energy.cards.energy_sources_table_title"
action: "navigate", ),
navigation_path: "/energy/electricity", type: "energy-sources-table",
collection_key: collectionKey,
show_only_totals: true,
}, },
}, ],
], });
}; }
if (hasPowerSources) { if (hasPowerSources) {
electricitySection.cards!.push({ view.sections!.push({
type: "power-sources-graph",
collection_key: collectionKey,
});
}
if (prefs!.device_consumption.length > 3) {
electricitySection.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_top_consumers_title"
),
type: "energy-devices-graph",
collection_key: collectionKey,
max_devices: 3,
modes: ["bar"],
});
} else if (hasGrid) {
const gauges: LovelaceCardConfig[] = [];
// Only include if we have a grid source & return.
if (hasReturn) {
gauges.push({
type: "energy-grid-neutrality-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
gauges.push({
type: "energy-carbon-consumed-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
// Only include if we have a solar source.
if (hasSolar) {
if (hasReturn) {
gauges.push({
type: "energy-solar-consumed-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
gauges.push({
type: "energy-self-sufficiency-gauge",
view_layout: { position: "sidebar" },
collection_key: collectionKey,
});
}
electricitySection.cards!.push({
type: "grid", type: "grid",
columns: 2, cards: [
square: false, {
cards: gauges, title: hass.localize(
"ui.panel.energy.cards.power_sources_graph_title"
),
type: "power-sources-graph",
collection_key: collectionKey,
show_legend: false,
},
],
}); });
} }
view.sections!.push(electricitySection); if (hasGrid || hasBattery) {
view.sections!.push({
type: "grid",
cards: [
{
title: hass.localize(
"ui.panel.energy.cards.energy_usage_graph_title"
),
type: "energy-usage-graph",
collection_key: "energy_dashboard",
},
],
});
}
if (hasGas) { if (hasGas) {
view.sections!.push({ view.sections!.push({
type: "grid", type: "grid",
cards: [ cards: [
{
type: "heading",
heading: hass.localize("ui.panel.energy.overview.gas"),
},
{ {
title: hass.localize( title: hass.localize(
"ui.panel.energy.cards.energy_gas_graph_title" "ui.panel.energy.cards.energy_gas_graph_title"
@@ -204,25 +145,25 @@ export class EnergyViewStrategy extends ReactiveElement {
}); });
} }
if (hasWater) { if (hasWaterSources || hasWaterDevices) {
view.sections!.push({ view.sections!.push({
type: "grid", type: "grid",
cards: [ cards: [
{ hasWaterSources
type: "heading", ? {
heading: hass.localize("ui.panel.energy.overview.water"), title: hass.localize(
tap_action: { "ui.panel.energy.cards.energy_water_graph_title"
action: "navigate", ),
navigation_path: "/energy/water", type: "energy-water-graph",
}, collection_key: collectionKey,
}, }
{ : {
title: hass.localize( title: hass.localize(
"ui.panel.energy.cards.energy_water_graph_title" "ui.panel.energy.cards.water_sankey_title"
), ),
type: "energy-water-graph", type: "water-sankey",
collection_key: collectionKey, collection_key: collectionKey,
}, },
], ],
}); });
} }
@@ -233,6 +174,6 @@ export class EnergyViewStrategy extends ReactiveElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"energy-overview-view-strategy": EnergyViewStrategy; "energy-overview-view-strategy": EnergyOverviewViewStrategy;
} }
} }

View File

@@ -7,8 +7,8 @@ import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
@customElement("energy-electricity-view-strategy") @customElement("energy-view-strategy")
export class EnergyElectricityViewStrategy extends ReactiveElement { export class EnergyViewStrategy extends ReactiveElement {
static async generate( static async generate(
_config: LovelaceStrategyConfig, _config: LovelaceStrategyConfig,
hass: HomeAssistant hass: HomeAssistant
@@ -21,6 +21,9 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
const energyCollection = getEnergyDataCollection(hass, { const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey, key: collectionKey,
}); });
if (!energyCollection.prefs) {
await energyCollection.refresh();
}
const prefs = energyCollection.prefs; const prefs = energyCollection.prefs;
// No energy sources available // No energy sources available
@@ -46,39 +49,19 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
const hasBattery = prefs.energy_sources.some( const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery" (source) => source.type === "battery"
); );
const hasPowerSources = prefs.energy_sources.find( const showFloorsNAreas = !prefs.device_consumption.some(
(source) => (d) => d.included_in_stat
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerDevices = prefs.device_consumption.find(
(device) => device.stat_rate
); );
view.cards!.push({
type: "energy-date-selection",
collection_key: collectionKey,
});
view.cards!.push({ view.cards!.push({
type: "energy-compare", type: "energy-compare",
collection_key: "energy_dashboard", collection_key: "energy_dashboard",
}); });
if (hasPowerSources) {
if (hasPowerDevices) {
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
view.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
type: "power-sources-graph",
collection_key: collectionKey,
});
}
// Only include if we have a grid or battery. // Only include if we have a grid or battery.
if (hasGrid || hasBattery) { if (hasGrid || hasBattery) {
view.cards!.push({ view.cards!.push({
@@ -156,15 +139,12 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
// Only include if we have at least 1 device in the config. // Only include if we have at least 1 device in the config.
if (prefs.device_consumption.length) { if (prefs.device_consumption.length) {
const showFloorsNAreas = !prefs.device_consumption.some(
(d) => d.included_in_stat
);
view.cards!.push({ view.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_sankey_title"), title: hass.localize(
type: "energy-sankey", "ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: "energy_dashboard", collection_key: "energy_dashboard",
group_by_floor: showFloorsNAreas,
group_by_area: showFloorsNAreas,
}); });
view.cards!.push({ view.cards!.push({
title: hass.localize( title: hass.localize(
@@ -174,11 +154,11 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
collection_key: "energy_dashboard", collection_key: "energy_dashboard",
}); });
view.cards!.push({ view.cards!.push({
title: hass.localize( title: hass.localize("ui.panel.energy.cards.energy_sankey_title"),
"ui.panel.energy.cards.energy_devices_detail_graph_title" type: "energy-sankey",
),
type: "energy-devices-detail-graph",
collection_key: "energy_dashboard", collection_key: "energy_dashboard",
group_by_floor: showFloorsNAreas,
group_by_area: showFloorsNAreas,
}); });
} }
@@ -188,6 +168,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"energy-electricity-view-strategy": EnergyElectricityViewStrategy; "energy-view-strategy": EnergyViewStrategy;
} }
} }

View File

@@ -0,0 +1,73 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@customElement("gas-view-strategy")
export class GasViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = {
type: "sections",
sections: [{ type: "grid", cards: [] }],
};
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
});
if (!energyCollection.prefs) {
await energyCollection.refresh();
}
const prefs = energyCollection.prefs;
const hasGasSources = prefs?.energy_sources.some(
(source) => source.type === "gas"
);
// No gas sources available
if (!prefs || !hasGasSources) {
return view;
}
const section = view.sections![0] as LovelaceSectionConfig;
section.cards!.push({
type: "energy-date-selection",
collection_key: collectionKey,
});
section.cards!.push({
type: "energy-compare",
collection_key: collectionKey,
});
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"),
type: "energy-gas-graph",
collection_key: collectionKey,
});
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_sources_table_title"),
type: "energy-sources-table",
collection_key: collectionKey,
types: ["gas"],
});
return view;
}
}
declare global {
interface HTMLElementTagNameMap {
"gas-view-strategy": GasViewStrategy;
}
}

View File

@@ -0,0 +1,82 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@customElement("power-view-strategy")
export class PowerViewStrategy extends ReactiveElement {
static async generate(
_config: LovelaceStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = {
type: "sections",
sections: [{ type: "grid", cards: [] }],
};
const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey,
});
await energyCollection.refresh();
const prefs = energyCollection.prefs;
const hasPowerSources = prefs?.energy_sources.some(
(source) =>
(source.type === "solar" && source.stat_rate) ||
(source.type === "battery" && source.stat_rate) ||
(source.type === "grid" && source.power?.length)
);
const hasPowerDevices = prefs?.device_consumption.some(
(device) => device.stat_rate
);
// No power sources configured
if (!prefs || (!hasPowerSources && !hasPowerDevices)) {
return view;
}
const section = view.sections![0] as LovelaceSectionConfig;
if (hasPowerSources) {
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
type: "power-sources-graph",
collection_key: collectionKey,
grid_options: {
columns: 36,
},
});
}
if (hasPowerDevices) {
const showFloorsNAreas = !prefs.device_consumption.some(
(d) => d.included_in_stat
);
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
type: "power-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsNAreas,
group_by_area: showFloorsNAreas,
grid_options: {
columns: 36,
},
});
}
return view;
}
}
declare global {
interface HTMLElementTagNameMap {
"power-view-strategy": PowerViewStrategy;
}
}

View File

@@ -5,14 +5,18 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@customElement("energy-water-view-strategy") @customElement("water-view-strategy")
export class EnergyWaterViewStrategy extends ReactiveElement { export class WaterViewStrategy extends ReactiveElement {
static async generate( static async generate(
_config: LovelaceStrategyConfig, _config: LovelaceStrategyConfig,
hass: HomeAssistant hass: HomeAssistant
): Promise<LovelaceViewConfig> { ): Promise<LovelaceViewConfig> {
const view: LovelaceViewConfig = { cards: [] }; const view: LovelaceViewConfig = {
type: "sections",
sections: [{ type: "grid", cards: [] }],
};
const collectionKey = const collectionKey =
_config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY; _config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY;
@@ -20,38 +24,42 @@ export class EnergyWaterViewStrategy extends ReactiveElement {
const energyCollection = getEnergyDataCollection(hass, { const energyCollection = getEnergyDataCollection(hass, {
key: collectionKey, key: collectionKey,
}); });
if (!energyCollection.prefs) {
await energyCollection.refresh();
}
const prefs = energyCollection.prefs; const prefs = energyCollection.prefs;
const hasWaterSources = prefs?.energy_sources.some(
(source) => source.type === "water"
);
const hasWaterDevices = prefs?.device_consumption_water?.length;
// No water sources available // No water sources available
if ( if (!prefs || (!hasWaterDevices && !hasWaterSources)) {
!prefs ||
(!prefs.device_consumption_water?.length &&
!prefs.energy_sources.some((source) => source.type === "water"))
) {
return view; return view;
} }
view.type = "sidebar"; const section = view.sections![0] as LovelaceSectionConfig;
const hasWater = prefs.energy_sources.some( section.cards!.push({
(source) => source.type === "water" type: "energy-date-selection",
); collection_key: collectionKey,
});
view.cards!.push({ section.cards!.push({
type: "energy-compare", type: "energy-compare",
collection_key: collectionKey, collection_key: collectionKey,
}); });
if (hasWater) { if (hasWaterSources) {
view.cards!.push({ section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"), title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"),
type: "energy-water-graph", type: "energy-water-graph",
collection_key: collectionKey, collection_key: collectionKey,
}); });
} }
if (hasWater) { if (hasWaterSources) {
view.cards!.push({ section.cards!.push({
title: hass.localize( title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title" "ui.panel.energy.cards.energy_sources_table_title"
), ),
@@ -62,11 +70,11 @@ export class EnergyWaterViewStrategy extends ReactiveElement {
} }
// Only include if we have at least 1 water device in the config. // Only include if we have at least 1 water device in the config.
if (prefs.device_consumption_water?.length) { if (hasWaterDevices) {
const showFloorsNAreas = !prefs.device_consumption_water.some( const showFloorsNAreas = !prefs.device_consumption_water.some(
(d) => d.included_in_stat (d) => d.included_in_stat
); );
view.cards!.push({ section.cards!.push({
title: hass.localize("ui.panel.energy.cards.water_sankey_title"), title: hass.localize("ui.panel.energy.cards.water_sankey_title"),
type: "water-sankey", type: "water-sankey",
collection_key: collectionKey, collection_key: collectionKey,
@@ -81,6 +89,6 @@ export class EnergyWaterViewStrategy extends ReactiveElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"energy-water-view-strategy": EnergyWaterViewStrategy; "water-view-strategy": WaterViewStrategy;
} }
} }

View File

@@ -2,6 +2,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/ha-entities-picker"; import "../../../components/entity/ha-entities-picker";
import "../../../components/ha-alert";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-dialog-footer"; import "../../../components/ha-dialog-footer";
import "../../../components/ha-wa-dialog"; import "../../../components/ha-wa-dialog";
@@ -78,6 +79,16 @@ export class DialogEditHome
@value-changed=${this._favoriteEntitiesChanged} @value-changed=${this._favoriteEntitiesChanged}
></ha-entities-picker> ></ha-entities-picker>
<ha-alert alert-type="info">
${this.hass.localize("ui.panel.home.editor.areas_hint", {
areas_page: html`<a
href="/config/areas?historyBack=1"
@click=${this.closeDialog}
>${this.hass.localize("ui.panel.home.editor.areas_page")}</a
>`,
})}
</ha-alert>
<ha-dialog-footer slot="footer"> <ha-dialog-footer slot="footer">
<ha-button <ha-button
appearance="plain" appearance="plain"
@@ -140,6 +151,11 @@ export class DialogEditHome
ha-entities-picker { ha-entities-picker {
display: block; display: block;
} }
ha-alert {
display: block;
margin-top: var(--ha-space-4);
}
`, `,
]; ];
} }

View File

@@ -13,6 +13,7 @@ import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strateg
import type { Lovelace } from "../lovelace/types"; import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container"; import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
const LIGHT_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = { const LIGHT_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: { strategy: {
@@ -115,6 +116,7 @@ class PanelLight extends LitElement {
this._lovelace this._lovelace
? html` ? html`
<hui-view-container .hass=${this.hass}> <hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view <hui-view
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}

View File

@@ -56,6 +56,19 @@ export function getSuggestedPeriod(
return dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"; return dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour";
} }
function createYAxisLabelFormatter(locale: FrontendLocaleData) {
let previousValue: number | undefined;
return (value: number): string => {
const maximumFractionDigits = Math.max(
1,
-Math.floor(Math.log10(Math.abs(value - (previousValue ?? value) || 1)))
);
previousValue = value;
return formatNumber(value, locale, { maximumFractionDigits });
};
}
export function getCommonOptions( export function getCommonOptions(
start: Date, start: Date,
end: Date, end: Date,
@@ -86,7 +99,7 @@ export function getCommonOptions(
align: "left", align: "left",
}, },
axisLabel: { axisLabel: {
formatter: (value: number) => formatNumber(Math.abs(value), locale), formatter: createYAxisLabelFormatter(locale),
}, },
splitLine: { splitLine: {
show: true, show: true,

View File

@@ -217,6 +217,9 @@ export class HuiEnergyDevicesGraphCard
show: true, show: true,
type: "value", type: "value",
name: "kWh", name: "kWh",
axisPointer: {
show: false,
},
}; };
options.yAxis = { options.yAxis = {
show: true, show: true,
@@ -551,9 +554,12 @@ export class HuiEnergyDevicesGraphCard
e.detail.seriesType === "pie" && e.detail.seriesType === "pie" &&
e.detail.event?.target?.type === "tspan" // label e.detail.event?.target?.type === "tspan" // label
) { ) {
fireEvent(this, "hass-more-info", { const id = (e.detail.data as any).id as string;
entityId: (e.detail.data as any).id as string, if (id !== "untracked") {
}); fireEvent(this, "hass-more-info", {
entityId: id,
});
}
} }
} }

View File

@@ -6,7 +6,10 @@ import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy"; import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import { getEnergyDataCollection } from "../../../../data/energy"; import {
getEnergyDataCollection,
getPowerFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types"; import type { LovelaceCard, LovelaceGridOptions } from "../../types";
@@ -23,6 +26,9 @@ const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
group_by_area: true, group_by_area: true,
}; };
// Minimum power threshold in kW to display a device node
const MIN_POWER_THRESHOLD = 0.01;
interface PowerData { interface PowerData {
solar: number; solar: number;
from_grid: number; from_grid: number;
@@ -232,7 +238,7 @@ class HuiPowerSankeyCard
color: computedStyle color: computedStyle
.getPropertyValue("--energy-grid-return-color") .getPropertyValue("--energy-grid-return-color")
.trim(), .trim(),
index: 2, index: 1,
}); });
if (powerData.battery_to_grid > 0) { if (powerData.battery_to_grid > 0) {
links.push({ links.push({
@@ -251,23 +257,75 @@ class HuiPowerSankeyCard
let untrackedConsumption = homeNode.value; let untrackedConsumption = homeNode.value;
const deviceNodes: Node[] = []; const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {}; const parentLinks: Record<string, string> = {};
// Build a map of device relationships for hierarchy resolution
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentPower(device.stat_rate);
if (value >= MIN_POWER_THRESHOLD) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for power hierarchy
// Walks up the chain to find an ancestor with stat_rate that will be rendered
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) {
return undefined;
}
// If this parent has a stat_rate and will be rendered, use it
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
// Otherwise, continue up the chain
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
prefs.device_consumption.forEach((device, idx) => { prefs.device_consumption.forEach((device, idx) => {
if (!device.stat_rate) { if (!device.stat_rate) {
return; return;
} }
const value = this._getCurrentPower(device.stat_rate); const value = this._getCurrentPower(device.stat_rate);
if (value < 0.01) { if (value < MIN_POWER_THRESHOLD) {
return; return;
} }
// Find the effective parent (may be different from direct parent if parent has no stat_rate)
const effectiveParent = findEffectiveParent(device.included_in_stat);
const node = { const node = {
id: device.stat_rate, id: device.stat_rate,
label: device.name || this._getEntityLabel(device.stat_rate), label: device.name || this._getEntityLabel(device.stat_rate),
value, value,
color: getGraphColorByIndex(idx, computedStyle), color: getGraphColorByIndex(idx, computedStyle),
index: 4, index: 4,
parent: device.included_in_stat, parent: effectiveParent,
}; };
if (node.parent) { if (node.parent) {
parentLinks[node.id] = node.parent; parentLinks[node.id] = node.parent;
@@ -669,33 +727,7 @@ class HuiPowerSankeyCard
// Track this entity for state change detection // Track this entity for state change detection
this._entities.add(entityId); this._entities.add(entityId);
const stateObj = this.hass.states[entityId]; return getPowerFromState(this.hass.states[entityId]) ?? 0;
if (!stateObj) {
return 0;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return 0;
}
// Normalize to kW based on unit of measurement (case-sensitive)
// Supported units: GW, kW, MW, mW, TW, W
const unit = stateObj.attributes.unit_of_measurement;
switch (unit) {
case "W":
return value / 1000;
case "mW":
return value / 1000000;
case "MW":
return value * 1000;
case "GW":
return value * 1000000;
case "TW":
return value * 1000000000;
default:
// Assume kW if no unit or unit is kW
return value;
}
} }
/** /**

View File

@@ -6,11 +6,14 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import type { LineSeriesOption } from "echarts/charts"; import type { LineSeriesOption } from "echarts/charts";
import { graphic } from "echarts"; import { LinearGradient } from "../../../../resources/echarts/echarts";
import "../../../../components/chart/ha-chart-base"; import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy"; import type { EnergyData } from "../../../../data/energy";
import { getEnergyDataCollection } from "../../../../data/energy"; import {
getEnergyDataCollection,
getPowerFromState,
} from "../../../../data/energy";
import type { StatisticValue } from "../../../../data/recorder"; import type { StatisticValue } from "../../../../data/recorder";
import type { FrontendLocaleData } from "../../../../data/translation"; import type { FrontendLocaleData } from "../../../../data/translation";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
@@ -132,7 +135,7 @@ export class HuiPowerSourcesGraphCard
compareEnd compareEnd
), ),
legend: { legend: {
show: true, show: this._config?.show_legend !== false,
type: "custom", type: "custom",
data: legendData, data: legendData,
}, },
@@ -197,6 +200,7 @@ export class HuiPowerSourcesGraphCard
}, },
}; };
const now = Date.now();
Object.keys(statIds).forEach((key, keyIndex) => { Object.keys(statIds).forEach((key, keyIndex) => {
if (statIds[key].stats.length) { if (statIds[key].stats.length) {
const colorHex = computedStyles.getPropertyValue(statIds[key].color); const colorHex = computedStyles.getPropertyValue(statIds[key].color);
@@ -204,7 +208,14 @@ export class HuiPowerSourcesGraphCard
// Echarts is supposed to handle that but it is bugged when you use it together with stacking. // Echarts is supposed to handle that but it is bugged when you use it together with stacking.
// The interpolation breaks the stacking, so this positive/negative is a workaround // The interpolation breaks the stacking, so this positive/negative is a workaround
const { positive, negative } = this._processData( const { positive, negative } = this._processData(
statIds[key].stats.map((id: string) => energyData.stats[id] ?? []) statIds[key].stats.map((id: string) => {
const stats = energyData.stats[id] ?? [];
const currentState = getPowerFromState(this.hass.states[id]);
if (currentState !== undefined) {
stats.push({ start: now, end: now, mean: currentState });
}
return stats;
})
); );
datasets.push({ datasets.push({
...commonSeriesOptions, ...commonSeriesOptions,
@@ -213,7 +224,7 @@ export class HuiPowerSourcesGraphCard
color: colorHex, color: colorHex,
stack: "positive", stack: "positive",
areaStyle: { areaStyle: {
color: new graphic.LinearGradient(0, 0, 0, 1, [ color: new LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`, color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
@@ -235,7 +246,7 @@ export class HuiPowerSourcesGraphCard
color: colorHex, color: colorHex,
stack: "negative", stack: "negative",
areaStyle: { areaStyle: {
color: new graphic.LinearGradient(0, 1, 0, 0, [ color: new LinearGradient(0, 1, 0, 0, [
{ {
offset: 0, offset: 0,
color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`, color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`,
@@ -323,9 +334,9 @@ export class HuiPowerSourcesGraphCard
const negative: [number, number][] = []; const negative: [number, number][] = [];
Object.entries(data).forEach(([x, y]) => { Object.entries(data).forEach(([x, y]) => {
const ts = Number(x); const ts = Number(x);
const meanY = y.reduce((a, b) => a + b, 0) / y.length; const sumY = y.reduce((a, b) => a + b, 0);
positive.push([ts, Math.max(0, meanY)]); positive.push([ts, Math.max(0, sumY)]);
negative.push([ts, Math.min(0, meanY)]); negative.push([ts, Math.min(0, sumY)]);
}); });
return { positive, negative }; return { positive, negative };
} }

View File

@@ -80,12 +80,6 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
throw new Error("Entities need to be an array"); throw new Error("Entities need to be an array");
} }
const computedStyles = getComputedStyle(this);
this._calendars = config!.entities.map((entity, idx) => ({
entity_id: entity,
backgroundColor: getColorByIndex(idx, computedStyles),
}));
if (this._config?.entities !== config.entities) { if (this._config?.entities !== config.entities) {
this._fetchCalendarEvents(); this._fetchCalendarEvents();
} }
@@ -93,6 +87,20 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard {
this._config = { initial_view: "dayGridMonth", ...config }; this._config = { initial_view: "dayGridMonth", ...config };
} }
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
!this.hasUpdated ||
(changedProps.has("_config") && this._config?.entities)
) {
const computedStyles = getComputedStyle(this);
this._calendars = this._config!.entities.map((entity, idx) => ({
entity_id: entity,
backgroundColor: getColorByIndex(idx, computedStyles),
}));
}
}
public getCardSize(): number { public getCardSize(): number {
return 12; return 12;
} }

View File

@@ -96,8 +96,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
`; `;
} }
const entityState = Number(stateObj.state);
if (stateObj.state === UNAVAILABLE) { if (stateObj.state === UNAVAILABLE) {
return html` return html`
<hui-warning <hui-warning
@@ -164,7 +162,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
.unit_of_measurement || .unit_of_measurement ||
""} ""}
style=${styleMap({ style=${styleMap({
"--gauge-color": this._computeSeverity(entityState), "--gauge-color": this._computeSeverity(Number(valueToDisplay)),
})} })}
.needle=${this._config!.needle} .needle=${this._config!.needle}
.levels=${this._config!.needle ? this._severityLevels() : undefined} .levels=${this._config!.needle ? this._severityLevels() : undefined}

View File

@@ -238,6 +238,7 @@ export interface WaterSankeyCardConfig extends EnergyCardBaseConfig {
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig { export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
type: "power-sources-graph"; type: "power-sources-graph";
title?: string; title?: string;
show_legend?: boolean;
} }
export interface PowerSankeyCardConfig extends EnergyCardBaseConfig { export interface PowerSankeyCardConfig extends EnergyCardBaseConfig {

View File

@@ -98,17 +98,32 @@ class HuiWaterSankeyCard
const nodes: Node[] = []; const nodes: Node[] = [];
const links: Link[] = []; const links: Link[] = [];
// Calculate total water consumption from all devices // Calculate total water consumption from all sources or devices
let totalWaterConsumption = 0; const totalDownstreamConsumption = prefs.device_consumption_water.reduce(
prefs.device_consumption_water.forEach((device) => { (total, device) => {
const value =
device.stat_consumption in this._data!.stats
? calculateStatisticSumGrowth(
this._data!.stats[device.stat_consumption]
) || 0
: 0;
return total + value;
},
0
);
const totalSourceSupply = waterSources.reduce((total, source) => {
const value = const value =
device.stat_consumption in this._data!.stats source.stat_energy_from in this._data!.stats
? calculateStatisticSumGrowth( ? calculateStatisticSumGrowth(
this._data!.stats[device.stat_consumption] this._data!.stats[source.stat_energy_from]
) || 0 ) || 0
: 0; : 0;
totalWaterConsumption += value; return total + value;
}); }, 0);
const totalWaterConsumption = Math.max(
totalDownstreamConsumption,
totalSourceSupply
);
// Create home/consumption node // Create home/consumption node
const homeNode: Node = { const homeNode: Node = {

View File

@@ -21,8 +21,8 @@ export const computeLovelaceEntityName = (
if (!config) { if (!config) {
return stateObj ? computeStateName(stateObj) : ""; return stateObj ? computeStateName(stateObj) : "";
} }
if (typeof config === "string") { if (typeof config !== "object") {
return config; return String(config);
} }
if (stateObj) { if (stateObj) {
return hass.formatEntityName(stateObj, config); return hass.formatEntityName(stateObj, config);

View File

@@ -5,10 +5,9 @@ import { computeStateDomain } from "../../../common/entity/compute_state_domain"
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { splitByGroups } from "../../../common/entity/split_by_groups"; import { splitByGroups } from "../../../common/entity/split_by_groups";
import { stripPrefixFromEntityName } from "../../../common/entity/strip_prefix_from_entity_name"; import { stripPrefixFromEntityName } from "../../../common/entity/strip_prefix_from_entity_name";
import { stringCompare } from "../../../common/string/compare"; import { orderCompare, stringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize"; import type { LocalizeFunc } from "../../../common/translations/localize";
import type { AreasDisplayValue } from "../../../components/ha-areas-display-editor"; import type { AreasDisplayValue } from "../../../components/ha-areas-display-editor";
import { areaCompare } from "../../../data/area_registry";
import type { import type {
EnergyPreferences, EnergyPreferences,
GridSourceTypeEnergyPreference, GridSourceTypeEnergyPreference,
@@ -572,13 +571,21 @@ export const generateDefaultViewConfig = (
const areaCards: LovelaceCardConfig[] = []; const areaCards: LovelaceCardConfig[] = [];
const sortedAreas = Object.keys(splittedByAreaDevice.areasWithEntities).sort( const areaIds = Object.keys(areaEntries);
areaCompare(areaEntries, areasPrefs?.order)
);
for (const areaId of sortedAreas) { if (areasPrefs?.order) {
const areaOrder = areasPrefs.order;
areaIds.sort(orderCompare(areaOrder));
}
for (const areaId of areaIds) {
// Skip areas with no entities
if (!(areaId in splittedByAreaDevice.areasWithEntities)) {
continue;
}
const areaEntities = splittedByAreaDevice.areasWithEntities[areaId]; const areaEntities = splittedByAreaDevice.areasWithEntities[areaId];
const area = areaEntries[areaId]; const area = areaEntries[areaId];
areaCards.push( areaCards.push(
...computeCards( ...computeCards(
hass, hass,

View File

@@ -66,6 +66,9 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public narrow?; @property({ type: Boolean, reflect: true }) public narrow?;
@property({ type: Boolean, attribute: "allow-compare" }) public allowCompare =
true;
@state() _startDate?: Date; @state() _startDate?: Date;
@state() _endDate?: Date; @state() _endDate?: Date;
@@ -222,15 +225,17 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
.label=${this.hass.localize("ui.common.menu")} .label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical} .path=${mdiDotsVertical}
></ha-icon-button> ></ha-icon-button>
<ha-check-list-item ${this.allowCompare
left ? html`<ha-check-list-item
@request-selected=${this._toggleCompare} left
.selected=${this._compare} @request-selected=${this._toggleCompare}
> .selected=${this._compare}
${this.hass.localize( >
"ui.panel.lovelace.components.energy_period_selector.compare" ${this.hass.localize(
)} "ui.panel.lovelace.components.energy_period_selector.compare"
</ha-check-list-item> )}
</ha-check-list-item>`
: nothing}
<slot name="overflow-menu"></slot> <slot name="overflow-menu"></slot>
</ha-button-menu> </ha-button-menu>
</div> </div>

View File

@@ -112,6 +112,12 @@ interface ActionItem {
subItems?: SubActionItem[]; subItems?: SubActionItem[];
} }
export interface ExtraActionItem {
icon: string;
labelKey: LocalizeKeys;
action: () => void;
}
interface SubActionItem { interface SubActionItem {
icon: string; icon: string;
key: LocalizeKeys; key: LocalizeKeys;
@@ -127,7 +133,7 @@ interface UndoStackItem {
@customElement("hui-root") @customElement("hui-root")
class HUIRoot extends LitElement { class HUIRoot extends LitElement {
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>; @property({ attribute: false }) public panel?: PanelInfo;
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -140,6 +146,8 @@ class HUIRoot extends LitElement {
prefix: string; prefix: string;
}; };
@property({ attribute: false }) public extraActionItems?: ExtraActionItem[];
@state() private _curView?: number | "hass-unused-entities"; @state() private _curView?: number | "hass-unused-entities";
private _configChangedByUndo = false; private _configChangedByUndo = false;
@@ -347,6 +355,25 @@ class HUIRoot extends LitElement {
}, },
]; ];
// Add extra action items from parent components
if (this.extraActionItems) {
this.extraActionItems.forEach((extraItem) => {
items.push({
icon: extraItem.icon,
key: extraItem.labelKey,
buttonAction: extraItem.action,
overflowAction: (ev: CustomEvent<RequestSelectedDetail>) => {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
extraItem.action();
},
visible: true,
overflow: this.narrow,
});
});
}
const overflowItems = items.filter((i) => i.visible && i.overflow); const overflowItems = items.filter((i) => i.visible && i.overflow);
const overflowCanPromote = const overflowCanPromote =
overflowItems.length === 1 && overflowItems[0].overflow_can_promote; overflowItems.length === 1 && overflowItems[0].overflow_can_promote;
@@ -543,68 +570,72 @@ class HUIRoot extends LitElement {
})} })}
> >
<div class="header"> <div class="header">
<div class="toolbar"> <slot name="toolbar">
<div class="toolbar">
${this._editMode
? html`
<div class="main-title">
${dashboardTitle ||
this.hass!.localize("ui.panel.lovelace.editor.header")}
<ha-icon-button
slot="actionItems"
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_lovelace.edit_title"
)}
.path=${mdiPencil}
class="edit-icon"
@click=${this._editDashboard}
></ha-icon-button>
</div>
<div class="action-items">${this._renderActionItems()}</div>
`
: html`
${isSubview
? html`
<ha-icon-button-arrow-prev
.hass=${this.hass}
slot="navigationIcon"
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${isSubview
? html`
<div class="main-title">${curViewConfig.title}</div>
`
: hasTabViews
? tabs
: html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
</div>
`}
<div class="action-items">${this._renderActionItems()}</div>
`}
</div>
${this._editMode ${this._editMode
? html` ? html`
<div class="main-title"> <div class="tab-bar">
${dashboardTitle || ${tabs}
this.hass!.localize("ui.panel.lovelace.editor.header")}
<ha-icon-button <ha-icon-button
slot="actionItems" slot="nav"
id="add-view"
@click=${this._addView}
.label=${this.hass!.localize( .label=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_lovelace.edit_title" "ui.panel.lovelace.editor.edit_view.add"
)} )}
.path=${mdiPencil} .path=${mdiPlus}
class="edit-icon"
@click=${this._editDashboard}
></ha-icon-button> ></ha-icon-button>
</div> </div>
<div class="action-items">${this._renderActionItems()}</div>
` `
: html` : nothing}
${isSubview </slot>
? html`
<ha-icon-button-arrow-prev
.hass=${this.hass}
slot="navigationIcon"
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${isSubview
? html`<div class="main-title">${curViewConfig.title}</div>`
: hasTabViews
? tabs
: html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
</div>
`}
<div class="action-items">${this._renderActionItems()}</div>
`}
</div>
${this._editMode
? html`
<div class="tab-bar">
${tabs}
<ha-icon-button
slot="nav"
id="add-view"
@click=${this._addView}
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.add"
)}
.path=${mdiPlus}
></ha-icon-button>
</div>
`
: nothing}
</div> </div>
<hui-view-container <hui-view-container
class=${this._editMode ? "has-tab-bar" : ""} class=${this._editMode ? "has-tab-bar" : ""}

View File

@@ -5,9 +5,7 @@ import { generateEntityFilter } from "../../../../../common/entity/entity_filter
import { stripPrefixFromEntityName } from "../../../../../common/entity/strip_prefix_from_entity_name"; import { stripPrefixFromEntityName } from "../../../../../common/entity/strip_prefix_from_entity_name";
import { orderCompare } from "../../../../../common/string/compare"; import { orderCompare } from "../../../../../common/string/compare";
import type { AreaRegistryEntry } from "../../../../../data/area_registry"; import type { AreaRegistryEntry } from "../../../../../data/area_registry";
import { areaCompare } from "../../../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../../../data/floor_registry"; import type { FloorRegistryEntry } from "../../../../../data/floor_registry";
import { floorCompare } from "../../../../../data/floor_registry";
import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card"; import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import { supportsAlarmModesCardFeature } from "../../../card-features/hui-alarm-modes-card-feature"; import { supportsAlarmModesCardFeature } from "../../../card-features/hui-alarm-modes-card-feature";
@@ -288,7 +286,11 @@ export const getAreas = (
? areas.filter((area) => !hiddenAreas!.includes(area.area_id)) ? areas.filter((area) => !hiddenAreas!.includes(area.area_id))
: areas.concat(); : areas.concat();
const compare = areaCompare(entries, areasOrder); if (!areasOrder) {
return filteredAreas;
}
const compare = orderCompare(areasOrder);
const sortedAreas = filteredAreas.sort((areaA, areaB) => const sortedAreas = filteredAreas.sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id) compare(areaA.area_id, areaB.area_id)
@@ -302,7 +304,12 @@ export const getFloors = (
floorsOrder?: string[] floorsOrder?: string[]
): FloorRegistryEntry[] => { ): FloorRegistryEntry[] => {
const floors = Object.values(entries); const floors = Object.values(entries);
const compare = floorCompare(entries, floorsOrder);
if (!floorsOrder) {
return floors;
}
const compare = orderCompare(floorsOrder);
return floors.sort((floorA, floorB) => return floors.sort((floorA, floorB) =>
compare(floorA.floor_id, floorB.floor_id) compare(floorA.floor_id, floorB.floor_id)

View File

@@ -40,10 +40,10 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
import("./original-states/original-states-view-strategy"), import("./original-states/original-states-view-strategy"),
"energy-overview": () => "energy-overview": () =>
import("../../energy/strategies/energy-overview-view-strategy"), import("../../energy/strategies/energy-overview-view-strategy"),
"energy-electricity": () => energy: () => import("../../energy/strategies/energy-view-strategy"),
import("../../energy/strategies/energy-electricity-view-strategy"), water: () => import("../../energy/strategies/water-view-strategy"),
"energy-water": () => gas: () => import("../../energy/strategies/gas-view-strategy"),
import("../../energy/strategies/energy-water-view-strategy"), power: () => import("../../energy/strategies/power-view-strategy"),
map: () => import("./map/map-view-strategy"), map: () => import("./map/map-view-strategy"),
iframe: () => import("./iframe/iframe-view-strategy"), iframe: () => import("./iframe/iframe-view-strategy"),
area: () => import("./areas/area-view-strategy"), area: () => import("./areas/area-view-strategy"),

View File

@@ -254,7 +254,12 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
widgetSection.cards!.push({ widgetSection.cards!.push({
type: "weather-forecast", type: "weather-forecast",
entity: weatherEntity, entity: weatherEntity,
forecast_type: "daily", show_forecast: false,
show_current: true,
grid_options: {
columns: 12,
rows: "auto",
},
} as WeatherForecastCardConfig); } as WeatherForecastCardConfig);
} }

View File

@@ -123,6 +123,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
"section-visibility-changed", "section-visibility-changed",
this._sectionVisibilityChanged this._sectionVisibilityChanged
); );
this._showSidebar = Boolean(window.history.state?.sidebar);
} }
disconnectedCallback(): void { disconnectedCallback(): void {
@@ -428,6 +429,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
this._showSidebar = !this._showSidebar; this._showSidebar = !this._showSidebar;
// Add sidebar state to history
window.history.replaceState(
{ ...window.history.state, sidebar: this._showSidebar },
""
);
// Restore scroll position after view updates // Restore scroll position after view updates
this.updateComplete.then(() => { this.updateComplete.then(() => {
const scrollY = this._showSidebar const scrollY = this._showSidebar
@@ -487,6 +494,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
); );
gap: var(--row-gap) var(--column-gap); gap: var(--row-gap) var(--column-gap);
padding: var(--row-gap) 0; padding: var(--row-gap) 0;
align-items: flex-start;
} }
.wrapper.has-sidebar .container { .wrapper.has-sidebar .container {
@@ -507,8 +515,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.wrapper.narrow hui-view-sidebar { .wrapper.narrow hui-view-sidebar {
grid-column: 1 / -1; grid-column: 1 / -1;
padding-bottom: calc( padding-bottom: calc(
var(--ha-space-4) + 56px + var(--ha-space-4) + var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
env(safe-area-inset-bottom)
); );
} }
@@ -518,25 +525,24 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.mobile-tabs { .mobile-tabs {
position: fixed; position: fixed;
bottom: calc(var(--ha-space-4) + env(safe-area-inset-bottom)); bottom: calc(var(--ha-space-3) + var(--safe-area-inset-bottom));
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
padding: 0; padding: 0;
z-index: 1; z-index: 1;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15))
drop-shadow(0 4px 16px rgba(0, 0, 0, 0.1));
} }
.mobile-tabs ha-control-select { .mobile-tabs ha-control-select {
width: max-content; width: max-content;
min-width: 280px; min-width: 280px;
max-width: 90%; max-width: 90%;
--control-select-thickness: 56px; --control-select-thickness: var(--ha-space-14);
--control-select-border-radius: var(--ha-border-radius-6xl); --control-select-border-radius: var(--ha-border-radius-pill);
--control-select-background: var(--card-background-color); --control-select-background: var(--card-background-color);
--control-select-background-opacity: 1; --control-select-background-opacity: 1;
--control-select-color: var(--primary-color); --control-select-color: var(--primary-color);
--control-select-padding: 6px; --control-select-padding: 6px;
box-shadow: rgba(0, 0, 0, 0.3) 0px 4px 10px 0px;
} }
ha-sortable { ha-sortable {
@@ -560,8 +566,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.wrapper.narrow.has-sidebar .content { .wrapper.narrow.has-sidebar .content {
padding-bottom: calc( padding-bottom: calc(
var(--ha-space-4) + 56px + var(--ha-space-4) + var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom)
env(safe-area-inset-bottom)
); );
} }

View File

@@ -1,14 +1,17 @@
import { mdiViewDashboard } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../components/ha-divider"; import "../../components/ha-divider";
import "../../components/ha-icon";
import "../../components/ha-list-item"; import "../../components/ha-list-item";
import "../../components/ha-select"; import "../../components/ha-select";
import "../../components/ha-settings-row"; import "../../components/ha-settings-row";
import "../../components/ha-svg-icon";
import { saveFrontendUserData } from "../../data/frontend"; import { saveFrontendUserData } from "../../data/frontend";
import type { LovelaceDashboard } from "../../data/lovelace/dashboard"; import type { LovelaceDashboard } from "../../data/lovelace/dashboard";
import { fetchDashboards } from "../../data/lovelace/dashboard"; import { fetchDashboards } from "../../data/lovelace/dashboard";
import { getPanelTitle } from "../../data/panel"; import { getPanelIcon, getPanelTitle } from "../../data/panel";
import type { HomeAssistant, PanelInfo } from "../../types"; import type { HomeAssistant, PanelInfo } from "../../types";
import { PANEL_DASHBOARDS } from "../config/lovelace/dashboards/ha-config-lovelace-dashboards"; import { PANEL_DASHBOARDS } from "../config/lovelace/dashboards/ha-config-lovelace-dashboards";
@@ -37,54 +40,57 @@ class HaPickDashboardRow extends LitElement {
<span slot="description"> <span slot="description">
${this.hass.localize("ui.panel.profile.dashboard.description")} ${this.hass.localize("ui.panel.profile.dashboard.description")}
</span> </span>
${this._dashboards <ha-select
? html`<ha-select .label=${this.hass.localize(
.label=${this.hass.localize( "ui.panel.profile.dashboard.dropdown_label"
"ui.panel.profile.dashboard.dropdown_label" )}
)} .value=${value}
.disabled=${!this._dashboards?.length} @selected=${this._dashboardChanged}
.value=${value} naturalMenuWidth
@selected=${this._dashboardChanged} >
naturalMenuWidth <ha-list-item .value=${USE_SYSTEM_VALUE}>
> ${this.hass.localize("ui.panel.profile.dashboard.system")}
<ha-list-item .value=${USE_SYSTEM_VALUE}> </ha-list-item>
${this.hass.localize("ui.panel.profile.dashboard.system")} <ha-divider></ha-divider>
<ha-list-item value="lovelace" graphic="icon">
<ha-svg-icon slot="graphic" .path=${mdiViewDashboard}></ha-svg-icon>
${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
</ha-list-item>
${PANEL_DASHBOARDS.map((panel) => {
const panelInfo = this.hass.panels[panel] as PanelInfo | undefined;
if (!panelInfo) {
return nothing;
}
return html`
<ha-list-item value=${panelInfo.url_path} graphic="icon">
<ha-icon
slot="graphic"
.icon=${getPanelIcon(panelInfo)}
></ha-icon>
${getPanelTitle(this.hass, panelInfo)}
</ha-list-item> </ha-list-item>
<ha-divider></ha-divider> `;
<ha-list-item value="lovelace"> })}
${this.hass.localize("ui.panel.profile.dashboard.lovelace")} ${this._dashboards?.length
</ha-list-item> ? html`
${PANEL_DASHBOARDS.map((panel) => { <ha-divider></ha-divider>
const panelInfo = this.hass.panels[panel] as ${this._dashboards.map((dashboard) => {
| PanelInfo if (!this.hass.user!.is_admin && dashboard.require_admin) {
| undefined; return "";
if (!panelInfo) { }
return nothing; return html`
} <ha-list-item .value=${dashboard.url_path} graphic="icon">
return html` <ha-icon
<ha-list-item value=${panelInfo.url_path}> slot="graphic"
${getPanelTitle(this.hass, panelInfo)} .icon=${dashboard.icon || "mdi:view-dashboard"}
</ha-list-item> ></ha-icon>
`; ${dashboard.title}
})} </ha-list-item>
<ha-divider></ha-divider> `;
${this._dashboards.map((dashboard) => { })}
if (!this.hass.user!.is_admin && dashboard.require_admin) { `
return ""; : nothing}
} </ha-select>
return html`
<ha-list-item .value=${dashboard.url_path}>
${dashboard.title}
</ha-list-item>
`;
})}
</ha-select>`
: html`<ha-select
.label=${this.hass.localize(
"ui.panel.profile.dashboard.dropdown_label"
)}
disabled
></ha-select>`}
</ha-settings-row> </ha-settings-row>
`; `;
} }

View File

@@ -13,6 +13,7 @@ import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strateg
import type { Lovelace } from "../lovelace/types"; import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container"; import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
const SECURITY_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = { const SECURITY_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: { strategy: {
@@ -115,6 +116,7 @@ class PanelSecurity extends LitElement {
this._lovelace this._lovelace
? html` ? html`
<hui-view-container .hass=${this.hass}> <hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view <hui-view
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}

View File

@@ -23,6 +23,12 @@ import { LabelLayout, UniversalTransition } from "echarts/features";
// Note that including the CanvasRenderer or SVGRenderer is a required step // Note that including the CanvasRenderer or SVGRenderer is a required step
import { CanvasRenderer } from "echarts/renderers"; import { CanvasRenderer } from "echarts/renderers";
// Import graphic utilities from zrender for use in charts
// This avoids importing from the full "echarts" package which has a separate registry
// zrender is a direct dependency of echarts and always available
// eslint-disable-next-line import/no-extraneous-dependencies
import LinearGradient from "zrender/lib/graphic/LinearGradient";
import type { import type {
// The series option types are defined with the SeriesOption suffix // The series option types are defined with the SeriesOption suffix
BarSeriesOption, BarSeriesOption,
@@ -75,4 +81,6 @@ echarts.use([
ToolboxComponent, ToolboxComponent,
]); ]);
export { LinearGradient };
export default echarts; export default echarts;

View File

@@ -55,6 +55,18 @@ const renderMarkdown = async (
marked.setOptions(markedOptions); marked.setOptions(markedOptions);
marked.use({
renderer: {
table(...args) {
const defaultRenderer = new marked.Renderer();
// Wrap the table with block element because the property 'overflow'
// cannot be applied to elements of display type 'table'.
// https://www.w3.org/TR/css-overflow-3/#overflow-control
return `<div>${defaultRenderer.table.apply(this, args)}</div>`;
},
},
});
const tokens = marked.lexer(content); const tokens = marked.lexer(content);
return tokens.map((token) => return tokens.map((token) =>
filterXSS(marked.parser([token]), { filterXSS(marked.parser([token]), {

View File

@@ -27,14 +27,27 @@ export const mainStyles = css`
--margin-title-ltr: 0 0 0 24px; --margin-title-ltr: 0 0 0 24px;
--margin-title-rtl: 0 24px 0 0; --margin-title-rtl: 0 24px 0 0;
/* safe-area-insets */ /* Safe area insets */
--safe-area-inset-top: var(--app-safe-area-inset-top, env(safe-area-inset-top, 0)); --safe-area-inset-top: var(--app-safe-area-inset-top, env(safe-area-inset-top, 0px));
--safe-area-inset-bottom: var(--app-safe-area-inset-bottom, env(safe-area-inset-bottom, 0)); --safe-area-inset-bottom: var(--app-safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
--safe-area-inset-left: var(--app-safe-area-inset-left, env(safe-area-inset-left, 0)); --safe-area-inset-left: var(--app-safe-area-inset-left, env(safe-area-inset-left, 0px));
--safe-area-inset-right: var(--app-safe-area-inset-right, env(safe-area-inset-right, 0)); --safe-area-inset-right: var(--app-safe-area-inset-right, env(safe-area-inset-right, 0px));
--safe-area-inset-y: calc(var(--safe-area-inset-top, 0px) + var(--safe-area-inset-bottom, 0px)); /* Safe area inset x and y */
--safe-area-inset-x: calc(var(--safe-area-inset-left, 0px) + var(--safe-area-inset-right, 0px)); --safe-area-inset-x: calc(var(--safe-area-inset-left, 0px) + var(--safe-area-inset-right, 0px));
--safe-area-inset-y: calc(var(--safe-area-inset-top, 0px) + var(--safe-area-inset-bottom, 0px));
/* Offsets for centering elements within asymmetric safe areas */
--safe-area-offset-left: calc(max(var(--safe-area-inset-left, 0px) - var(--safe-area-inset-right, 0px), 0px) / 2);
--safe-area-offset-right: calc(max(var(--safe-area-inset-right, 0px) - var(--safe-area-inset-left, 0px), 0px) / 2);
--safe-area-offset-top: calc(max(var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px), 0px) / 2);
--safe-area-offset-bottom: calc(max(var(--safe-area-inset-bottom, 0px) - var(--safe-area-inset-top, 0px), 0px) / 2);
/* Safe width and height for use instead of 100vw and 100vh
* when working with areas like dialogs which need to fill the entire safe area.
*/
--safe-width: calc(100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right));
--safe-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
} }
`; `;

View File

@@ -56,7 +56,11 @@ export const urlSyncMixin = <
// if we are instead navigating forward, the dialogs are already closed // if we are instead navigating forward, the dialogs are already closed
closeLastDialog(); closeLastDialog();
} }
if ("dialog" in ev.state) { if ("dialogData" in ev.state) {
// if we have dialog data we are closing a dialog with appended state
// so dialog has the change to navigate back to the previous state
closeLastDialog(ev.state);
} else if ("dialog" in ev.state) {
// coming to a dialog // coming to a dialog
// the dialog stack must be empty in this case so this state should be cleaned up // the dialog stack must be empty in this case so this state should be cleaned up
mainWindow.history.back(); mainWindow.history.back();

View File

@@ -830,7 +830,6 @@
"add_new": "Add new area…", "add_new": "Add new area…",
"no_areas": "No areas available", "no_areas": "No areas available",
"no_match": "No areas found for {term}", "no_match": "No areas found for {term}",
"unassigned_areas": "Unassigned areas",
"failed_create_area": "Failed to create area." "failed_create_area": "Failed to create area."
}, },
"floor-picker": { "floor-picker": {
@@ -1392,7 +1391,8 @@
"addon_dashboard": "Add-on dashboard", "addon_dashboard": "Add-on dashboard",
"addon_store": "Add-on store", "addon_store": "Add-on store",
"addon_info": "{addon} info", "addon_info": "{addon} info",
"shortcuts": "[%key:ui::panel::config::info::shortcuts%]" "shortcuts": "[%key:ui::panel::config::info::shortcuts%]",
"labs": "[%key:ui::panel::config::labs::caption%]"
} }
}, },
"filter_placeholder": "Search entities", "filter_placeholder": "Search entities",
@@ -2227,7 +2227,9 @@
"title": "Edit home page", "title": "Edit home page",
"description": "Configure your home page display preferences.", "description": "Configure your home page display preferences.",
"favorite_entities_helper": "Display your favorite entities. Home Assistant will still suggest based on commonly used up to 8 slots.", "favorite_entities_helper": "Display your favorite entities. Home Assistant will still suggest based on commonly used up to 8 slots.",
"save_failed": "Failed to save home page configuration" "save_failed": "Failed to save home page configuration",
"areas_hint": "You can rearrange your floors and areas in the order that best represents your house on the {areas_page}.",
"areas_page": "areas page"
} }
}, },
"my": { "my": {
@@ -2380,6 +2382,7 @@
"name": "Name", "name": "Name",
"icon": "Icon", "icon": "Icon",
"level": "Level", "level": "Level",
"level_helper": "Used to determine the default floor icon. Does not influence the floor order.",
"name_required": "Name is required", "name_required": "Name is required",
"floor_id": "Floor ID", "floor_id": "Floor ID",
"unknown_error": "Unknown error", "unknown_error": "Unknown error",
@@ -2459,7 +2462,7 @@
"introduction2": "To place devices in an area, use the link below to navigate to the integrations page and then click on a configured integration to get to the device cards.", "introduction2": "To place devices in an area, use the link below to navigate to the integrations page and then click on a configured integration to get to the device cards.",
"integrations_page": "Integrations page", "integrations_page": "Integrations page",
"no_areas": "Looks like you have no areas yet!", "no_areas": "Looks like you have no areas yet!",
"unassigned_areas": "Unassigned areas", "other_areas": "Other areas",
"create_area": "Create area", "create_area": "Create area",
"create_floor": "Create floor", "create_floor": "Create floor",
"floor": { "floor": {
@@ -2470,7 +2473,16 @@
}, },
"area_reorder_failed": "Failed to reorder areas", "area_reorder_failed": "Failed to reorder areas",
"area_move_failed": "Failed to move area", "area_move_failed": "Failed to move area",
"floor_reorder_failed": "Failed to reorder floors" "floor_reorder_failed": "Failed to reorder floors",
"reorder": "Reorder"
},
"dialog": {
"reorder_areas_title": "Reorder areas",
"reorder_floors_areas_title": "Reorder floors and areas",
"other_areas": "Other areas",
"reorder_failed": "Failed to save order",
"empty_floor": "No areas on this floor",
"empty_unassigned": "All your areas are assigned to floors"
}, },
"editor": { "editor": {
"create_area": "Create area", "create_area": "Create area",
@@ -4045,6 +4057,7 @@
"other_areas": "Other areas", "other_areas": "Other areas",
"services": "Services", "services": "Services",
"helpers": "Helpers", "helpers": "Helpers",
"entity_hidden": "[%key:ui::panel::config::devices::entities::hidden%]",
"triggers": { "triggers": {
"name": "Triggers", "name": "Triggers",
"header": "When", "header": "When",
@@ -9564,10 +9577,12 @@
} }
}, },
"energy": { "energy": {
"overview": { "title": {
"electricity": "Electricity", "overview": "Summary",
"electricity": "Energy",
"gas": "Gas", "gas": "Gas",
"water": "Water" "water": "Water",
"now": "Now"
}, },
"download_data": "[%key:ui::panel::history::download_data%]", "download_data": "[%key:ui::panel::history::download_data%]",
"configure": "[%key:ui::dialogs::quick-bar::commands::navigation::energy%]", "configure": "[%key:ui::dialogs::quick-bar::commands::navigation::energy%]",

View File

@@ -1,8 +1,14 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
let askWrite; let askWrite;
const HASS_URL = `${location.protocol}//${location.host}`;
describe("token_storage.askWrite", () => { describe("token_storage.askWrite", () => {
beforeEach(() => {
vi.stubGlobal("__HASS_URL__", HASS_URL);
});
afterEach(() => { afterEach(() => {
vi.resetModules(); vi.resetModules();
}); });

View File

@@ -4,9 +4,12 @@ import { FallbackStorage } from "../../../test_helper/local-storage-fallback";
let saveTokens; let saveTokens;
const HASS_URL = `${location.protocol}//${location.host}`;
describe("token_storage.saveTokens", () => { describe("token_storage.saveTokens", () => {
beforeEach(() => { beforeEach(() => {
window.localStorage = new FallbackStorage(); window.localStorage = new FallbackStorage();
vi.stubGlobal("__HASS_URL__", HASS_URL);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -2,6 +2,8 @@ import { describe, it, expect, test, vi, afterEach, beforeEach } from "vitest";
import type { AuthData } from "home-assistant-js-websocket"; import type { AuthData } from "home-assistant-js-websocket";
import { FallbackStorage } from "../../../test_helper/local-storage-fallback"; import { FallbackStorage } from "../../../test_helper/local-storage-fallback";
const HASS_URL = `${location.protocol}//${location.host}`;
describe("token_storage", () => { describe("token_storage", () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal( vi.stubGlobal(
@@ -11,6 +13,7 @@ describe("token_storage", () => {
writeEnabled: undefined, writeEnabled: undefined,
}) })
); );
vi.stubGlobal("__HASS_URL__", HASS_URL);
window.localStorage = new FallbackStorage(); window.localStorage = new FallbackStorage();
}); });

View File

@@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest";
import { floorCompare } from "../../src/data/floor_registry";
import type { FloorRegistryEntry } from "../../src/data/floor_registry";
describe("floorCompare", () => {
describe("floorCompare()", () => {
it("sorts by floor ID alphabetically", () => {
const floors = ["basement", "attic", "ground"];
expect(floors.sort(floorCompare())).toEqual([
"attic",
"basement",
"ground",
]);
});
it("handles numeric strings in natural order", () => {
const floors = ["floor10", "floor2", "floor1"];
expect(floors.sort(floorCompare())).toEqual([
"floor1",
"floor2",
"floor10",
]);
});
});
describe("floorCompare(entries)", () => {
it("sorts by level descending (highest to lowest), then by name", () => {
const entries = {
floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry,
floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry,
floor3: { name: "Basement", level: -1 } as FloorRegistryEntry,
};
const floors = ["floor1", "floor2", "floor3"];
expect(floors.sort(floorCompare(entries))).toEqual([
"floor2",
"floor1",
"floor3",
]);
});
it("treats null level as -9999, placing it at the end", () => {
const entries = {
floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry,
floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry,
floor3: { name: "Unassigned", level: null } as FloorRegistryEntry,
};
const floors = ["floor2", "floor3", "floor1"];
expect(floors.sort(floorCompare(entries))).toEqual([
"floor2",
"floor1",
"floor3",
]);
});
it("sorts by name when levels are equal", () => {
const entries = {
floor1: { name: "Suite B", level: 1 } as FloorRegistryEntry,
floor2: { name: "Suite A", level: 1 } as FloorRegistryEntry,
};
const floors = ["floor1", "floor2"];
expect(floors.sort(floorCompare(entries))).toEqual(["floor2", "floor1"]);
});
it("falls back to floor ID when entry not found", () => {
const entries = {
floor1: { name: "Ground Floor" } as FloorRegistryEntry,
};
const floors = ["xyz", "floor1", "abc"];
expect(floors.sort(floorCompare(entries))).toEqual([
"abc",
"floor1",
"xyz",
]);
});
});
describe("floorCompare(entries, order)", () => {
it("follows order array", () => {
const entries = {
basement: { name: "Basement" } as FloorRegistryEntry,
ground: { name: "Ground Floor" } as FloorRegistryEntry,
first: { name: "First Floor" } as FloorRegistryEntry,
};
const order = ["first", "ground", "basement"];
const floors = ["basement", "first", "ground"];
expect(floors.sort(floorCompare(entries, order))).toEqual([
"first",
"ground",
"basement",
]);
});
it("places items not in order array at the end, sorted by name", () => {
const entries = {
floor1: { name: "First Floor" } as FloorRegistryEntry,
floor2: { name: "Ground Floor" } as FloorRegistryEntry,
floor3: { name: "Basement" } as FloorRegistryEntry,
};
const order = ["floor1"];
const floors = ["floor3", "floor2", "floor1"];
expect(floors.sort(floorCompare(entries, order))).toEqual([
"floor1",
"floor3",
"floor2",
]);
});
});
});