Compare commits

...

23 Commits

Author SHA1 Message Date
Paul Bottein
5ce6419cd8 Add sections for entities and helpers 2025-11-25 14:53:49 +01:00
Paul Bottein
ff28ca11c9 Add unassigned devices view to home dashboard 2025-11-25 11:50:30 +01:00
Aidan Timson
82a3db39fe Use entity name for activity/logbook renderer (#28083) 2025-11-25 12:46:57 +02:00
Paul Bottein
254857b53f Improve sidebar and dashboard management logic (#28019)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-25 10:39:07 +00:00
Simon Lamon
1648be6b83 Rename DialogLovelaceDashboardConfigureStrategy (#28092) 2025-11-25 12:22:18 +02:00
Jan Layola
c4c774c217 Add total consumption to energy usage graph card (#28086)
* Add total consumption to energy usage graph card

* Add "used" suffix to energy usage graph card total

* Apply code review feedback

* Update src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 10:18:29 +00:00
Bram Kragten
6a0aab2088 Add dynamic condition support (#28058)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 11:17:53 +01:00
Aidan Timson
5a5593ec5b Add labels to device info card on device/service/hub page (#28082)
* Fix typo

* Add labels to device info card

* Lock

* Use token

* Add same class

* Apply suggestions from code review

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

* Match codebase style

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 08:47:35 +02:00
Paulus Schoutsen
2d39afdeac Render full chat log in voice debug (#27678) 2025-11-24 22:19:09 -05:00
Paul Bottein
974ac31277 Winter is coming (#28036)
* Winter is coming

* Set max to 30 on mobile

* Change storage key

* Use core

* Add browser level switch for winter mode

* Simplify logic
2025-11-24 22:11:38 +01:00
Petar Petrov
47e98d532d Fix energy distribution card not advancing to next day (#28081) 2025-11-24 20:44:22 +01:00
Jan Bouwhuis
2efc513221 Bump home-assistant-js-websocket to 9.6.0 (#28091) 2025-11-24 20:25:33 +01:00
Aidan Timson
5d820e3046 Fix color of dialog header and expansion panel (#28087) 2025-11-24 17:57:10 +01:00
Wendelin
c3cff3bcd3 Fix add to list item (#28073)
* Fix add to list item

* Remove unused css
2025-11-24 17:55:53 +02:00
Bram Kragten
e65a8a6b66 Add device database toggle to analytics (#27948)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 16:30:26 +01:00
Aidan Timson
318452d6f6 Reset automation sidebar width on double click (#28076) 2025-11-24 14:37:11 +01:00
Wendelin
6890823c31 Make login button full width (#28072) 2025-11-24 10:49:13 +01:00
Simon Lamon
ba9bab38c9 Migrate updates dropdown to ha-dropdown (#28039)
* Migrate upgrade dropdown

* Apply suggestion from @MindFreeze

* Update src/panels/config/core/ha-config-section-updates.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 09:06:55 +00:00
Wendelin
085b3884af Fix add to selected action (#28062) 2025-11-24 11:01:58 +02:00
Simon Lamon
a9c816ed9c Migrate analytics dropdown to ha-dropdown (#28038)
* Migrate analytics dropdown

* Apply suggestion from @MindFreeze

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 08:52:22 +00:00
Nico Wiedemann
c3201eecf3 Show blueprint usage count in overview (#28054)
* Show blueprint usage count in overview

* feat(blueprint): add usage count functionality and update table rendering

* feat(blueprint): enhance usage count display with interactive chip

* fix(blueprint): convert _handleUsageClick to an arrow function for consistent context binding

* Update src/panels/config/blueprint/ha-blueprint-overview.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 07:07:04 +00:00
dependabot[bot]
bd67a3b90a Bump actions/checkout from 5.0.0 to 6.0.0 (#28056) 2025-11-24 07:28:51 +01:00
dependabot[bot]
e59ab45e60 Bump github/codeql-action from 4.31.3 to 4.31.4 (#28057) 2025-11-24 07:13:43 +01:00
68 changed files with 2815 additions and 685 deletions

View File

@@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: dev
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: master

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
@@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
@@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4

View File

@@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: dev
@@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: master

View File

@@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0

View File

@@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0

View File

@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6

View File

@@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
@@ -91,7 +91,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
@@ -120,7 +120,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Upload Translations
run: |

View File

@@ -112,7 +112,7 @@
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.14",
"home-assistant-js-websocket": "9.5.0",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18",
"js-yaml": "4.1.1",

View File

@@ -2,8 +2,8 @@
import { genClientId } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { keyed } from "lit/directives/keyed";
import { customElement, property, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-button";
@@ -118,6 +118,9 @@ export class HaAuthFlow extends LitElement {
display: block;
margin-top: 16px;
}
.action ha-button {
width: 100%;
}
</style>
<form>${this._renderForm()}</form>
`;

View File

@@ -0,0 +1,84 @@
import {
mdiAmpersand,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiGateOr,
mdiIdentifier,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
export const CONDITION_ICONS = {
device: mdiDevices,
and: mdiAmpersand,
or: mdiGateOr,
not: mdiNotEqualVariant,
state: mdiStateMachine,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
template: mdiCodeBraces,
time: mdiClockOutline,
trigger: mdiIdentifier,
zone: mdiMapMarkerRadius,
};
@customElement("ha-condition-icon")
export class HaConditionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.condition) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = conditionIcon(this.hass, this.condition).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
const domain = computeDomain(this.condition!);
return html`
<ha-svg-icon
.path=${CONDITION_ICONS[this.condition!] ||
FALLBACK_DOMAIN_ICONS[domain]}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-condition-icon": HaConditionIcon;
}
}

View File

@@ -75,11 +75,15 @@ export class HaDialogHeader extends LitElement {
font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-medium);
color: var(--ha-dialog-header-title-color, var(--primary-text-color));
}
.header-subtitle {
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
color: var(
--ha-dialog-header-subtitle-color,
var(--secondary-text-color)
);
}
@media all and (min-width: 450px) and (min-height: 500px) {
.header-bar {

View File

@@ -209,6 +209,7 @@ export class HaExpansionPanel extends LitElement {
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
color: var(--primary-text-color);
}
.container {

View File

@@ -66,7 +66,7 @@ export class HaIconOverflowMenu extends LitElement {
.path=${item.path}
></ha-svg-icon>
${item.label}
</ha-md-menu-item> `
</ha-md-menu-item>`
)}
</ha-md-button-menu>`
: html`
@@ -103,6 +103,7 @@ export class HaIconOverflowMenu extends LitElement {
:host {
display: flex;
justify-content: flex-end;
cursor: initial;
}
div[role="separator"] {
border-right: 1px solid var(--divider-color);

View File

@@ -27,6 +27,7 @@ export interface DisplayItem {
label: string;
description?: string;
disableSorting?: boolean;
disableHiding?: boolean;
}
export interface DisplayValue {
@@ -101,6 +102,7 @@ export class HaItemDisplayEditor extends LitElement {
icon,
iconPath,
disableSorting,
disableHiding,
} = item;
return html`
<ha-md-list-item
@@ -155,18 +157,21 @@ export class HaItemDisplayEditor extends LitElement {
</div>
`
: nothing}
<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
.label=${this.hass.localize(
`ui.components.items-display-editor.${isVisible ? "hide" : "show"}`,
{
label: label,
}
)}
.value=${value}
@click=${this._toggle}
></ha-icon-button>
${!isVisible || !disableHiding
? html`<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
.label=${this.hass.localize(
`ui.components.items-display-editor.${isVisible ? "hide" : "show"}`,
{
label: label,
}
)}
.value=${value}
@click=${this._toggle}
.disabled=${disableHiding || false}
></ha-icon-button>`
: nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon

View File

@@ -36,6 +36,11 @@ export class HaMdMenuItem extends MenuItemEl {
::slotted([slot="headline"]) {
text-wrap: nowrap;
}
:host([disabled]) {
opacity: 1;
--md-menu-item-label-text-color: var(--disabled-text-color);
--md-menu-item-leading-icon-color: var(--disabled-text-color);
}
`,
];
}

View File

@@ -6,7 +6,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getDefaultPanelUrlPath } from "../data/panel";
import { getPanelIcon, getPanelTitle } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
@@ -43,13 +43,8 @@ const createViewNavigationItem = (
const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard",
title:
panel.url_path === getDefaultPanelUrlPath(hass)
? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) ||
panel.title ||
(panel.url_path ? titleCase(panel.url_path) : ""),
icon: getPanelIcon(panel) || "mdi:view-dashboard",
title: getPanelTitle(hass, panel) || "",
});
@customElement("ha-navigation-picker")

View File

@@ -1,22 +1,13 @@
import {
mdiBell,
mdiCalendar,
mdiCellphoneCog,
mdiChartBox,
mdiClipboardList,
mdiCog,
mdiFormatListBulletedType,
mdiHammer,
mdiLightningBolt,
mdiMenu,
mdiMenuOpen,
mdiPlayBoxMultiple,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -33,7 +24,14 @@ import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import { getDefaultPanelUrlPath } from "../data/panel";
import {
FIXED_PANELS,
getDefaultPanelUrlPath,
getPanelIcon,
getPanelIconPath,
getPanelTitle,
SHOW_AFTER_SPACER_PANELS,
} from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs";
@@ -54,8 +52,6 @@ import "./ha-spinner";
import "./ha-svg-icon";
import "./user/ha-user-badge";
const SHOW_AFTER_SPACER = ["config", "developer-tools"];
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = {
@@ -67,18 +63,6 @@ const SORT_VALUE_URL_PATHS = {
config: 11,
};
export const PANEL_ICONS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
history: mdiChartBox,
logbook: mdiFormatListBulletedType,
lovelace: mdiViewDashboard,
map: mdiTooltipAccount,
"media-browser": mdiPlayBoxMultiple,
todo: mdiClipboardList,
};
const panelSorter = (
reverseSort: string[],
defaultPanel: string,
@@ -155,16 +139,23 @@ export const computePanels = memoizeOne(
const beforeSpacer: PanelInfo[] = [];
const afterSpacer: PanelInfo[] = [];
Object.values(panels).forEach((panel) => {
const allPanels = Object.values(panels).filter(
(panel) => !FIXED_PANELS.includes(panel.url_path)
);
allPanels.forEach((panel) => {
const isDefaultPanel = panel.url_path === defaultPanel;
if (
hiddenPanels.includes(panel.url_path) ||
(!panel.title && panel.url_path !== defaultPanel) ||
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path))
!isDefaultPanel &&
(!panel.title ||
hiddenPanels.includes(panel.url_path) ||
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path)))
) {
return;
}
(SHOW_AFTER_SPACER.includes(panel.url_path)
(SHOW_AFTER_SPACER_PANELS.includes(panel.url_path)
? afterSpacer
: beforeSpacer
).push(panel);
@@ -251,10 +242,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return nothing;
}
// Show the supervisor as being part of configuration
const selectedPanel = this.route.path?.startsWith("/hassio/")
? "config"
: this.hass.panelUrl;
const selectedPanel = this.hass.panelUrl;
// prettier-ignore
return html`
@@ -397,9 +385,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _renderAllPanels(selectedPanel: string) {
if (!this._panelOrder || !this._hiddenPanels) {
return html`
<ha-fade-in .delay=${500}
><ha-spinner size="small"></ha-spinner
></ha-fade-in>
<ha-fade-in .delay=${500}>
<ha-spinner size="small"></ha-spinner>
</ha-fade-in>
`;
}
@@ -413,7 +401,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this.hass.locale
);
// prettier-ignore
return html`
<ha-md-list
class="ha-scrollbar"
@@ -422,61 +409,42 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>
${this._renderPanels(beforeSpacer, selectedPanel, defaultPanel)}
${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel, defaultPanel)}
${this._renderExternalConfiguration()}
${this._renderPanels(afterSpacer, selectedPanel)}
${this.hass.user?.is_admin
? this._renderConfiguration(selectedPanel)
: this._renderExternalConfiguration()}
</ha-md-list>
`;
}
private _renderPanels(
panels: PanelInfo[],
selectedPanel: string,
defaultPanel: string
) {
private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
return panels.map((panel) =>
this._renderPanel(
panel.url_path,
panel.url_path === defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon,
panel.url_path === defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
selectedPanel
)
this._renderPanel(panel, panel.url_path === selectedPanel)
);
}
private _renderPanel(
urlPath: string,
title: string | null,
icon: string | null | undefined,
iconPath: string | null | undefined,
selectedPanel: string
) {
return urlPath === "config"
? this._renderConfiguration(title, selectedPanel)
: html`
<ha-md-list-item
.href=${`/${urlPath}`}
type="link"
class=${classMap({
selected: selectedPanel === urlPath,
})}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-md-list-item>
`;
private _renderPanel(panel: PanelInfo, isSelected: boolean) {
const title = getPanelTitle(this.hass, panel);
const urlPath = panel.url_path;
const icon = getPanelIcon(panel);
const iconPath = getPanelIconPath(panel);
return html`
<ha-md-list-item
.href=${`/${urlPath}`}
type="link"
class=${classMap({ selected: isSelected })}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-md-list-item>
`;
}
private _renderDivider() {
@@ -487,10 +455,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return html`<div class="spacer" disabled></div>`;
}
private _renderConfiguration(title: string | null, selectedPanel: string) {
private _renderConfiguration(selectedPanel: string) {
if (!this.hass.user?.is_admin) {
return nothing;
}
const isSelected =
selectedPanel === "config" || this.route.path?.startsWith("/hassio/");
return html`
<ha-md-list-item
class="configuration${selectedPanel === "config" ? " selected" : ""}"
class="configuration ${classMap({ selected: isSelected })}"
type="button"
href="/config"
@mouseenter=${this._itemMouseEnter}
@@ -504,15 +477,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
${this._updatesCount + this._issuesCount}
</span>
`
: ""}
<span class="item-text" slot="headline">${title}</span>
: nothing}
<span class="item-text" slot="headline"
>${this.hass.localize("panel.config")}</span
>
${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0)
? html`
<span class="badge" slot="end"
>${this._updatesCount + this._issuesCount}</span
>
`
: ""}
: nothing}
</ha-md-list-item>
`;
}
@@ -535,19 +510,20 @@ class HaSidebar extends SubscribeMixin(LitElement) {
? html`
<span class="badge" slot="start"> ${notificationCount} </span>
`
: ""}
: nothing}
<span class="item-text" slot="headline"
>${this.hass.localize("ui.notification_drawer.title")}</span
>
${this.alwaysExpand && notificationCount > 0
? html`<span class="badge" slot="end">${notificationCount}</span>`
: ""}
: nothing}
</ha-md-list-item>
`;
}
private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(this.hass);
const isSelected = selectedPanel === "profile";
return html`
<ha-md-list-item
@@ -555,7 +531,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
type="link"
class=${classMap({
user: true,
selected: selectedPanel === "profile",
selected: isSelected,
rtl: isRTL,
})}
@mouseenter=${this._itemMouseEnter}
@@ -566,31 +542,30 @@ class HaSidebar extends SubscribeMixin(LitElement) {
.user=${this.hass.user}
.hass=${this.hass}
></ha-user-badge>
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : ""}</span
>
<span class="item-text" slot="headline">
${this.hass.user ? this.hass.user.name : ""}
</span>
</ha-md-list-item>
`;
}
private _renderExternalConfiguration() {
return html`${!this.hass.user?.is_admin &&
this.hass.auth.external?.config.hasSettingsScreen
? html`
<ha-md-list-item
@click=${this._handleExternalAppConfiguration}
type="button"
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline">
${this.hass.localize("ui.sidebar.external_app_configuration")}
</span>
</ha-md-list-item>
`
: ""}`;
if (!this.hass.auth.external?.config.hasSettingsScreen) {
return nothing;
}
return html`
<ha-md-list-item
@click=${this._handleExternalAppConfiguration}
type="button"
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline">
${this.hass.localize("ui.sidebar.external_app_configuration")}
</span>
</ha-md-list-item>
`;
}
private _handleExternalAppConfiguration(ev: Event) {

View File

@@ -0,0 +1,178 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { subscribeLabFeatures } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
interface Snowflake {
id: number;
left: number;
size: number;
duration: number;
delay: number;
blur: number;
}
@customElement("ha-snowflakes")
export class HaSnowflakes extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _enabled = false;
@state() private _snowflakes: Snowflake[] = [];
private _maxSnowflakes = 50;
public hassSubscribe() {
return [
subscribeLabFeatures(this.hass!.connection, (features) => {
this._enabled =
features.find(
(f) =>
f.domain === "frontend" && f.preview_feature === "winter_mode"
)?.enabled ?? false;
}),
];
}
private _generateSnowflakes() {
if (!this._enabled) {
this._snowflakes = [];
return;
}
const snowflakes: Snowflake[] = [];
for (let i = 0; i < this._maxSnowflakes; i++) {
snowflakes.push({
id: i,
left: Math.random() * 100, // Random position from 0-100%
size: Math.random() * 12 + 8, // Random size between 8-20px
duration: Math.random() * 8 + 8, // Random duration between 8-16s
delay: Math.random() * 8, // Random delay between 0-8s
blur: Math.random() * 1, // Random blur between 0-1px
});
}
this._snowflakes = snowflakes;
}
protected willUpdate(changedProps: Map<string, unknown>) {
super.willUpdate(changedProps);
if (changedProps.has("_enabled")) {
this._generateSnowflakes();
}
}
protected render() {
if (!this._enabled) {
return nothing;
}
const isDark = this.hass?.themes.darkMode ?? false;
return html`
<div class="snowflakes ${isDark ? "dark" : "light"}" aria-hidden="true">
${this._snowflakes.map(
(flake) => html`
<div
class="snowflake ${this.narrow && flake.id >= 30
? "hide-narrow"
: ""}"
style="
left: ${flake.left}%;
font-size: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
filter: blur(${flake.blur}px);
"
>
</div>
`
)}
</div>
`;
}
static readonly styles = css`
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.snowflakes {
position: absolute;
top: -10%;
left: 0;
width: 100%;
height: 110%;
pointer-events: none;
}
.snowflake {
position: absolute;
top: -10%;
opacity: 0.7;
user-select: none;
pointer-events: none;
animation: fall linear infinite;
}
.light .snowflake {
color: #00bcd4;
text-shadow:
0 0 5px #00bcd4,
0 0 10px #00e5ff;
}
.dark .snowflake {
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 {
display: none;
}
@keyframes fall {
0% {
transform: translateY(-10vh) translateX(0);
}
25% {
transform: translateY(30vh) translateX(10px);
}
50% {
transform: translateY(60vh) translateX(-10px);
}
75% {
transform: translateY(85vh) translateX(10px);
}
100% {
transform: translateY(120vh) translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.snowflake {
animation: none;
display: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-snowflakes": HaSnowflakes;
}
}

View File

@@ -5,6 +5,7 @@ export interface AnalyticsPreferences {
diagnostics?: boolean;
usage?: boolean;
statistics?: boolean;
snapshots?: boolean;
}
export interface Analytics {

View File

@@ -214,6 +214,8 @@ export interface PipelineRun {
stage: "ready" | "wake_word" | "stt" | "intent" | "tts" | "done" | "error";
run: PipelineRunStartEvent["data"];
error?: PipelineErrorEvent["data"];
started: Date;
finished?: Date;
wake_word?: PipelineWakeWordStartEvent["data"] &
Partial<PipelineWakeWordEndEvent["data"]> & { done: boolean };
stt?: PipelineSTTStartEvent["data"] &
@@ -235,6 +237,7 @@ export const processEvent = (
stage: "ready",
run: event.data,
events: [event],
started: new Date(event.timestamp),
};
return run;
}
@@ -290,9 +293,14 @@ export const processEvent = (
tts: { ...run.tts!, ...event.data, done: true },
};
} else if (event.type === "run-end") {
run = { ...run, stage: "done" };
run = { ...run, finished: new Date(event.timestamp), stage: "done" };
} else if (event.type === "error") {
run = { ...run, stage: "error", error: event.data };
run = {
...run,
finished: new Date(event.timestamp),
stage: "error",
error: event.data,
};
} else {
run = { ...run };
}

View File

@@ -10,6 +10,7 @@ import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint";
import type { ConditionDescription } from "./condition";
import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script";
@@ -236,6 +237,12 @@ interface BaseCondition {
condition: string;
alias?: string;
enabled?: boolean;
options?: Record<string, unknown>;
}
export interface PlatformCondition extends BaseCondition {
condition: Exclude<string, LegacyCondition["condition"]>;
target?: HassServiceTarget;
}
export interface LogicalCondition extends BaseCondition {
@@ -320,7 +327,7 @@ export type AutomationElementGroup = Record<
{ icon?: string; members?: AutomationElementGroup }
>;
export type Condition =
export type LegacyCondition =
| StateCondition
| NumericStateCondition
| SunCondition
@@ -331,6 +338,8 @@ export type Condition =
| LogicalCondition
| TriggerCondition;
export type Condition = LegacyCondition | PlatformCondition;
export type ConditionWithShorthand =
| Condition
| ShorthandAndConditionList
@@ -608,6 +617,7 @@ export interface ConditionSidebarConfig extends BaseSidebarConfig {
insertAfter: (value: Condition | Condition[]) => boolean;
toggleYamlMode: () => void;
config: Condition;
description?: ConditionDescription;
yamlMode: boolean;
uiSupported: boolean;
}

View File

@@ -18,7 +18,14 @@ import {
} from "../common/string/format-list";
import { hasTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types";
import type { Condition, ForDict, LegacyTrigger, Trigger } from "./automation";
import type {
Condition,
ForDict,
LegacyCondition,
LegacyTrigger,
Trigger,
} from "./automation";
import { getConditionDomain, getConditionObjectId } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import {
localizeDeviceAutomationCondition,
@@ -896,6 +903,39 @@ const tryDescribeCondition = (
}
}
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry
);
if (description) {
return description;
}
const conditionType = condition.condition;
const domain = getConditionDomain(condition.condition);
const type = getConditionObjectId(condition.condition);
return (
hass.localize(
`component.${domain}.conditions.${type}.description_configured`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.unknown_condition`
)
);
};
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
) => {
if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions);
@@ -1287,12 +1327,5 @@ const tryDescribeCondition = (
);
}
return (
hass.localize(
`ui.panel.config.automation.editor.conditions.type.${condition.condition}.label`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.unknown_condition`
)
);
return undefined;
};

228
src/data/chat_log.ts Normal file
View File

@@ -0,0 +1,228 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
export const enum ChatLogEventType {
INITIAL_STATE = "initial_state",
CREATED = "created",
UPDATED = "updated",
DELETED = "deleted",
CONTENT_ADDED = "content_added",
}
export interface ChatLogAttachment {
media_content_id: string;
mime_type: string;
path: string;
}
export interface ChatLogSystemContent {
role: "system";
content: string;
created: Date;
}
export interface ChatLogUserContent {
role: "user";
content: string;
created: Date;
attachments?: ChatLogAttachment[];
}
export interface ChatLogAssistantContent {
role: "assistant";
agent_id: string;
created: Date;
content?: string;
thinking_content?: string;
tool_calls?: any[];
}
export interface ChatLogToolResultContent {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: any;
created: Date;
}
export type ChatLogContent =
| ChatLogSystemContent
| ChatLogUserContent
| ChatLogAssistantContent
| ChatLogToolResultContent;
export interface ChatLog {
conversation_id: string;
continue_conversation: boolean;
content: ChatLogContent[];
created: Date;
}
// Internal wire format types (not exported)
interface ChatLogSystemContentWire {
role: "system";
content: string;
created: string;
}
interface ChatLogUserContentWire {
role: "user";
content: string;
created: string;
attachments?: ChatLogAttachment[];
}
interface ChatLogAssistantContentWire {
role: "assistant";
agent_id: string;
created: string;
content?: string;
thinking_content?: string;
tool_calls?: {
tool_name: string;
tool_args: Record<string, any>;
id: string;
external: boolean;
}[];
}
interface ChatLogToolResultContentWire {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: any;
created: string;
}
type ChatLogContentWire =
| ChatLogSystemContentWire
| ChatLogUserContentWire
| ChatLogAssistantContentWire
| ChatLogToolResultContentWire;
interface ChatLogWire {
conversation_id: string;
continue_conversation: boolean;
content: ChatLogContentWire[];
created: string;
}
const processContent = (content: ChatLogContentWire): ChatLogContent => ({
...content,
created: new Date(content.created),
});
const processChatLog = (chatLog: ChatLogWire): ChatLog => ({
...chatLog,
created: new Date(chatLog.created),
content: chatLog.content.map(processContent),
});
interface ChatLogInitialStateEvent {
event_type: ChatLogEventType.INITIAL_STATE;
data: ChatLogWire;
}
interface ChatLogIndexInitialStateEvent {
event_type: ChatLogEventType.INITIAL_STATE;
data: ChatLogWire[];
}
interface ChatLogCreatedEvent {
conversation_id: string;
event_type: ChatLogEventType.CREATED;
data: ChatLogWire;
}
interface ChatLogUpdatedEvent {
conversation_id: string;
event_type: ChatLogEventType.UPDATED;
data: { chat_log: ChatLogWire };
}
interface ChatLogDeletedEvent {
conversation_id: string;
event_type: ChatLogEventType.DELETED;
data: ChatLogWire;
}
interface ChatLogContentAddedEvent {
conversation_id: string;
event_type: ChatLogEventType.CONTENT_ADDED;
data: { content: ChatLogContentWire };
}
type ChatLogSubscriptionEvent =
| ChatLogInitialStateEvent
| ChatLogUpdatedEvent
| ChatLogDeletedEvent
| ChatLogContentAddedEvent;
type ChatLogIndexSubscriptionEvent =
| ChatLogIndexInitialStateEvent
| ChatLogCreatedEvent
| ChatLogDeletedEvent;
export const subscribeChatLog = (
hass: HomeAssistant,
conversationId: string,
callback: (chatLog: ChatLog | null) => void
): Promise<UnsubscribeFunc> => {
let chatLog: ChatLog | null = null;
return hass.connection.subscribeMessage<ChatLogSubscriptionEvent>(
(event) => {
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
chatLog = processChatLog(event.data);
callback(chatLog);
} else if (event.event_type === ChatLogEventType.CONTENT_ADDED) {
if (chatLog) {
chatLog = {
...chatLog,
content: [...chatLog.content, processContent(event.data.content)],
};
callback(chatLog);
}
} else if (event.event_type === ChatLogEventType.UPDATED) {
chatLog = processChatLog(event.data.chat_log);
callback(chatLog);
} else if (event.event_type === ChatLogEventType.DELETED) {
chatLog = null;
callback(null);
}
},
{
type: "conversation/chat_log/subscribe",
conversation_id: conversationId,
}
);
};
export const subscribeChatLogIndex = (
hass: HomeAssistant,
callback: (chatLogs: ChatLog[]) => void
): Promise<UnsubscribeFunc> => {
let chatLogs: ChatLog[] = [];
return hass.connection.subscribeMessage<ChatLogIndexSubscriptionEvent>(
(event) => {
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
chatLogs = event.data.map(processChatLog);
callback(chatLogs);
} else if (event.event_type === ChatLogEventType.CREATED) {
chatLogs = [...chatLogs, processChatLog(event.data)];
callback(chatLogs);
} else if (event.event_type === ChatLogEventType.DELETED) {
chatLogs = chatLogs.filter(
(chatLog) => chatLog.conversation_id !== event.conversation_id
);
callback(chatLogs);
}
},
{
type: "conversation/chat_log/subscribe_index",
}
);
};

View File

@@ -1,38 +1,15 @@
import {
mdiAmpersand,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiGateOr,
mdiIdentifier,
mdiMapClock,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiShape,
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import { mdiMapClock, mdiShape } from "@mdi/js";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type { AutomationElementGroupCollection } from "./automation";
export const CONDITION_ICONS = {
device: mdiDevices,
and: mdiAmpersand,
or: mdiGateOr,
not: mdiNotEqualVariant,
state: mdiStateMachine,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
template: mdiCodeBraces,
time: mdiClockOutline,
trigger: mdiIdentifier,
zone: mdiMapMarkerRadius,
};
import type { Selector, TargetSelector } from "./selector";
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device: {},
dynamicGroups: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
@@ -62,3 +39,33 @@ export const COLLAPSIBLE_CONDITION_ELEMENTS = [
"ha-automation-condition-not",
"ha-automation-condition-or",
];
export interface ConditionDescription {
target?: TargetSelector["target"];
fields: Record<
string,
{
example?: string | boolean | number;
default?: unknown;
required?: boolean;
selector?: Selector;
context?: Record<string, string>;
}
>;
}
export type ConditionDescriptions = Record<string, ConditionDescription>;
export const subscribeConditions = (
hass: HomeAssistant,
callback: (conditions: ConditionDescriptions) => void
) =>
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
type: "condition_platforms/subscribe",
});
export const getConditionDomain = (condition: string) =>
condition.includes(".") ? computeDomain(condition) : condition;
export const getConditionObjectId = (condition: string) =>
condition.includes(".") ? computeObjectId(condition) : "_";

View File

@@ -775,6 +775,7 @@ export const getEnergyDataCollection = (
hass.locale,
hass.config
);
collection.refresh();
scheduleUpdatePeriod();
},
addHours(

View File

@@ -7,8 +7,8 @@ export interface CoreFrontendUserData {
}
export interface SidebarFrontendUserData {
panelOrder: string[];
hiddenPanels: string[];
panelOrder?: string[];
hiddenPanels?: string[];
}
export interface CoreFrontendSystemData {

View File

@@ -60,6 +60,7 @@ import type {
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { getTriggerDomain, getTriggerObjectId } from "./trigger";
import { getConditionDomain, getConditionObjectId } from "./condition";
/** Icon to use when no icon specified for service. */
export const DEFAULT_SERVICE_ICON = mdiRoomService;
@@ -138,15 +139,25 @@ const resources: {
all?: Promise<Record<string, TriggerIcons>>;
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
};
conditions: {
all?: Promise<Record<string, ConditionIcons>>;
domains: Record<string, ConditionIcons | Promise<ConditionIcons>>;
};
} = {
entity: {},
entity_component: {},
services: { domains: {} },
triggers: { domains: {} },
conditions: { domains: {} },
};
interface IconResources<
T extends ComponentIcons | PlatformIcons | ServiceIcons | TriggerIcons,
T extends
| ComponentIcons
| PlatformIcons
| ServiceIcons
| TriggerIcons
| ConditionIcons,
> {
resources: Record<string, T>;
}
@@ -195,17 +206,24 @@ type TriggerIcons = Record<
{ trigger: string; sections?: Record<string, string> }
>;
type ConditionIcons = Record<
string,
{ condition: string; sections?: Record<string, string> }
>;
export type IconCategory =
| "entity"
| "entity_component"
| "services"
| "triggers";
| "triggers"
| "conditions";
interface CategoryType {
entity: PlatformIcons;
entity_component: ComponentIcons;
services: ServiceIcons;
triggers: TriggerIcons;
conditions: ConditionIcons;
}
export const getHassIcons = async <T extends IconCategory>(
@@ -327,6 +345,13 @@ export const getTriggerIcons = async (
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
getCategoryIcons(hass, "triggers", domain, force);
export const getConditionIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<ConditionIcons | Record<string, ConditionIcons> | undefined> =>
getCategoryIcons(hass, "conditions", domain, force);
// Cache for sorted range keys
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
@@ -526,6 +551,25 @@ export const triggerIcon = async (
return icon;
};
export const conditionIcon = async (
hass: HomeAssistant,
condition: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = getConditionDomain(condition);
const conditionIcons = await getConditionIcons(hass, domain);
if (conditionIcons) {
const conditionName = getConditionObjectId(condition);
const condIcon = conditionIcons[conditionName] as ConditionIcons[string];
icon = condIcon?.condition;
}
if (!icon) {
icon = await domainIcon(hass, domain);
}
return icon;
};
export const serviceIcon = async (
hass: HomeAssistant,
service: string

View File

@@ -1,3 +1,15 @@
import {
mdiAccount,
mdiCalendar,
mdiChartBox,
mdiClipboardList,
mdiFormatListBulletedType,
mdiHammer,
mdiLightningBolt,
mdiPlayBoxMultiple,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js";
import type { HomeAssistant, PanelInfo } from "../types";
/** Panel to show when no panel is picked. */
@@ -60,7 +72,7 @@ export const getPanelTitleFromUrlPath = (
return getPanelTitle(hass, panel);
};
export const getPanelIcon = (panel: PanelInfo): string | null => {
export const getPanelIcon = (panel: PanelInfo): string | undefined => {
if (!panel.icon) {
switch (panel.component_name) {
case "profile":
@@ -70,5 +82,24 @@ export const getPanelIcon = (panel: PanelInfo): string | null => {
}
}
return panel.icon;
return panel.icon || undefined;
};
export const PANEL_ICON_PATHS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
history: mdiChartBox,
logbook: mdiFormatListBulletedType,
lovelace: mdiViewDashboard,
profile: mdiAccount,
map: mdiTooltipAccount,
"media-browser": mdiPlayBoxMultiple,
todo: mdiClipboardList,
};
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
PANEL_ICON_PATHS[panel.url_path];
export const FIXED_PANELS = ["profile", "config"];
export const SHOW_AFTER_SPACER_PANELS = ["developer-tools"];

View File

@@ -75,7 +75,8 @@ export type TranslationCategory =
| "preview_features"
| "selector"
| "services"
| "triggers";
| "triggers"
| "conditions";
export const subscribeTranslationPreferences = (
hass: HomeAssistant,

View File

@@ -2,14 +2,15 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-list-item";
import "../../components/ha-md-list-item";
import "../../components/ha-spinner";
import type {
ExternalEntityAddToActions,
ExternalEntityAddToAction,
ExternalEntityAddToActions,
} from "../../external_app/external_messaging";
import { showToast } from "../../util/toast";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
@customElement("ha-more-info-add-to")
@@ -51,6 +52,7 @@ export class HaMoreInfoAddTo extends LitElement {
app_payload: action.app_payload,
},
});
fireEvent(this, "add-to-action-selected");
} catch (err: any) {
showToast(this, {
message: this.hass.localize(
@@ -91,19 +93,18 @@ export class HaMoreInfoAddTo extends LitElement {
<div class="actions-list">
${this._externalActions.actions.map(
(action) => html`
<ha-list-item
graphic="icon"
<ha-md-list-item
type="button"
.disabled=${!action.enabled}
.action=${action}
.twoline=${!!action.details}
@click=${this._actionSelected}
>
<ha-icon slot="start" .icon=${action.mdi_icon}></ha-icon>
<span>${action.name}</span>
${action.details
? html`<span slot="secondary">${action.details}</span>`
? html`<span slot="supporting-text">${action.details}</span>`
: nothing}
<ha-icon slot="graphic" .icon=${action.mdi_icon}></ha-icon>
</ha-list-item>
</ha-md-list-item>
`
)}
</div>
@@ -129,15 +130,6 @@ export class HaMoreInfoAddTo extends LitElement {
flex-direction: column;
}
ha-list-item {
cursor: pointer;
}
ha-list-item[disabled] {
cursor: not-allowed;
opacity: 0.5;
}
ha-icon {
display: flex;
align-items: center;
@@ -149,4 +141,8 @@ declare global {
interface HTMLElementTagNameMap {
"ha-more-info-add-to": HaMoreInfoAddTo;
}
interface HASSDomEvents {
"add-to-action-selected": undefined;
}
}

View File

@@ -645,6 +645,7 @@ export class MoreInfoDialog extends LitElement {
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
`
: nothing

View File

@@ -1,5 +1,5 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiClose } from "@mdi/js";
import { mdiClose, mdiDotsVertical, mdiRestart } from "@mdi/js";
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -9,18 +9,30 @@ import "../../components/ha-dialog-header";
import "../../components/ha-fade-in";
import "../../components/ha-icon-button";
import "../../components/ha-items-display-editor";
import type { DisplayValue } from "../../components/ha-items-display-editor";
import type {
DisplayItem,
DisplayValue,
} from "../../components/ha-items-display-editor";
import "../../components/ha-md-button-menu";
import "../../components/ha-md-dialog";
import type { HaMdDialog } from "../../components/ha-md-dialog";
import { computePanels, PANEL_ICONS } from "../../components/ha-sidebar";
import "../../components/ha-md-menu-item";
import { computePanels } from "../../components/ha-sidebar";
import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
import {
fetchFrontendUserData,
saveFrontendUserData,
} from "../../data/frontend";
import {
getDefaultPanelUrlPath,
getPanelIcon,
getPanelIconPath,
getPanelTitle,
SHOW_AFTER_SPACER_PANELS,
} from "../../data/panel";
import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { getDefaultPanelUrlPath } from "../../data/panel";
@customElement("dialog-edit-sidebar")
class DialogEditSidebar extends LitElement {
@@ -105,48 +117,53 @@ class DialogEditSidebar extends LitElement {
this.hass.locale
);
// Add default hidden panels that are missing in hidden
const orderSet = new Set(this._order);
const hiddenSet = new Set(this._hidden);
for (const panel of panels) {
if (
panel.default_visible === false &&
!this._order.includes(panel.url_path) &&
!this._hidden.includes(panel.url_path)
!orderSet.has(panel.url_path) &&
!hiddenSet.has(panel.url_path)
) {
this._hidden.push(panel.url_path);
hiddenSet.add(panel.url_path);
}
}
if (hiddenSet.has(defaultPanel)) {
hiddenSet.delete(defaultPanel);
}
const hiddenPanels = Array.from(hiddenSet);
const items = [
...beforeSpacer,
...panels.filter((panel) => this._hidden!.includes(panel.url_path)),
...afterSpacer.filter((panel) => panel.url_path !== "config"),
].map((panel) => ({
...panels.filter((panel) => hiddenPanels.includes(panel.url_path)),
...afterSpacer,
].map<DisplayItem>((panel) => ({
value: panel.url_path,
label:
panel.url_path === defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title || "?",
icon: panel.icon || undefined,
iconPath:
panel.url_path === defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
disableSorting: panel.url_path === "developer-tools",
(getPanelTitle(this.hass, panel) || panel.url_path) +
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
icon: getPanelIcon(panel),
iconPath: getPanelIconPath(panel),
disableSorting: SHOW_AFTER_SPACER_PANELS.includes(panel.url_path),
disableHiding: panel.url_path === defaultPanel,
}));
return html`<ha-items-display-editor
.hass=${this.hass}
.value=${{
order: this._order,
hidden: this._hidden,
}}
.items=${items}
@value-changed=${this._changed}
dont-sort-visible
>
</ha-items-display-editor>`;
return html`
<ha-items-display-editor
.hass=${this.hass}
.value=${{
order: this._order,
hidden: hiddenPanels,
}}
.items=${items}
@value-changed=${this._changed}
dont-sort-visible
>
</ha-items-display-editor>
`;
}
protected render() {
@@ -171,6 +188,22 @@ class DialogEditSidebar extends LitElement {
>${this.hass.localize("ui.sidebar.edit_subtitle")}</span
>`
: nothing}
<ha-md-button-menu
slot="actionItems"
positioning="popover"
anchor-corner="end-end"
menu-corner="start-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item .clickAction=${this._resetToDefaults}>
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.sidebar.reset_to_defaults")}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dialog-header>
<div slot="content" class="content">${this._renderContent()}</div>
<div slot="actions">
@@ -194,6 +227,26 @@ class DialogEditSidebar extends LitElement {
this._hidden = [...hidden];
}
private _resetToDefaults = async () => {
const confirmation = await showConfirmationDialog(this, {
text: this.hass.localize("ui.sidebar.reset_confirmation"),
confirmText: this.hass.localize("ui.common.reset"),
});
if (!confirmation) {
return;
}
this._order = [];
this._hidden = [];
try {
await saveFrontendUserData(this.hass.connection, "sidebar", {});
} catch (err: any) {
this._error = err.message || err;
}
this.closeDialog();
};
private async _save() {
if (this._migrateToUserData) {
const confirmation = await showConfirmationDialog(this, {

View File

@@ -7,6 +7,7 @@ import { listenMediaQuery } from "../common/dom/media_query";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { computeRTLDirection } from "../common/util/compute_rtl";
import "../components/ha-drawer";
import "../components/ha-snowflakes";
import { showNotificationDrawer } from "../dialogs/notifications/show-notification-drawer";
import type { HomeAssistant, Route } from "../types";
import "./partial-panel-resolver";
@@ -50,6 +51,7 @@ export class HomeAssistantMain extends LitElement {
this.hass.panels && this.hass.userData && this.hass.systemData;
return html`
<ha-snowflakes .hass=${this.hass} .narrow=${this.narrow}></ha-snowflakes>
<ha-drawer
.type=${sidebarNarrow ? "modal" : ""}
.open=${sidebarNarrow ? this._drawerOpen : false}

View File

@@ -1,19 +1,29 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stringCompare } from "../../../../../common/string/compare";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { stringCompare } from "../../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { CONDITION_ICONS } from "../../../../../components/ha-condition-icon";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-select";
import type { HaSelect } from "../../../../../components/ha-select";
import type { Condition } from "../../../../../data/automation";
import {
DYNAMIC_PREFIX,
getValueFromDynamic,
isDynamic,
type Condition,
} from "../../../../../data/automation";
import type { ConditionDescriptions } from "../../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
CONDITION_ICONS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../../../data/condition";
import type { Entries, HomeAssistant } from "../../../../../types";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../../types";
import "../../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
import "../../condition/types/ha-automation-condition-and";
@@ -30,7 +40,10 @@ import "../../condition/types/ha-automation-condition-zone";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-condition")
export class HaConditionAction extends LitElement implements ActionElement {
export class HaConditionAction
extends SubscribeMixin(LitElement)
implements ActionElement
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -43,6 +56,8 @@ export class HaConditionAction extends LitElement implements ActionElement {
@property({ type: Boolean, attribute: "indent" }) public indent = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@query("ha-automation-condition-editor")
private _conditionEditor?: HaAutomationConditionEditor;
@@ -50,6 +65,21 @@ export class HaConditionAction extends LitElement implements ActionElement {
return { condition: "state" };
}
protected hassSubscribe() {
return [
subscribeConditions(this.hass, (conditions) =>
this._addConditions(conditions)
),
];
}
private _addConditions(conditions: ConditionDescriptions) {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
}
protected render() {
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
this.action.condition
@@ -64,19 +94,25 @@ export class HaConditionAction extends LitElement implements ActionElement {
"ui.panel.config.automation.editor.conditions.type_select"
)}
.disabled=${this.disabled}
.value=${this.action.condition}
.value=${this.action.condition in this._conditionDescriptions
? `${DYNAMIC_PREFIX}${this.action.condition}`
: this.action.condition}
naturalMenuWidth
@selected=${this._typeChanged}
@closed=${stopPropagation}
>
${this._processedTypes(this.hass.localize).map(
([opt, label, icon]) => html`
${this._processedTypes(
this._conditionDescriptions,
this.hass.localize
).map(
([opt, label, condition]) => html`
<ha-list-item .value=${opt} graphic="icon">
${label}<ha-svg-icon
${label}
<ha-condition-icon
slot="graphic"
.path=${icon}
></ha-svg-icon
></ha-list-item>
.condition=${condition}
></ha-condition-icon>
</ha-list-item>
`
)}
</ha-select>
@@ -88,11 +124,14 @@ export class HaConditionAction extends LitElement implements ActionElement {
? html`
<ha-automation-condition-editor
.condition=${this.action}
.description=${this._conditionDescriptions[this.action.condition]}
.disabled=${this.disabled}
.hass=${this.hass}
@value-changed=${this._conditionChanged}
.narrow=${this.narrow}
.uiSupported=${this._uiSupported(this.action.condition)}
.uiSupported=${this._uiSupported(
this._getType(this.action, this._conditionDescriptions)
)}
.indent=${this.indent}
action
></ha-automation-condition-editor>
@@ -102,19 +141,46 @@ export class HaConditionAction extends LitElement implements ActionElement {
}
private _processedTypes = memoizeOne(
(localize: LocalizeFunc): [string, string, string][] =>
(Object.entries(CONDITION_ICONS) as Entries<typeof CONDITION_ICONS>)
.map(
([condition, icon]) =>
[
condition,
localize(
`ui.panel.config.automation.editor.conditions.type.${condition}.label`
),
icon,
] as [string, string, string]
)
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language))
(
conditionDescriptions: ConditionDescriptions,
localize: LocalizeFunc
): [string, string, string][] => {
const legacy = (
Object.keys(CONDITION_ICONS) as (keyof typeof CONDITION_ICONS)[]
).map(
(condition) =>
[
condition,
localize(
`ui.panel.config.automation.editor.conditions.type.${condition}.label`
),
condition,
] as [string, string, string]
);
const platform = Object.keys(conditionDescriptions).map((condition) => {
const domain = getConditionDomain(condition);
const conditionObjId = getConditionObjectId(condition);
return [
`${DYNAMIC_PREFIX}${condition}`,
localize(`component.${domain}.conditions.${conditionObjId}.name`) ||
condition,
condition,
] as [string, string, string];
});
return [...legacy, ...platform].sort((a, b) =>
stringCompare(a[1], b[1], this.hass.locale.language)
);
}
);
private _getType = memoizeOne(
(condition: Condition, conditionDescriptions: ConditionDescriptions) => {
if (condition.condition in conditionDescriptions) {
return "platform";
}
return condition.condition;
}
);
private _conditionChanged(ev: CustomEvent) {
@@ -132,6 +198,18 @@ export class HaConditionAction extends LitElement implements ActionElement {
return;
}
if (isDynamic(type)) {
const value = getValueFromDynamic(type);
if (value !== this.action.condition) {
fireEvent(this, "value-changed", {
value: {
condition: value,
},
});
}
return;
}
const elClass = customElements.get(
`ha-automation-condition-${type}`
) as CustomElementConstructor & {

View File

@@ -56,12 +56,19 @@ import {
type AutomationElementGroup,
type AutomationElementGroupCollection,
} from "../../../data/automation";
import type { ConditionDescriptions } from "../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS_GROUP,
CONDITION_COLLECTIONS,
CONDITION_ICONS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../data/condition";
import { getServiceIcons, getTriggerIcons } from "../../../data/icons";
import {
getConditionIcons,
getServiceIcons,
getTriggerIcons,
} from "../../../data/icons";
import type { IntegrationManifest } from "../../../data/integration";
import {
domainToName,
@@ -82,6 +89,7 @@ import { isMac } from "../../../util/is_mac";
import { showToast } from "../../../util/toast";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import { PASTE_VALUE } from "./show-add-automation-element-dialog";
import { CONDITION_ICONS } from "../../../components/ha-condition-icon";
const TYPES = {
trigger: { collections: TRIGGER_COLLECTIONS, icons: TRIGGER_ICONS },
@@ -119,7 +127,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
const ACTION_SERVICE_KEYWORDS = ["dynamicGroups", "helpers", "other"];
const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "other"];
@customElement("add-automation-element-dialog")
class DialogAddAutomationElement
@@ -152,6 +160,8 @@ class DialogAddAutomationElement
@state() private _triggerDescriptions: TriggerDescriptions = {};
@state() private _conditionDescriptions: ConditionDescriptions = {};
@query(".items ha-md-list ha-md-list-item")
private _itemsListFirstElement?: HaMdList;
@@ -169,15 +179,15 @@ class DialogAddAutomationElement
this.addKeyboardShortcuts();
this._unsubscribe();
this._fetchManifests();
if (this._params?.type === "action") {
this.hass.loadBackendTranslation("services");
this._fetchManifests();
this._calculateUsedDomains();
getServiceIcons(this.hass);
}
if (this._params?.type === "trigger") {
} else if (this._params?.type === "trigger") {
this.hass.loadBackendTranslation("triggers");
this._fetchManifests();
getTriggerIcons(this.hass);
this._unsub = subscribeTriggers(this.hass, (triggers) => {
this._triggerDescriptions = {
@@ -185,7 +195,17 @@ class DialogAddAutomationElement
...triggers,
};
});
} else if (this._params?.type === "condition") {
this.hass.loadBackendTranslation("conditions");
getConditionIcons(this.hass);
this._unsub = subscribeConditions(this.hass, (conditions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
});
}
this._fullScreen = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
@@ -199,10 +219,7 @@ class DialogAddAutomationElement
public closeDialog() {
this.removeKeyboardShortcuts();
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
this._unsubscribe();
if (this._params) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -219,6 +236,13 @@ class DialogAddAutomationElement
return true;
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
private _getGroups = (
type: AddAutomationElementDialogParams["type"],
group?: string,
@@ -348,8 +372,11 @@ class DialogAddAutomationElement
items.push(
...this._triggers(localize, this._triggerDescriptions, manifests)
);
}
if (type === "action") {
} else if (type === "condition") {
items.push(
...this._conditions(localize, this._conditionDescriptions, manifests)
);
} else if (type === "action") {
items.push(...this._services(localize, services, manifests));
}
return items;
@@ -372,6 +399,7 @@ class DialogAddAutomationElement
localize: LocalizeFunc,
services: HomeAssistant["services"],
triggerDescriptions: TriggerDescriptions,
conditionDescriptions: ConditionDescriptions,
manifests?: DomainManifestLookup
): {
titleKey?: LocalizeKeys;
@@ -383,35 +411,10 @@ class DialogAddAutomationElement
let collectionGroups = Object.entries(collection.groups);
const groups: ListItem[] = [];
if (
type === "action" &&
Object.keys(collection.groups).some((item) =>
ACTION_SERVICE_KEYWORDS.includes(item)
)
) {
groups.push(
...this._serviceGroups(
localize,
services,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
)
);
collectionGroups = collectionGroups.filter(
([key]) => !ACTION_SERVICE_KEYWORDS.includes(key)
);
}
if (
type === "trigger" &&
Object.keys(collection.groups).some((item) =>
ACTION_SERVICE_KEYWORDS.includes(item)
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
@@ -429,7 +432,53 @@ class DialogAddAutomationElement
);
collectionGroups = collectionGroups.filter(
([key]) => !ACTION_SERVICE_KEYWORDS.includes(key)
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "condition" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._conditionGroups(
localize,
conditionDescriptions,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "action" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._serviceGroups(
localize,
services,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
}
@@ -487,10 +536,6 @@ class DialogAddAutomationElement
services: HomeAssistant["services"],
manifests?: DomainManifestLookup
): ListItem[] => {
if (type === "action" && isDynamic(group)) {
return this._services(localize, services, manifests, group);
}
if (type === "trigger" && isDynamic(group)) {
return this._triggers(
localize,
@@ -499,6 +544,17 @@ class DialogAddAutomationElement
group
);
}
if (type === "condition" && isDynamic(group)) {
return this._conditions(
localize,
this._conditionDescriptions,
manifests,
group
);
}
if (type === "action" && isDynamic(group)) {
return this._services(localize, services, manifests, group);
}
const groups = this._getGroups(type, group, collectionIndex);
@@ -688,6 +744,102 @@ class DialogAddAutomationElement
}
);
private _conditionGroups = (
localize: LocalizeFunc,
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
type: "helper" | "other" | undefined
): ListItem[] => {
if (!conditions || !manifests) {
return [];
}
const result: ListItem[] = [];
const addedDomains = new Set<string>();
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
if (
(type === undefined &&
(ENTITY_DOMAINS_MAIN.has(domain) ||
(manifest?.integration_type === "entity" &&
domainUsed &&
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
(type === "helper" && manifest?.integration_type === "helper") ||
(type === "other" &&
!ENTITY_DOMAINS_MAIN.has(domain) &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
(!domainUsed && manifest?.integration_type === "entity") ||
!["helper", "entity"].includes(manifest?.integration_type || "")))
) {
result.push({
icon: html`
<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
brand-fallback
></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _conditions = memoizeOne(
(
localize: LocalizeFunc,
conditions: ConditionDescriptions,
_manifests: DomainManifestLookup | undefined,
group?: string
): ListItem[] => {
if (!conditions) {
return [];
}
const result: ListItem[] = [];
for (const condition of Object.keys(conditions)) {
const domain = getConditionDomain(condition);
const conditionName = getConditionObjectId(condition);
if (group && group !== `${DYNAMIC_PREFIX}${domain}`) {
continue;
}
result.push({
icon: html`
<ha-condition-icon
.hass=${this.hass}
.condition=${condition}
></ha-condition-icon>
`,
key: `${DYNAMIC_PREFIX}${condition}`,
name:
localize(`component.${domain}.conditions.${conditionName}.name`) ||
condition,
description:
localize(
`component.${domain}.conditions.${conditionName}.description`
) || condition,
});
}
return result;
}
);
private _services = memoizeOne(
(
localize: LocalizeFunc,
@@ -832,6 +984,7 @@ class DialogAddAutomationElement
this.hass.localize,
this.hass.services,
this._triggerDescriptions,
this._conditionDescriptions,
this._manifests
);
@@ -1136,6 +1289,7 @@ class DialogAddAutomationElement
super.disconnectedCallback();
window.removeEventListener("resize", this._updateNarrow);
this._removeSearchKeybindings();
this._unsubscribe();
}
private _updateNarrow = () => {

View File

@@ -8,11 +8,13 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Condition } from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import type { ConditionElement } from "./ha-automation-condition-row";
import "./types/ha-automation-condition-platform";
@customElement("ha-automation-condition-editor")
export default class HaAutomationConditionEditor extends LitElement {
@@ -35,6 +37,8 @@ export default class HaAutomationConditionEditor extends LitElement {
@property({ type: Boolean, attribute: "supported" }) public uiSupported =
false;
@property({ attribute: false }) public description?: ConditionDescription;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
@@ -83,16 +87,23 @@ export default class HaAutomationConditionEditor extends LitElement {
`
: html`
<div @value-changed=${this._onUiChanged}>
${dynamicElement(
`ha-automation-condition-${condition.condition}`,
{
hass: this.hass,
condition: condition,
disabled: this.disabled,
optionsInSidebar: this.indent,
narrow: this.narrow,
}
)}
${this.description
? html`<ha-automation-condition-platform
.hass=${this.hass}
.condition=${this.condition}
.description=${this.description}
.disabled=${this.disabled}
></ha-automation-condition-platform>`
: dynamicElement(
`ha-automation-condition-${condition.condition}`,
{
hass: this.hass,
condition: condition,
disabled: this.disabled,
optionsInSidebar: this.indent,
narrow: this.narrow,
}
)}
</div>
`}
</div>

View File

@@ -32,6 +32,7 @@ import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-condition-icon";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
@@ -44,10 +45,8 @@ import type {
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import {
CONDITION_BUILDING_BLOCKS,
CONDITION_ICONS,
} from "../../../../data/condition";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
@@ -130,6 +129,9 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _warnings?: string[];
@property({ attribute: false })
public conditionDescriptions: ConditionDescriptions = {};
@property({ type: Boolean, attribute: "sidebar" })
public optionsInSidebar = false;
@@ -179,11 +181,11 @@ export default class HaAutomationConditionRow extends LitElement {
private _renderRow() {
return html`
<ha-svg-icon
<ha-condition-icon
slot="leading-icon"
class="condition-icon"
.path=${CONDITION_ICONS[this.condition.condition]}
></ha-svg-icon>
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
@@ -395,9 +397,14 @@ export default class HaAutomationConditionRow extends LitElement {
<ha-automation-condition-editor
.hass=${this.hass}
.condition=${this.condition}
.description=${this.conditionDescriptions[
this.condition.condition
]}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
.uiSupported=${this._uiSupported(this.condition.condition)}
.uiSupported=${this._uiSupported(
this._getType(this.condition, this.conditionDescriptions)
)}
.narrow=${this.narrow}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-condition-editor>`
@@ -476,7 +483,9 @@ export default class HaAutomationConditionRow extends LitElement {
.hass=${this.hass}
.condition=${this.condition}
.disabled=${this.disabled}
.uiSupported=${this._uiSupported(this.condition.condition)}
.uiSupported=${this._uiSupported(
this._getType(this.condition, this.conditionDescriptions)
)}
indent
.selected=${this._selected}
.narrow=${this.narrow}
@@ -786,7 +795,10 @@ export default class HaAutomationConditionRow extends LitElement {
cut: this._cutCondition,
test: this._testCondition,
config: sidebarCondition,
uiSupported: this._uiSupported(sidebarCondition.condition),
uiSupported: this._uiSupported(
this._getType(sidebarCondition, this.conditionDescriptions)
),
description: this.conditionDescriptions[sidebarCondition.condition],
yamlMode: this._yamlMode,
} satisfies ConditionSidebarConfig);
this._selected = true;
@@ -802,6 +814,16 @@ export default class HaAutomationConditionRow extends LitElement {
}
}
private _getType = memoizeOne(
(condition: Condition, conditionDescriptions: ConditionDescriptions) => {
if (condition.condition in conditionDescriptions) {
return "platform";
}
return condition.condition;
}
);
private _uiSupported = memoizeOne(
(type: string) =>
customElements.get(`ha-automation-condition-${type}`) !== undefined

View File

@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -12,11 +13,18 @@ import "../../../../components/ha-button";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import type {
AutomationClipboard,
Condition,
import {
getValueFromDynamic,
isDynamic,
type AutomationClipboard,
type Condition,
} from "../../../../data/automation";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import type { ConditionDescriptions } from "../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
subscribeConditions,
} from "../../../../data/condition";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
@@ -25,10 +33,9 @@ import {
import { automationRowsStyles } from "../styles";
import "./ha-automation-condition-row";
import type HaAutomationConditionRow from "./ha-automation-condition-row";
import { ensureArray } from "../../../../common/array/ensure-array";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends LitElement {
export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public conditions!: Condition[];
@@ -46,6 +53,8 @@ export default class HaAutomationCondition extends LitElement {
@state() private _rowSortSelected?: number;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@storage({
key: "automationClipboard",
@@ -64,6 +73,26 @@ export default class HaAutomationCondition extends LitElement {
private _conditionKeys = new WeakMap<Condition, string>();
protected hassSubscribe() {
return [
subscribeConditions(this.hass, (conditions) =>
this._addConditions(conditions)
),
];
}
private _addConditions(conditions: ConditionDescriptions) {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("conditions");
}
protected updated(changedProperties: PropertyValues) {
if (!changedProperties.has("conditions")) {
return;
@@ -168,6 +197,7 @@ export default class HaAutomationCondition extends LitElement {
.last=${idx === this.conditions.length - 1}
.totalConditions=${this.conditions.length}
.condition=${cond}
.conditionDescriptions=${this._conditionDescriptions}
.disabled=${this.disabled}
.narrow=${this.narrow}
@duplicate=${this._duplicateCondition}
@@ -237,6 +267,10 @@ export default class HaAutomationCondition extends LitElement {
conditions = this.conditions.concat(
deepClone(this._clipboard!.condition)
);
} else if (isDynamic(value)) {
conditions = this.conditions.concat({
condition: getValueFromDynamic(value),
});
} else {
const condition = value as Condition["condition"];
const elClass = customElements.get(

View File

@@ -0,0 +1,416 @@
import { mdiHelpCircle } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformCondition } from "../../../../../data/automation";
import {
getConditionDomain,
getConditionObjectId,
type ConditionDescription,
} from "../../../../../data/condition";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
field.selector &&
!field.required &&
!("boolean" in field.selector && field.default);
@customElement("ha-automation-condition-platform")
export class HaPlatformCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: PlatformCondition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ type: Boolean }) public disabled = false;
@state() private _checkedKeys = new Set();
@state() private _manifest?: IntegrationManifest;
public static get defaultConfig(): PlatformCondition {
return { condition: "" };
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (!this.hasUpdated) {
this.hass.loadBackendTranslation("conditions");
this.hass.loadBackendTranslation("selector");
}
if (!changedProperties.has("condition")) {
return;
}
const oldValue = changedProperties.get("condition") as
| undefined
| this["condition"];
// Fetch the manifest if we have a condition selected and the condition domain changed.
// If no condition is selected, clear the manifest.
if (this.condition?.condition) {
const domain = getConditionDomain(this.condition.condition);
const oldDomain = getConditionDomain(oldValue?.condition || "");
if (domain !== oldDomain) {
this._fetchManifest(domain);
}
} else {
this._manifest = undefined;
}
}
protected render() {
const domain = getConditionDomain(this.condition.condition);
const conditionName = getConditionObjectId(this.condition.condition);
const description = this.hass.localize(
`component.${domain}.conditions.${conditionName}.description`
);
const conditionDesc = this.description;
const shouldRenderDataYaml = !conditionDesc?.fields;
const hasOptional = Boolean(
conditionDesc?.fields &&
Object.values(conditionDesc.fields).some((field) =>
showOptionalToggle(field)
)
);
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${this._manifest
? html`<a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
${conditionDesc && "target" in conditionDesc
? html`<ha-settings-row narrow>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: nothing}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_secondary"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector
></ha-settings-row>`
: nothing}
${shouldRenderDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize(
"ui.components.service-control.action_data"
)}
.name=${"data"}
.readOnly=${this.disabled}
.defaultValue=${this.condition?.options}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: Object.entries(conditionDesc.fields).map(([fieldName, dataField]) =>
this._renderField(
fieldName,
dataField,
hasOptional,
domain,
conditionName
)
)}
`;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
);
private _renderField = (
fieldName: string,
dataField: ConditionDescription["fields"][string],
hasOptional: boolean,
domain: string | undefined,
conditionName: string | undefined
) => {
const selector = dataField?.selector ?? { text: null };
const showOptional = showOptionalToggle(dataField);
return dataField.selector
? html`<ha-settings-row narrow>
${!showOptional
? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: nothing
: html`<ha-checkbox
.key=${fieldName}
.checked=${this._checkedKeys.has(fieldName) ||
(this.condition?.options &&
this.condition.options[fieldName] !== undefined)}
.disabled=${this.disabled}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading"
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
) || conditionName}</span
>
<span slot="description"
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.description`
)}</span
>
<ha-selector
.disabled=${this.disabled ||
(showOptional &&
!this._checkedKeys.has(fieldName) &&
(!this.condition?.options ||
this.condition.options[fieldName] === undefined))}
.hass=${this.hass}
.selector=${selector}
.context=${this._generateContext(dataField)}
.key=${fieldName}
@value-changed=${this._dataChanged}
.value=${this.condition?.options
? this.condition.options[fieldName]
: undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
></ha-selector>
</ha-settings-row>`
: nothing;
};
private _generateContext(
field: ConditionDescription["fields"][string]
): Record<string, any> | undefined {
if (!field.context) {
return undefined;
}
const context = {};
for (const [context_key, data_key] of Object.entries(field.context)) {
context[context_key] =
data_key === "target"
? this.condition.target
: this.condition.options?.[data_key];
}
return context;
}
private _dataChanged(ev: CustomEvent) {
ev.stopPropagation();
if (ev.detail.isValid === false) {
// Don't clear an object selector that returns invalid YAML
return;
}
const key = (ev.currentTarget as any).key;
const value = ev.detail.value;
if (
this.condition?.options?.[key] === value ||
((!this.condition?.options || !(key in this.condition.options)) &&
(value === "" || value === undefined))
) {
return;
}
const options = { ...this.condition?.options, [key]: value };
if (
value === "" ||
value === undefined ||
(typeof value === "object" && !Object.keys(value).length)
) {
delete options[key];
}
fireEvent(this, "value-changed", {
value: {
...this.condition,
options,
},
});
}
private _targetChanged(ev: CustomEvent): void {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
...this.condition,
target: ev.detail.value,
},
});
}
private _checkboxChanged(ev) {
const checked = ev.currentTarget.checked;
const key = ev.currentTarget.key;
let options;
if (checked) {
this._checkedKeys.add(key);
const field =
this.description &&
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
options = {
...this.condition?.options,
[key]: defaultValue,
};
}
} else {
this._checkedKeys.delete(key);
options = { ...this.condition?.options };
delete options[key];
}
if (options) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
options,
},
});
}
this.requestUpdate("_checkedKeys");
}
private _localizeValueCallback = (key: string) => {
if (!this.condition?.condition) {
return "";
}
return this.hass.localize(
`component.${computeDomain(this.condition.condition)}.selector.${key}`
);
};
private async _fetchManifest(integration: string) {
this._manifest = undefined;
try {
this._manifest = await fetchIntegrationManifest(this.hass, integration);
} catch (_err: any) {
// eslint-disable-next-line no-console
console.log(`Unable to fetch integration manifest for ${integration}`);
// Ignore if loading manifest fails. Probably bad JSON in manifest
}
}
static styles = css`
ha-settings-row {
padding: 0 var(--ha-space-4);
}
ha-settings-row[narrow] {
padding-bottom: var(--ha-space-2);
}
ha-settings-row {
--settings-row-content-width: 100%;
--settings-row-prefix-display: contents;
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-service-picker,
ha-entity-picker,
ha-yaml-editor {
display: block;
margin: 0 var(--ha-space-4);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
p {
margin: 0 var(--ha-space-4);
padding: var(--ha-space-4) 0;
}
:host([hide-picker]) p {
padding-top: 0;
}
.checkbox-spacer {
width: 32px;
}
ha-checkbox {
margin-left: calc(var(--ha-space-4) * -1);
margin-inline-start: calc(var(--ha-space-4) * -1);
margin-inline-end: initial;
}
.help-icon {
color: var(--secondary-text-color);
}
.description {
justify-content: space-between;
display: flex;
align-items: center;
padding-right: 2px;
padding-inline-end: 2px;
padding-inline-start: initial;
}
.description p {
direction: ltr;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-platform": HaPlatformCondition;
}
}

View File

@@ -188,6 +188,7 @@ export default class HaAutomationSidebar extends LitElement {
class="handle ${this._resizing ? "resizing" : ""}"
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleMouseDown}
@dblclick=${this._handleDoubleClick}
@focus=${this._startKeyboardResizing}
@blur=${this._stopKeyboardResizing}
tabindex="0"
@@ -258,6 +259,17 @@ export default class HaAutomationSidebar extends LitElement {
);
};
private _handleDoubleClick = (ev: MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
this._unregisterResizeHandlers();
this._tinykeysUnsub?.();
this._tinykeysUnsub = undefined;
this._resizing = false;
document.body.style.removeProperty("cursor");
fireEvent(this, "sidebar-reset-size");
};
private _startResizing(clientX: number) {
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
@@ -422,5 +434,6 @@ declare global {
deltaInPx: number;
};
"sidebar-resizing-stopped": undefined;
"sidebar-reset-size": undefined;
}
}

View File

@@ -317,6 +317,7 @@ export class HaManualAutomationEditor extends LitElement {
@value-changed=${this._sidebarConfigChanged}
@sidebar-resized=${this._resizeSidebar}
@sidebar-resizing-stopped=${this._stopResizeSidebar}
@sidebar-reset-size=${this._resetSidebarWidth}
></ha-automation-sidebar>
</div>
</div>
@@ -700,6 +701,16 @@ export class HaManualAutomationEditor extends LitElement {
this._prevSidebarWidthPx = undefined;
}
private _resetSidebarWidth(ev: Event) {
ev.stopPropagation();
this._prevSidebarWidthPx = undefined;
this._sidebarWidthPx = SIDEBAR_DEFAULT_WIDTH;
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._sidebarWidthPx}px`
);
}
static get styles(): CSSResultGroup {
return [
saveFabStyles,

View File

@@ -16,11 +16,16 @@ import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import {
testCondition,
type ConditionSidebarConfig,
import type {
LegacyCondition,
ConditionSidebarConfig,
} from "../../../../data/automation";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { testCondition } from "../../../../data/automation";
import {
CONDITION_BUILDING_BLOCKS,
getConditionDomain,
getConditionObjectId,
} from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
@@ -84,14 +89,25 @@ export default class HaAutomationSidebarCondition extends LitElement {
"ui.panel.config.automation.editor.conditions.condition"
);
const domain =
"condition" in this.config.config &&
getConditionDomain(this.config.config.condition);
const conditionName =
"condition" in this.config.config &&
getConditionObjectId(this.config.config.condition);
const title =
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${type}.label`
) || type;
`ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.label`
) ||
this.hass.localize(
`component.${domain}.conditions.${conditionName}.name`
) ||
type;
const description = isBuildingBlock
? this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${type}.description.picker`
`ui.panel.config.automation.editor.conditions.type.${type as LegacyCondition["condition"]}.description.picker`
)
: "";
@@ -282,6 +298,7 @@ export default class HaAutomationSidebarCondition extends LitElement {
class="sidebar-editor"
.hass=${this.hass}
.condition=${this.config.config}
.description=${this.config.description}
.yamlMode=${this.yamlMode}
.uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar}

View File

@@ -43,6 +43,7 @@ import {
} from "../../../data/blueprint";
import { showScriptEditor } from "../../../data/script";
import { findRelated } from "../../../data/search";
import "../../../components/chips/ha-assist-chip";
import {
showAlertDialog,
showConfirmationDialog,
@@ -60,6 +61,7 @@ type BlueprintMetaDataPath = BlueprintMetaData & {
error: boolean;
type: "automation" | "script";
fullpath: string;
usageCount?: number;
};
const createNewFunctions = {
@@ -128,14 +130,20 @@ class HaBlueprintOverview extends LitElement {
})
private _filter = "";
@state() private _usageCounts: Record<string, number> = {};
private _usageCountRequest = 0;
private _processedBlueprints = memoizeOne(
(
blueprints: Record<string, Blueprints>,
localize: LocalizeFunc
localize: LocalizeFunc,
usageCounts: Record<string, number>
): BlueprintMetaDataPath[] => {
const result: any[] = [];
Object.entries(blueprints).forEach(([type, typeBlueprints]) =>
Object.entries(typeBlueprints).forEach(([path, blueprint]) => {
const fullpath = `${type}/${path}`;
if ("error" in blueprint) {
result.push({
name: blueprint.error,
@@ -145,7 +153,8 @@ class HaBlueprintOverview extends LitElement {
),
error: true,
path,
fullpath: `${type}/${path}`,
fullpath,
usageCount: 0,
});
} else {
result.push({
@@ -156,7 +165,8 @@ class HaBlueprintOverview extends LitElement {
),
error: false,
path,
fullpath: `${type}/${path}`,
fullpath,
usageCount: usageCounts[fullpath] || 0,
});
}
})
@@ -189,6 +199,34 @@ class HaBlueprintOverview extends LitElement {
filterable: true,
flex: 2,
},
usage_count: {
title: localize(
"ui.panel.config.blueprint.overview.headers.usage_count"
),
sortable: true,
valueColumn: "usageCount",
type: "numeric",
minWidth: "100px",
maxWidth: "120px",
template: (blueprint) => {
const count = blueprint.usageCount ?? 0;
return html`
<ha-assist-chip
filled
.active=${count > 0}
label=${String(count)}
title=${blueprint.error
? String(count)
: this.hass.localize(
`ui.panel.config.blueprint.overview.view_${blueprint.type}`
)}
?disabled=${blueprint.error}
data-fullpath=${blueprint.fullpath}
@click=${this._handleUsageClick}
></ha-assist-chip>
`;
},
},
fullpath: {
title: "fullpath",
hidden: true,
@@ -266,6 +304,7 @@ class HaBlueprintOverview extends LitElement {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._loadUsageCounts();
if (this.route.path === "/import") {
const url = extractSearchParam("blueprint_url");
navigate("/config/blueprint/dashboard", { replace: true });
@@ -275,6 +314,13 @@ class HaBlueprintOverview extends LitElement {
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("blueprints")) {
this._loadUsageCounts();
}
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
@@ -284,7 +330,11 @@ class HaBlueprintOverview extends LitElement {
.route=${this.route}
.tabs=${configSections.automations}
.columns=${this._columns(this.hass.localize)}
.data=${this._processedBlueprints(this.blueprints, this.hass.localize)}
.data=${this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
)}
id="fullpath"
.noDataText=${this.hass.localize(
"ui.panel.config.blueprint.overview.no_blueprints"
@@ -380,10 +430,51 @@ class HaBlueprintOverview extends LitElement {
fireEvent(this, "reload-blueprints");
}
private async _loadUsageCounts() {
if (!this.blueprints) {
return;
}
const request = ++this._usageCountRequest;
const usageCounts: Record<string, number> = {};
const blueprintList = this._processedBlueprints(
this.blueprints,
this.hass.localize,
{}
);
await Promise.all(
blueprintList.map(async (blueprint) => {
if (blueprint.error) {
usageCounts[blueprint.fullpath] = 0;
return;
}
try {
const related = await findRelated(
this.hass,
`${blueprint.domain}_blueprint`,
blueprint.path
);
const count =
(related.automation?.length || 0) + (related.script?.length || 0);
usageCounts[blueprint.fullpath] = count;
} catch (_err) {
usageCounts[blueprint.fullpath] = 0;
}
})
);
if (request === this._usageCountRequest) {
this._usageCounts = usageCounts;
}
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize
this.hass.localize,
this._usageCounts
).find((b) => b.fullpath === ev.detail.id)!;
if (blueprint.error) {
showAlertDialog(this, {
@@ -397,6 +488,25 @@ class HaBlueprintOverview extends LitElement {
this._createNew(blueprint);
}
private _handleUsageClick = (ev: Event) => {
ev.stopPropagation();
ev.preventDefault();
const target = ev.currentTarget as HTMLElement | null;
const fullpath = target?.dataset.fullpath;
if (!fullpath) {
return;
}
const blueprint = this._processedBlueprints(
this.blueprints,
this.hass.localize,
this._usageCounts
).find((item) => item.fullpath === fullpath);
if (!blueprint || blueprint.error) {
return;
}
this._showUsed(blueprint);
};
private _showUsed = (blueprint: BlueprintMetaDataPath) => {
navigate(
`/config/${blueprint.domain}/dashboard?blueprint=${encodeURIComponent(

View File

@@ -1,14 +1,10 @@
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-analytics";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-checkbox";
import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import type { Analytics } from "../../../data/analytics";
import {
getAnalyticsDetails,
@@ -17,6 +13,8 @@ import {
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-alert";
@customElement("ha-config-analytics")
class ConfigAnalytics extends LitElement {
@@ -34,10 +32,22 @@ class ConfigAnalytics extends LitElement {
: undefined;
return html`
<ha-card outlined>
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.analytics.header") ||
"Home Assistant analytics"}
>
<div class="card-content">
${error ? html`<div class="error">${error}</div>` : ""}
<p>${this.hass.localize("ui.panel.config.analytics.intro")}</p>
${error ? html`<div class="error">${error}</div>` : nothing}
<p>
${this.hass.localize("ui.panel.config.analytics.intro")}
<a
href=${documentationUrl(this.hass, "/integrations/analytics/")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.panel.config.analytics.learn_more")}</a
>.
</p>
<ha-analytics
translation_key_panel="config"
@analytics-preferences-changed=${this._preferencesChanged}
@@ -45,26 +55,59 @@ class ConfigAnalytics extends LitElement {
.analytics=${this._analyticsDetails}
></ha-analytics>
</div>
<div class="card-actions">
<ha-button @click=${this._save}>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.save_button"
)}
</ha-button>
</div>
</ha-card>
<div class="footer">
<ha-button
size="small"
appearance="plain"
href=${documentationUrl(this.hass, "/integrations/analytics/")}
target="_blank"
rel="noreferrer"
>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
${this.hass.localize("ui.panel.config.analytics.learn_more")}
</ha-button>
</div>
${this._analyticsDetails &&
"snapshots" in this._analyticsDetails.preferences
? html`<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.header"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.info"
)}
<a
href=${documentationUrl(this.hass, "/device-database/")}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.learn_more"
)}</a
>.
</p>
<ha-alert
.title=${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.alert.title"
)}
>${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.alert.content"
)}</ha-alert
>
<ha-settings-row>
<span slot="heading" data-for="snapshots">
${this.hass.localize(
`ui.panel.config.analytics.preferences.snapshots.title`
)}
</span>
<span slot="description" data-for="snapshots">
${this.hass.localize(
`ui.panel.config.analytics.preferences.snapshots.description`
)}
</span>
<ha-switch
@change=${this._handleDeviceRowClick}
.checked=${!!this._analyticsDetails?.preferences.snapshots}
.disabled=${this._analyticsDetails === undefined}
name="snapshots"
>
</ha-switch>
</ha-settings-row>
</div>
</ha-card>`
: nothing}
`;
}
@@ -96,11 +139,25 @@ class ConfigAnalytics extends LitElement {
}
}
private _handleDeviceRowClick(ev: Event) {
const target = ev.target as HaSwitch;
this._analyticsDetails = {
...this._analyticsDetails!,
preferences: {
...this._analyticsDetails!.preferences,
snapshots: target.checked,
},
};
this._save();
}
private _preferencesChanged(event: CustomEvent): void {
this._analyticsDetails = {
...this._analyticsDetails!,
preferences: event.detail.preferences,
};
this._save();
}
static get styles(): CSSResultGroup {
@@ -117,21 +174,10 @@ class ConfigAnalytics extends LitElement {
p {
margin-top: 0;
}
.card-actions {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
ha-card:not(:first-of-type) {
margin-top: 24px;
}
.footer {
padding: 32px 0 16px;
text-align: center;
}
ha-button[size="small"] ha-svg-icon {
--mdc-icon-size: 16px;
}
`, // row-reverse so we tab first to "save"
`,
];
}
}

View File

@@ -2,9 +2,7 @@ import { mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-button-menu";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth";
import "../../../layouts/hass-subpage";
@@ -14,6 +12,8 @@ import {
downloadFileSupported,
fileDownload,
} from "../../../util/file_download";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-dropdown";
@customElement("ha-config-section-analytics")
class HaConfigSectionAnalytics extends LitElement {
@@ -33,22 +33,19 @@ class HaConfigSectionAnalytics extends LitElement {
>
${downloadFileSupported(this.hass)
? html`
<ha-button-menu
@action=${this._handleOverflowAction}
<ha-dropdown
@wa-select=${this._handleOverflowAction}
slot="toolbar-icon"
>
<ha-icon-button slot="trigger" .path=${mdiDotsVertical}>
</ha-icon-button>
<ha-list-item graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiDownload}
></ha-svg-icon>
<ha-dropdown-item .value=${"download_device_info"}>
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.analytics.download_device_info"
)}
</ha-list-item>
</ha-button-menu>
</ha-dropdown-item>
</ha-dropdown>
`
: nothing}
<div class="content">
@@ -58,9 +55,16 @@ class HaConfigSectionAnalytics extends LitElement {
`;
}
private async _handleOverflowAction(): Promise<void> {
const signedPath = await getSignedPath(this.hass, "/api/analytics/devices");
fileDownload(signedPath.path);
private async _handleOverflowAction(
ev: CustomEvent<{ item: { value: string } }>
): Promise<void> {
if (ev.detail.item.value === "download_device_info") {
const signedPath = await getSignedPath(
this.hass,
"/api/analytics/devices"
);
fileDownload(signedPath.path);
}
}
static styles = css`

View File

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

View File

@@ -1,7 +1,9 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { stringCompare } from "../../../../common/string/compare";
import { titleCase } from "../../../../common/string/title-case";
import "../../../../components/ha-card";
import type { DeviceRegistryEntry } from "../../../../data/device_registry";
@@ -9,16 +11,61 @@ import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { createSearchParam } from "../../../../common/url/search-params";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import "../../../../components/ha-icon";
import "../../../../components/ha-label";
import type { LabelRegistryEntry } from "../../../../data/label_registry";
import { subscribeLabelRegistry } from "../../../../data/label_registry";
import { computeCssColor } from "../../../../common/color/compute-color";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
@customElement("ha-device-info-card")
export class HaDeviceCard extends LitElement {
export class HaDeviceCard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ type: Boolean }) public narrow = false;
@state() private _labelRegistry?: LabelRegistryEntry[];
private _labelsData = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined,
labelIds: string[],
language: string
): {
map: Map<string, LabelRegistryEntry>;
ids: string[];
} => {
const map = labels
? new Map(labels.map((label) => [label.label_id, label]))
: new Map<string, LabelRegistryEntry>();
const ids = [...labelIds].sort((labelA, labelB) =>
stringCompare(
map.get(labelA)?.name || labelA,
map.get(labelB)?.name || labelB,
language
)
);
return { map, ids };
}
);
public hassSubscribe() {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labelRegistry = labels;
}),
];
}
protected render(): TemplateResult {
const { map: labelMap, ids: labels } = this._labelsData(
this._labelRegistry,
this.device.labels,
this.hass.locale.language
);
return html`
<ha-card
outlined
@@ -58,7 +105,7 @@ export class HaDeviceCard extends LitElement {
<span class="hub"
><a
href="/config/devices/device/${this.device.via_device_id}"
>${this._computeDeviceNameDislay(
>${this._computeDeviceNameDisplay(
this.device.via_device_id
)}</a
></span
@@ -126,6 +173,34 @@ export class HaDeviceCard extends LitElement {
</div>
`
)}
${labels.length > 0
? html`
<div class="extra-info labels">
${labels.map((labelId) => {
const label = labelMap.get(labelId);
const color =
label?.color && typeof label.color === "string"
? computeCssColor(label.color)
: undefined;
return html`
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label?.description}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
${label?.name || labelId}
</ha-label>
`;
})}
</div>
`
: nothing}
<slot></slot>
</div>
<slot name="actions"></slot>
@@ -139,7 +214,7 @@ export class HaDeviceCard extends LitElement {
);
}
private _computeDeviceNameDislay(deviceId) {
private _computeDeviceNameDisplay(deviceId: string) {
const device = this.hass.devices[deviceId];
return device
? computeDeviceNameDisplay(device, this.hass)
@@ -162,8 +237,26 @@ export class HaDeviceCard extends LitElement {
.device {
width: 30%;
}
.labels {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-1);
width: 100%;
max-width: 100%;
}
.labels ha-label {
min-width: 0;
max-width: 100%;
flex: 0 1 auto;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
--ha-label-text-color: var(--primary-text-color);
--ha-label-icon-color: var(--primary-text-color);
}
.extra-info {
margin-top: 8px;
margin-top: var(--ha-space-2);
word-wrap: break-word;
}
.manuf,

View File

@@ -12,7 +12,7 @@ import "../../../lovelace/editor/dashboard-strategy-editor/hui-dashboard-strateg
import type { LovelaceDashboardConfigureStrategyDialogParams } from "./show-dialog-lovelace-dashboard-configure-strategy";
@customElement("dialog-lovelace-dashboard-configure-strategy")
export class DialogLovelaceDashboardDetail extends LitElement {
export class DialogLovelaceDashboardConfigureStrategy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardConfigureStrategyDialogParams;
@@ -97,6 +97,6 @@ export class DialogLovelaceDashboardDetail extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"dialog-lovelace-dashboard-configure-strategy": DialogLovelaceDashboardDetail;
"dialog-lovelace-dashboard-configure-strategy": DialogLovelaceDashboardConfigureStrategy;
}
}

View File

@@ -8,16 +8,13 @@ import "../../../../components/ha-button";
import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import { saveFrontendSystemData } from "../../../../data/frontend";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
@customElement("dialog-lovelace-dashboard-detail")
@@ -61,9 +58,9 @@ export class DialogLovelaceDashboardDetail extends LitElement {
if (!this._params || !this._data) {
return nothing;
}
const defaultPanelUrlPath =
this.hass.systemData?.default_panel || DEFAULT_PANEL;
const titleInvalid = !this._data.title || !this._data.title.trim();
const isLovelaceDashboard = this._params.urlPath === "lovelace";
return html`
<ha-dialog
@@ -88,9 +85,9 @@ export class DialogLovelaceDashboardDetail extends LitElement {
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
: this._params.urlPath === "lovelace"
: isLovelaceDashboard
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_default"
"ui.panel.config.lovelace.dashboards.cant_edit_lovelace"
)
: html`
<ha-form
@@ -119,24 +116,9 @@ export class DialogLovelaceDashboardDetail extends LitElement {
)}
</ha-button>
`
: ""}
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._toggleDefault}
.disabled=${this._params.urlPath === "lovelace" &&
defaultPanelUrlPath === "lovelace"}
>
${this._params.urlPath === defaultPanelUrlPath
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.remove_default"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default"
)}
</ha-button>
: nothing}
`
: ""}
: nothing}
<ha-button
slot="primaryAction"
@click=${this._updateDashboard}
@@ -254,40 +236,6 @@ export class DialogLovelaceDashboardDetail extends LitElement {
};
}
private async _toggleDefault() {
const urlPath = this._params?.urlPath;
if (!urlPath) {
return;
}
const defaultPanel = this.hass.systemData?.default_panel || DEFAULT_PANEL;
// Add warning dialog to saying that this will change the default dashboard for all users
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_title"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
),
text: this.hass.localize(
urlPath === defaultPanel
? "ui.panel.config.lovelace.dashboards.detail.remove_default_confirm_text"
: "ui.panel.config.lovelace.dashboards.detail.set_default_confirm_text"
),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: false,
});
if (!confirm) {
return;
}
saveFrontendSystemData(this.hass.connection, "core", {
...this.hass.systemData,
default_panel: urlPath === defaultPanel ? undefined : urlPath,
});
}
private async _updateDashboard() {
if (this._params?.urlPath && !this._params.dashboard?.id) {
this.closeDialog();

View File

@@ -1,8 +1,9 @@
import {
mdiCheck,
mdiCheckCircleOutline,
mdiDelete,
mdiDotsVertical,
mdiHomeCircleOutline,
mdiHomeEdit,
mdiPencil,
mdiPlus,
} from "@mdi/js";
@@ -10,7 +11,6 @@ import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoize from "memoize-one";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { storage } from "../../../../common/decorators/storage";
import { navigate } from "../../../../common/navigate";
import { stringCompare } from "../../../../common/string/compare";
@@ -29,6 +29,7 @@ import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import { saveFrontendSystemData } from "../../../../data/frontend";
import type { LovelacePanelConfig } from "../../../../data/lovelace";
import type { LovelaceRawConfig } from "../../../../data/lovelace/config/types";
import {
@@ -45,7 +46,11 @@ import {
fetchDashboards,
updateDashboard,
} from "../../../../data/lovelace/dashboard";
import { DEFAULT_PANEL } from "../../../../data/panel";
import {
DEFAULT_PANEL,
getPanelIcon,
getPanelTitle,
} from "../../../../data/panel";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-tabs-subpage-data-table";
@@ -56,12 +61,21 @@ import { lovelaceTabs } from "../ha-config-lovelace";
import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy";
import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail";
export const PANEL_DASHBOARDS = [
"home",
"light",
"security",
"climate",
"energy",
] as string[];
type DataTableItem = Pick<
LovelaceDashboard,
"icon" | "title" | "show_in_sidebar" | "require_admin" | "mode" | "url_path"
> & {
default: boolean;
filename: string;
localized_type: string;
type: string;
};
@@ -112,7 +126,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
state: false,
subscribe: false,
})
private _activeGrouping?: string = "type";
private _activeGrouping?: string = "localized_type";
@storage({
key: "lovelace-dashboards-table-collapsed",
@@ -167,7 +181,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
<ha-svg-icon
.id="default-icon-${dashboard.title}"
style="padding-left: 10px; padding-inline-start: 10px; padding-inline-end: initial; direction: var(--direction);"
.path=${mdiCheckCircleOutline}
.path=${mdiHomeCircleOutline}
></ha-svg-icon>
<ha-tooltip
.for="default-icon-${dashboard.title}"
@@ -183,7 +197,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
},
};
columns.type = {
columns.localized_type = {
title: localize(
"ui.panel.config.lovelace.dashboards.picker.headers.type"
),
@@ -253,7 +267,15 @@ export class HaConfigLovelaceDashboards extends LitElement {
.hass=${this.hass}
narrow
.items=${[
...(this._canEdit(dashboard.url_path)
{
path: mdiHomeEdit,
label: localize(
"ui.panel.config.lovelace.dashboards.picker.set_as_default"
),
action: () => this._handleSetAsDefault(dashboard),
disabled: dashboard.default,
},
...(dashboard.type === "user_created"
? [
{
path: mdiPencil,
@@ -262,10 +284,6 @@ export class HaConfigLovelaceDashboards extends LitElement {
),
action: () => this._handleEdit(dashboard),
},
]
: []),
...(this._canDelete(dashboard.url_path)
? [
{
label: this.hass.localize(
"ui.panel.config.lovelace.dashboards.picker.delete"
@@ -288,92 +306,43 @@ export class HaConfigLovelaceDashboards extends LitElement {
private _getItems = memoize(
(dashboards: LovelaceDashboard[], defaultUrlPath: string | null) => {
const defaultMode = (
this.hass.panels?.lovelace?.config as LovelacePanelConfig
).mode;
const mode = (this.hass.panels?.lovelace?.config as LovelacePanelConfig)
.mode;
const isDefault = defaultUrlPath === "lovelace";
const result: DataTableItem[] = [
{
icon: "mdi:view-dashboard",
title: this.hass.localize("panel.states"),
default: isDefault,
show_in_sidebar: isDefault,
show_in_sidebar: true,
require_admin: false,
url_path: "lovelace",
mode: defaultMode,
filename: defaultMode === "yaml" ? "ui-lovelace.yaml" : "",
type: this._localizeType("built_in"),
mode: mode,
filename: mode === "yaml" ? "ui-lovelace.yaml" : "",
type: "built_in",
localized_type: this._localizeType("built_in"),
},
];
if (isComponentLoaded(this.hass, "energy")) {
result.push({
icon: "mdi:lightning-bolt",
title: this.hass.localize(`ui.panel.config.dashboard.energy.main`),
show_in_sidebar: true,
mode: "storage",
url_path: "energy",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
if (this.hass.panels.light) {
result.push({
icon: this.hass.panels.light.icon || "mdi:lamps",
title: this.hass.localize("panel.light"),
PANEL_DASHBOARDS.forEach((panel) => {
const panelInfo = this.hass.panels[panel];
if (!panel) {
return;
}
const item: DataTableItem = {
icon: getPanelIcon(panelInfo),
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
show_in_sidebar: true,
mode: "storage",
url_path: "light",
url_path: panelInfo.url_path,
filename: "",
default: false,
default: defaultUrlPath === panelInfo.url_path,
require_admin: false,
type: this._localizeType("built_in"),
});
}
if (this.hass.panels.security) {
result.push({
icon: this.hass.panels.security.icon || "mdi:security",
title: this.hass.localize("panel.security"),
show_in_sidebar: true,
mode: "storage",
url_path: "security",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
if (this.hass.panels.climate) {
result.push({
icon: this.hass.panels.climate.icon || "mdi:home-thermometer",
title: this.hass.localize("panel.climate"),
show_in_sidebar: true,
mode: "storage",
url_path: "climate",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
if (this.hass.panels.home) {
result.push({
icon: this.hass.panels.home.icon || "mdi:home",
title: this.hass.localize("panel.home"),
show_in_sidebar: true,
mode: "storage",
url_path: "home",
filename: "",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
type: "built_in",
localized_type: this._localizeType("built_in"),
};
result.push(item);
});
result.push(
...dashboards
@@ -386,7 +355,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
filename: "",
...dashboard,
default: defaultUrlPath === dashboard.url_path,
type: this._localizeType("user_created"),
type: "user_created",
localized_type: this._localizeType("user_created"),
}) satisfies DataTableItem
)
);
@@ -486,20 +456,32 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._openDetailDialog(dashboard, urlPath);
}
private _canDelete(urlPath: string) {
return ![
"lovelace",
"energy",
"light",
"security",
"climate",
"home",
].includes(urlPath);
}
private _handleSetAsDefault = async (item: DataTableItem) => {
if (item.default) {
return;
}
private _canEdit(urlPath: string) {
return !["light", "security", "climate", "home"].includes(urlPath);
}
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.set_default_confirm_text"
),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: false,
});
if (!confirm) {
return;
}
await saveFrontendSystemData(this.hass.connection, "core", {
...this.hass.systemData,
default_panel: item.url_path,
});
};
private _handleDelete = async (item: DataTableItem) => {
const dashboard = this._dashboards.find(
@@ -581,10 +563,6 @@ export class HaConfigLovelaceDashboards extends LitElement {
private async _deleteDashboard(
dashboard: LovelaceDashboard
): Promise<boolean> {
if (!this._canDelete(dashboard.url_path)) {
return false;
}
const confirm = await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.lovelace.dashboards.confirm_delete_title",

View File

@@ -270,6 +270,7 @@ export class HaManualScriptEditor extends LitElement {
@value-changed=${this._sidebarConfigChanged}
@sidebar-resized=${this._resizeSidebar}
@sidebar-resizing-stopped=${this._stopResizeSidebar}
@sidebar-reset-size=${this._resetSidebarWidth}
></ha-automation-sidebar>
</div>
</div>
@@ -618,6 +619,16 @@ export class HaManualScriptEditor extends LitElement {
this._prevSidebarWidthPx = undefined;
}
private _resetSidebarWidth(ev: Event) {
ev.stopPropagation();
this._prevSidebarWidthPx = undefined;
this._sidebarWidthPx = SIDEBAR_DEFAULT_WIDTH;
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._sidebarWidthPx}px`
);
}
static get styles(): CSSResultGroup {
return [
saveFabStyles,

View File

@@ -6,6 +6,7 @@ import {
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time";
import type {
PipelineRunEvent,
@@ -20,6 +21,8 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../types";
import "./assist-render-pipeline-events";
import type { ChatLog } from "../../../../data/chat_log";
import { subscribeChatLog } from "../../../../data/chat_log";
@customElement("assist-pipeline-debug")
export class AssistPipelineDebug extends LitElement {
@@ -37,8 +40,12 @@ export class AssistPipelineDebug extends LitElement {
@state() private _events?: PipelineRunEvent[];
@state() private _chatLog?: ChatLog;
private _unsubRefreshEventsID?: number;
private _unsubChatLogUpdates?: Promise<UnsubscribeFunc>;
protected render() {
return html`<hass-subpage
.narrow=${this.narrow}
@@ -106,6 +113,7 @@ export class AssistPipelineDebug extends LitElement {
? html`<assist-render-pipeline-events
.hass=${this.hass}
.events=${this._events}
.chatLog=${this._chatLog}
></assist-render-pipeline-events>`
: ""}
</div>
@@ -120,6 +128,10 @@ export class AssistPipelineDebug extends LitElement {
clearRefresh = true;
}
if (changedProperties.has("_runId")) {
if (this._unsubChatLogUpdates) {
this._unsubChatLogUpdates.then((unsub) => unsub());
this._unsubChatLogUpdates = undefined;
}
this._fetchEvents();
clearRefresh = true;
}
@@ -135,6 +147,10 @@ export class AssistPipelineDebug extends LitElement {
clearTimeout(this._unsubRefreshEventsID);
this._unsubRefreshEventsID = undefined;
}
if (this._unsubChatLogUpdates) {
this._unsubChatLogUpdates.then((unsub) => unsub());
this._unsubChatLogUpdates = undefined;
}
}
private async _fetchRuns() {
@@ -185,8 +201,27 @@ export class AssistPipelineDebug extends LitElement {
});
return;
}
if (!this._events!.length) {
return;
}
if (!this._unsubChatLogUpdates && this._events[0].type === "run-start") {
this._unsubChatLogUpdates = subscribeChatLog(
this.hass,
this._events[0].data.conversation_id,
(chatLog) => {
if (chatLog) {
this._chatLog = chatLog;
} else {
this._unsubChatLogUpdates?.then((unsub) => unsub());
this._unsubChatLogUpdates = undefined;
}
}
);
this._unsubChatLogUpdates.catch(() => {
this._unsubChatLogUpdates = undefined;
});
}
if (
this._events?.length &&
// If the last event is not a finish run event, the run is still ongoing.
// Refresh events automatically.
!["run-end", "error"].includes(this._events[this._events.length - 1].type)

View File

@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { extractSearchParam } from "../../../../common/url/search-params";
import "../../../../components/ha-assist-pipeline-picker";
import "../../../../components/ha-button";
@@ -24,6 +25,8 @@ import type { HomeAssistant } from "../../../../types";
import { AudioRecorder } from "../../../../util/audio-recorder";
import { fileDownload } from "../../../../util/file_download";
import "./assist-render-pipeline-run";
import type { ChatLog } from "../../../../data/chat_log";
import { subscribeChatLog } from "../../../../data/chat_log";
@customElement("assist-pipeline-run-debug")
export class AssistPipelineRunDebug extends LitElement {
@@ -46,6 +49,13 @@ export class AssistPipelineRunDebug extends LitElement {
@state() private _pipelineId?: string =
extractSearchParam("pipeline") || undefined;
@state() private _chatLog?: ChatLog;
private _chatLogSubscription: {
conversationId: string;
unsub: Promise<UnsubscribeFunc>;
} | null = null;
protected render(): TemplateResult {
return html`
<hass-subpage
@@ -178,6 +188,7 @@ export class AssistPipelineRunDebug extends LitElement {
<assist-render-pipeline-run
.hass=${this.hass}
.pipelineRun=${run}
.chatLog=${this._chatLog}
></assist-render-pipeline-run>
`
)}
@@ -186,6 +197,14 @@ export class AssistPipelineRunDebug extends LitElement {
`;
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._chatLogSubscription) {
this._chatLogSubscription.unsub.then((unsub) => unsub());
this._chatLogSubscription = null;
}
}
private get conversationId(): string | null {
return this._pipelineRuns.length === 0
? null
@@ -408,6 +427,32 @@ export class AssistPipelineRunDebug extends LitElement {
added = true;
}
callback(updatedRun);
const conversationId = this.conversationId;
if (
!this._chatLog &&
conversationId &&
(!this._chatLogSubscription ||
this._chatLogSubscription.conversationId !== conversationId)
) {
if (this._chatLogSubscription) {
this._chatLogSubscription.unsub.then((unsub) => unsub());
}
this._chatLogSubscription = {
conversationId,
unsub: subscribeChatLog(this.hass, conversationId, (chatLog) => {
if (chatLog) {
this._chatLog = chatLog;
} else {
this._chatLogSubscription?.unsub.then((unsub) => unsub());
this._chatLogSubscription = null;
}
}),
};
this._chatLogSubscription.unsub.catch(() => {
this._chatLogSubscription = null;
});
}
},
{
...options,

View File

@@ -9,6 +9,7 @@ import type {
import { processEvent } from "../../../../data/assist_pipeline";
import type { HomeAssistant } from "../../../../types";
import "./assist-render-pipeline-run";
import type { ChatLog } from "../../../../data/chat_log";
@customElement("assist-render-pipeline-events")
export class AssistPipelineEvents extends LitElement {
@@ -16,6 +17,8 @@ export class AssistPipelineEvents extends LitElement {
@property({ attribute: false }) public events!: PipelineRunEvent[];
@property({ attribute: false }) public chatLog?: ChatLog;
private _processEvents = memoizeOne(
(events: PipelineRunEvent[]): PipelineRun | undefined => {
let run: PipelineRun | undefined;
@@ -56,6 +59,7 @@ export class AssistPipelineEvents extends LitElement {
<assist-render-pipeline-run
.hass=${this.hass}
.pipelineRun=${run}
.chatLog=${this.chatLog}
></assist-render-pipeline-run>
`;
}

View File

@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-alert";
@@ -12,6 +12,12 @@ import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/ha-yaml-editor";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import type {
ChatLogAssistantContent,
ChatLog,
ChatLogContent,
ChatLogUserContent,
} from "../../../../data/chat_log";
const RUN_DATA = ["pipeline", "language"];
const WAKE_WORD_DATA = ["engine"];
@@ -119,7 +125,7 @@ const dataMinusKeysRender = (
result[key] = data[key];
}
return render
? html`<ha-expansion-panel>
? html`<ha-expansion-panel class="yaml-expansion">
<span slot="header"
>${hass.localize("ui.panel.config.voice_assistants.debug.raw")}</span
>
@@ -134,6 +140,8 @@ export class AssistPipelineDebug extends LitElement {
@property({ attribute: false }) public pipelineRun!: PipelineRun;
@property({ attribute: false }) public chatLog?: ChatLog;
private _audioElement?: HTMLAudioElement;
private get _isPlaying(): boolean {
@@ -147,31 +155,47 @@ export class AssistPipelineDebug extends LitElement {
) || "ready"
: "ready";
const messages: { from: string; text: string }[] = [];
let messages: ChatLogContent[];
const userMessage =
(this.pipelineRun.init_options &&
"text" in this.pipelineRun.init_options.input
? this.pipelineRun.init_options.input.text
: undefined) ||
this.pipelineRun?.stt?.stt_output?.text ||
this.pipelineRun?.intent?.intent_input;
if (this.chatLog) {
messages = this.chatLog.content.filter(
this.pipelineRun.finished
? (content: ChatLogContent) =>
content.role === "system" ||
(content.created >= this.pipelineRun.started &&
content.created <= this.pipelineRun.finished!)
: (content: ChatLogContent) =>
content.role === "system" ||
content.created >= this.pipelineRun.started
);
} else {
messages = [];
if (userMessage) {
messages.push({
from: "user",
text: userMessage,
});
}
// We don't have the chat log everywhere yet, just fallback for now.
const userMessage =
(this.pipelineRun.init_options &&
"text" in this.pipelineRun.init_options.input
? this.pipelineRun.init_options.input.text
: undefined) ||
this.pipelineRun?.stt?.stt_output?.text ||
this.pipelineRun?.intent?.intent_input;
if (
this.pipelineRun?.intent?.intent_output?.response?.speech?.plain?.speech
) {
messages.push({
from: "hass",
text: this.pipelineRun.intent.intent_output.response.speech.plain
.speech,
});
if (userMessage) {
messages.push({
role: "user",
content: userMessage,
} as ChatLogUserContent);
}
if (
this.pipelineRun?.intent?.intent_output?.response?.speech?.plain?.speech
) {
messages.push({
role: "assistant",
content:
this.pipelineRun.intent.intent_output.response.speech.plain.speech,
} as ChatLogAssistantContent);
}
}
return html`
@@ -190,10 +214,58 @@ export class AssistPipelineDebug extends LitElement {
${messages.length > 0
? html`
<div class="messages">
${messages.map(
({ from, text }) => html`
<div class=${`message ${from}`}>${text}</div>
`
${messages.map((content) =>
content.role === "system" || content.role === "tool_result"
? html`
<ha-expansion-panel
class="content-expansion ${content.role}"
>
<div slot="header">
${content.role === "system"
? "System"
: `Result for ${content.tool_name}`}
</div>
${content.role === "system"
? html`<pre>${content.content}</pre>`
: html`
<ha-yaml-editor
read-only
auto-update
.value=${content}
></ha-yaml-editor>
`}
</ha-expansion-panel>
`
: html`
${content.content
? html`
<div class=${`message ${content.role}`}>
${content.content}
</div>
`
: nothing}
${content.role === "assistant" &&
content.tool_calls?.length
? html`
<ha-expansion-panel
class="content-expansion assistant"
>
<span slot="header">
Call
${content.tool_calls.length === 1
? content.tool_calls[0].tool_name
: `${content.tool_calls.length} tools`}
</span>
<ha-yaml-editor
read-only
auto-update
.value=${content.tool_calls}
></ha-yaml-editor>
</ha-expansion-panel>
`
: nothing}
`
)}
</div>
<div style="clear:both"></div>
@@ -442,7 +514,7 @@ export class AssistPipelineDebug extends LitElement {
: ""}
${maybeRenderError(this.pipelineRun, "tts", lastRunStage)}
<ha-card>
<ha-expansion-panel>
<ha-expansion-panel class="yaml-expansion">
<span slot="header"
>${this.hass.localize(
"ui.panel.config.voice_assistants.debug.raw"
@@ -519,12 +591,12 @@ export class AssistPipelineDebug extends LitElement {
.row > div:last-child {
text-align: right;
}
ha-expansion-panel {
.yaml-expansion {
padding-left: 8px;
padding-inline-start: 8px;
padding-inline-end: initial;
}
.card-content ha-expansion-panel {
.card-content .yaml-expansion {
padding-left: 0px;
padding-inline-start: 0px;
padding-inline-end: initial;
@@ -540,27 +612,59 @@ export class AssistPipelineDebug extends LitElement {
margin-top: 8px;
}
.content-expansion {
margin: 8px 0;
border-radius: var(--ha-border-radius-xl);
clear: both;
padding: 0 8px;
--input-fill-color: none;
max-width: calc(100% - 24px);
--expansion-panel-summary-padding: 0px;
--expansion-panel-content-padding: 0px;
}
.content-expansion *[slot="header"] {
font-weight: var(--ha-font-weight-normal);
}
.system {
background-color: var(--success-color);
}
.message {
padding: 8px;
}
.message,
.content-expansion {
font-size: var(--ha-font-size-l);
margin: 8px 0;
padding: 8px;
border-radius: var(--ha-border-radius-xl);
clear: both;
}
.message.user {
.messages pre {
white-space: pre-wrap;
}
.user,
.tool_result {
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
float: var(--float-end);
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--light-primary-color);
color: var(--text-light-primary-color, var(--primary-text-color));
direction: var(--direction);
}
.message.hass {
.message.user,
.content-expansion div[slot="header"] {
text-align: right;
}
.assistant {
margin-right: 24px;
margin-inline-end: 24px;
margin-inline-start: initial;

View File

@@ -11,6 +11,7 @@ import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { navigate } from "../../common/navigate";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import "../../components/entity/state-badge";
@@ -463,15 +464,24 @@ class HaLogbookRenderer extends LitElement {
entityName: string | undefined,
noLink?: boolean
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId);
if (!entityId) {
return entityName || "";
}
const stateObj = this.hass.states[entityId];
const hasState = Boolean(stateObj);
const displayName = hasState
? this.hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME) ||
entityName ||
stateObj.attributes.friendly_name ||
entityId
: entityName || entityId;
if (!hasState) {
return displayName;
}
return noLink
? displayName
: html`<button

View File

@@ -14,6 +14,7 @@ import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import "./common/hui-energy-graph-chip";
import type {
EnergyData,
EnergySumData,
@@ -67,6 +68,8 @@ export class HuiEnergyUsageGraphCard
@state() private _compareEnd?: Date;
@state() private _total?: number;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -100,9 +103,19 @@ export class HuiEnergyUsageGraphCard
return html`
<ha-card>
${this._config.title
? html`<h1 class="card-header">${this._config.title}</h1>`
: ""}
<div class="card-header">
<span>${this._config.title ? this._config.title : nothing}</span>
${this._total
? html`<hui-energy-graph-chip
.tooltip=${this._formatTotal(this._total)}
>
${this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_usage_graph.total_usage",
{ num: formatNumber(this._total, this.hass.locale) }
)}
</hui-energy-graph-chip>`
: nothing}
</div>
<div
class="content ${classMap({
"has-header": !!this._config.title,
@@ -338,6 +351,13 @@ export class HuiEnergyUsageGraphCard
datasets.sort((a, b) => a.order - b.order);
fillDataGapsAndRoundCaps(datasets);
this._chartData = datasets;
this._total = this._processTotal(consumption);
}
private _processTotal(consumption: EnergyConsumptionData) {
return consumption.total.used_total > 0
? consumption.total.used_total
: undefined;
}
private _processDataSet(
@@ -515,6 +535,9 @@ export class HuiEnergyUsageGraphCard
height: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0;
}
.content {

View File

@@ -35,6 +35,7 @@ const COLORS: Record<HomeSummary, string> = {
climate: "deep-orange",
security: "blue-grey",
media_players: "blue",
unassigned_devices: "grey",
};
@customElement("hui-home-summary-card")

View File

@@ -50,6 +50,8 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
"home-media-players": () =>
import("./home/home-media-players-view-strategy"),
"home-area": () => import("./home/home-area-view-strategy"),
"home-unassigned-devices": () =>
import("./home/home-unassigned-devices-view-strategy"),
light: () => import("../../light/strategies/light-view-strategy"),
security: () => import("../../security/strategies/security-view-strategy"),
climate: () => import("../../climate/strategies/climate-view-strategy"),

View File

@@ -9,6 +9,7 @@ export const HOME_SUMMARIES = [
"climate",
"security",
"media_players",
"unassigned_devices",
] as const;
export type HomeSummary = (typeof HOME_SUMMARIES)[number];
@@ -18,6 +19,7 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
climate: "mdi:home-thermometer",
security: "mdi:security",
media_players: "mdi:multimedia",
unassigned_devices: "mdi:shape",
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
@@ -25,6 +27,19 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
climate: climateEntityFilters,
security: securityEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
unassigned_devices: [
{
area: null,
hidden_platform: [
"automation",
"script",
"hassio",
"backup",
"zone",
"person",
],
},
],
};
export const getSummaryLabel = (

View File

@@ -71,6 +71,16 @@ export class HomeDashboardStrategy extends ReactiveElement {
icon: HOME_SUMMARIES_ICONS.media_players,
} satisfies LovelaceViewRawConfig;
const unassignedDevicesView = {
title: getSummaryLabel(hass.localize, "unassigned_devices"),
path: "unassigned-devices",
subview: true,
strategy: {
type: "home-unassigned-devices",
},
icon: HOME_SUMMARIES_ICONS.unassigned_devices,
} satisfies LovelaceViewRawConfig;
return {
views: [
{
@@ -83,6 +93,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
},
...areaViews,
mediaPlayersView,
unassignedDevicesView,
],
};
}

View File

@@ -228,6 +228,19 @@ export class HomeMainViewStrategy extends ReactiveElement {
columns: 4,
},
} satisfies HomeSummaryCard),
{
type: "home-summary",
summary: "unassigned_devices",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "unassigned-devices",
},
grid_options: {
rows: 2,
columns: 4,
},
} satisfies HomeSummaryCard,
].filter(Boolean) as LovelaceCardConfig[];
const summarySection: LovelaceSectionConfig = {
@@ -297,6 +310,29 @@ export class HomeMainViewStrategy extends ReactiveElement {
}
}
const noAreaFilter = generateEntityFilter(hass, {
area: null,
});
const otherEntities = allEntities.filter(noAreaFilter);
if (otherEntities.length > 0) {
widgetSection.cards!.push({
type: "tile",
entity: otherEntities[0],
icon: "mdi:shape",
name: "Unassigned devices",
hide_state: true,
tap_action: {
action: "navigate",
navigation_path: "unassigned-devices",
},
icon_tap_action: {
action: "none",
},
});
}
const sections = (
[
favoriteSection.cards && favoriteSection,

View File

@@ -0,0 +1,205 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import {
findEntities,
generateEntityFilter,
} from "../../../../common/entity/entity_filter";
import { clamp } from "../../../../common/number/clamp";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import { isHelperDomain } from "../../../config/helpers/const";
import type { HeadingCardConfig } from "../../cards/types";
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
export interface HomeUnassignedDevicesViewStrategyConfig {
type: "home-unassigned-devices";
}
@customElement("home-unassigned-devices-view-strategy")
export class HomeUnassignedDevicesViewStrategy extends ReactiveElement {
static async generate(
_config: HomeUnassignedDevicesViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const allEntities = Object.keys(hass.states);
const unassignedFilters = HOME_SUMMARIES_FILTERS.unassigned_devices.map(
(filter) => generateEntityFilter(hass, filter)
);
const unassignedEntities = findEntities(allEntities, unassignedFilters);
const sections: LovelaceSectionRawConfig[] = [];
const entitiesByDevice: Record<string, string[]> = {};
const entitiesWithoutDevices: string[] = [];
for (const entityId of unassignedEntities) {
const stateObj = hass.states[entityId];
if (!stateObj) continue;
const { device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
if (!device) {
entitiesWithoutDevices.push(entityId);
continue;
}
if (!(device.id in entitiesByDevice)) {
entitiesByDevice[device.id] = [];
}
entitiesByDevice[device.id].push(entityId);
}
const devicesEntities = Object.entries(entitiesByDevice).map(
([deviceId, entities]) => ({
device_id: deviceId,
entities: entities,
})
);
const helpersEntities = entitiesWithoutDevices.filter((entityId) => {
const domain = entityId.split(".")[0];
return isHelperDomain(domain);
});
const otherEntities = entitiesWithoutDevices.filter((entityId) => {
const domain = entityId.split(".")[0];
return !isHelperDomain(domain);
});
const batteryFilter = generateEntityFilter(hass, {
domain: "sensor",
device_class: "battery",
});
const energyFilter = generateEntityFilter(hass, {
domain: "sensor",
device_class: ["energy", "power"],
});
const primaryFilter = generateEntityFilter(hass, {
entity_category: "none",
});
for (const deviceEntities of devicesEntities) {
if (deviceEntities.entities.length === 0) continue;
const batteryEntities = deviceEntities.entities.filter((e) =>
batteryFilter(e)
);
const entities = deviceEntities.entities.filter(
(e) => !batteryFilter(e) && !energyFilter(e) && primaryFilter(e)
);
if (entities.length === 0) {
continue;
}
const deviceId = deviceEntities.device_id;
const device = hass.devices[deviceId];
let heading = "";
if (device) {
heading =
computeDeviceName(device) ||
hass.localize("ui.panel.lovelace.strategy.home.unamed_device");
}
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: heading,
tap_action: device
? {
action: "navigate",
navigation_path: `/config/devices/device/${device.id}`,
}
: undefined,
badges: [
...batteryEntities.slice(0, 1).map((e) => ({
entity: e,
type: "entity",
tap_action: {
action: "more-info",
},
})),
],
} satisfies HeadingCardConfig,
...entities.map((e) => ({
type: "tile",
entity: e,
name: {
type: "entity",
},
})),
],
});
}
if (helpersEntities.length) {
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.unassigned_devices.unassigned_helpers"
),
} satisfies HeadingCardConfig,
...helpersEntities.map((e) => ({
type: "tile",
entity: e,
})),
],
});
}
if (otherEntities.length) {
sections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.unassigned_devices.unassigned_entities"
),
} satisfies HeadingCardConfig,
...otherEntities.map((e) => ({
type: "tile",
entity: e,
})),
],
});
}
// Allow between 2 and 3 columns (the max should be set to define the width of the header)
const maxColumns = clamp(sections.length, 2, 3);
// Take the full width if there is only one section to avoid narrow header on desktop
if (sections.length === 1) {
sections[0].column_span = 2;
}
return {
type: "sections",
header: {
badges_position: "bottom",
},
max_columns: maxColumns,
sections: sections,
};
}
}
declare global {
interface HTMLElementTagNameMap {
"home-unassigned-devices-view-strategy": HomeUnassignedDevicesViewStrategy;
}
}

View File

@@ -1,13 +1,16 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-divider";
import "../../components/ha-list-item";
import "../../components/ha-select";
import "../../components/ha-settings-row";
import { saveFrontendUserData } from "../../data/frontend";
import type { LovelaceDashboard } from "../../data/lovelace/dashboard";
import { fetchDashboards } from "../../data/lovelace/dashboard";
import type { HomeAssistant } from "../../types";
import { saveFrontendUserData } from "../../data/frontend";
import { getPanelTitle } from "../../data/panel";
import type { HomeAssistant, PanelInfo } from "../../types";
import { PANEL_DASHBOARDS } from "../config/lovelace/dashboards/ha-config-lovelace-dashboards";
const USE_SYSTEM_VALUE = "___use_system___";
@@ -47,12 +50,24 @@ class HaPickDashboardRow extends LitElement {
<ha-list-item .value=${USE_SYSTEM_VALUE}>
${this.hass.localize("ui.panel.profile.dashboard.system")}
</ha-list-item>
<ha-divider></ha-divider>
<ha-list-item value="lovelace">
${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
</ha-list-item>
<ha-list-item value="home">
${this.hass.localize("ui.panel.profile.dashboard.home")}
</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}>
${getPanelTitle(this.hass, panelInfo)}
</ha-list-item>
`;
})}
<ha-divider></ha-divider>
${this._dashboards.map((dashboard) => {
if (!this.hass.user!.is_admin && dashboard.require_admin) {
return "";

View File

@@ -2217,7 +2217,9 @@
"sidebar_toggle": "Sidebar toggle",
"edit_sidebar": "Edit sidebar",
"edit_subtitle": "Synced on all devices",
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device."
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device.",
"reset_to_defaults": "Reset to defaults",
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
},
"panel": {
"home": {
@@ -3508,6 +3510,7 @@
"edit": "Edit",
"delete": "Delete",
"add_dashboard": "Add dashboard",
"set_as_default": "Set as default",
"type": {
"user_created": "User created",
"built_in": "Built-in"
@@ -3516,7 +3519,7 @@
"confirm_delete_title": "Delete {dashboard_title}?",
"confirm_delete_text": "This dashboard will be permanently deleted.",
"cant_edit_yaml": "Dashboards created in YAML cannot be edited from the UI. Change them in configuration.yaml.",
"cant_edit_default": "The default dashboard, Overview, cannot be edited from the UI. You can hide it by setting another dashboard as default.",
"cant_edit_lovelace": "The Overview dashboard title and icon cannot be changed. You can create a new dashboard to get more customization options.",
"detail": {
"edit_dashboard": "Edit dashboard",
"new_dashboard": "Add new dashboard",
@@ -3533,9 +3536,7 @@
"set_default": "Set as default",
"remove_default": "Remove as default",
"set_default_confirm_title": "Set as default dashboard?",
"set_default_confirm_text": "This will replace the current default dashboard. Users can still override their default dashboard in their profile settings.",
"remove_default_confirm_title": "Remove default dashboard?",
"remove_default_confirm_text": "The default dashboard will be changed to Overview for every user. Users can still override their default dashboard in their profile settings."
"set_default_confirm_text": "This dashboard will be shown to all users when opening Home Assistant. Each user can change this in their profile."
}
},
"resources": {
@@ -4796,7 +4797,8 @@
"headers": {
"name": "Name",
"type": "Type",
"file_name": "File name"
"file_name": "File name",
"usage_count": "In use"
},
"types": {
"automation": "Automation",
@@ -6779,6 +6781,7 @@
},
"analytics": {
"caption": "Analytics",
"header": "Home Assistant analytics",
"description": "Learn how to share data to improve Home Assistant",
"preferences": {
"base": {
@@ -6796,10 +6799,21 @@
"diagnostics": {
"title": "Diagnostics",
"description": "Share crash reports when unexpected errors occur."
},
"snapshots": {
"title": "Devices",
"description": "Generic information about your devices.",
"header": "Device analytics",
"info": "Anonymously share data about your devices to help build the Open Home Foundations device database. This free, open source resource helps users find useful information about smart home devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).",
"learn_more": "Learn more about the device database and how we process your data",
"alert": {
"title": "Important",
"content": "Only enable this option if you understand that your device information will be shared."
}
}
},
"need_base_enabled": "You need to enable basic analytics for this option to be available",
"learn_more": "How we process your data",
"learn_more": "Learn how we process your data",
"intro": "Share anonymized information from your installation to help make Home Assistant better and help us convince manufacturers to add local control and privacy-focused features.",
"download_device_info": "Preview device analytics"
},
@@ -7049,7 +7063,8 @@
},
"home": {
"summary_list": {
"media_players": "Media players"
"media_players": "Media players",
"unassigned_devices": "Unassigned devices"
},
"welcome_user": "Welcome {user}",
"summaries": "Summaries",
@@ -7079,6 +7094,10 @@
"home_media_players": {
"media_players": "Media players",
"other_media_players": "Other media players"
},
"unassigned_devices": {
"unassigned_helpers": "Unassigned helpers",
"unassigned_entities": "Unassigned entities"
}
},
"cards": {
@@ -7145,6 +7164,7 @@
"energy_usage_graph": {
"total_consumed": "Total consumed {num} kWh",
"total_returned": "Total returned {num} kWh",
"total_usage": "{num} kWh used",
"combined_from_grid": "Combined from grid",
"consumed_solar": "Consumed solar",
"consumed_battery": "Consumed battery"

View File

@@ -9332,7 +9332,7 @@ __metadata:
gulp-rename: "npm:2.1.0"
gulp-zopfli-green: "npm:6.0.2"
hls.js: "npm:1.6.14"
home-assistant-js-websocket: "npm:9.5.0"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.2.2"
@@ -9393,10 +9393,10 @@ __metadata:
languageName: unknown
linkType: soft
"home-assistant-js-websocket@npm:9.5.0":
version: 9.5.0
resolution: "home-assistant-js-websocket@npm:9.5.0"
checksum: 10/42f991b3b85aa61be28984f099a001ac083fb3da54b2777283d0c97976c564a303d8d4ba467e1b8e29cbc33151cd6eef64c1a7d3392d62bbb9cbb27aa7ca9942
"home-assistant-js-websocket@npm:9.6.0":
version: 9.6.0
resolution: "home-assistant-js-websocket@npm:9.6.0"
checksum: 10/0eded7864632b5e19e92289ffac0e24308b1e8f425e292ae87ed21450852f7705db521e202614b1d5bbdb7948633143dce2524ed548db0c38486b40ed1ffa474
languageName: node
linkType: hard