Compare commits

...

42 Commits

Author SHA1 Message Date
Aidan Timson
d1afcfcd32 Match original more closely 2025-11-07 13:43:20 +00:00
Aidan Timson
29e6ada90e Remove binary 2025-11-07 12:12:32 +00:00
Aidan Timson
ff1745fccc Working connection 2025-11-07 12:11:04 +00:00
Aidan Timson
59aaf86104 Update 2025-11-07 11:54:59 +00:00
Aidan Timson
52bc692c79 Add simple backend 2025-11-07 11:45:51 +00:00
Aidan Timson
29de405912 Setup CLI in container 2025-11-07 11:37:58 +00:00
Aidan Timson
a723171ef2 Clear mocks 2025-11-07 11:25:36 +00:00
Aidan Timson
cfe193cf60 Setup 2025-11-07 11:16:04 +00:00
Aidan Timson
a8e6557b09 Fix 2025-11-07 10:49:23 +00:00
Aidan Timson
a517aabdd8 Basic auto theme support 2025-11-07 10:44:30 +00:00
Aidan Timson
10c8828aa5 Setup 2025-11-07 10:33:51 +00:00
Aidan Timson
5801ce944c Refactor dashboard conditional listeners to use mixin (#27837)
* Update media query to new APIs

* Refactor conditional rendering

* Cleanup

* Restore original functionality

* Restore

* Reduce

* Reduce

* Reduce

* Restore legacy code while we still support them

* Clear conditional listeners before setting up again
2025-11-07 12:22:16 +02:00
Petar Petrov
79ad9dbf44 Disable graph resize animation for general resizing (#27816) 2025-11-07 08:34:42 +01:00
Wendelin
9814533d47 Add service titles to automation action sidebar (#27831)
* Add service titles to automation action sidebar

* Add split limit
2025-11-07 07:27:21 +00:00
renovate[bot]
bdb6c684c0 Update dependency eslint to v9.39.1 (#27846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 08:38:42 +02:00
renovate[bot]
0046167f5c Update dependency typescript-eslint to v8.46.3 (#27842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 08:38:21 +02:00
renovate[bot]
5266f0d761 Update dependency @bundle-stats/plugin-webpack-filter to v4.21.6 (#27841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-07 08:37:35 +02:00
Jan Bouwhuis
cc5c0d04c4 Fix index for service action translation in service action dialog (#27824) 2025-11-06 15:15:52 +01:00
Wendelin
7f3d5f557f Target picker row check if not found entity isn't "all" (#27826)
Target picker row check if not found entity isn't all
2025-11-06 15:15:09 +01:00
Wendelin
9b48cd7737 Add trigger/condition/action dialog: select single search result with enter key (#27825)
* Add trigger/condition/action dialog: select single search result with enter key

* Update src/panels/config/automation/add-automation-element-dialog.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-06 13:46:53 +00:00
Aidan Timson
11c6f6ec78 Update @home-assistant/webawesome to 3.0.0-beta.6.ha.7 (#27834) 2025-11-06 15:33:10 +02:00
Wendelin
cb0f59b26d Fix floor details area picker (#27827) 2025-11-06 12:42:30 +02:00
Paul Bottein
c89fc35578 Fix OHF logo theme (#27830) 2025-11-06 12:39:52 +02:00
Timothy
f03cd9c239 Add Add entity to feature for external_app (#26346)
* Add Add entity to feature for external_app

* Update icon from plus to plusboxmultiple

* Apply suggestion on the name

* Add missing shouldHandleRequestSelectedEvent that caused duplicate

* WIP

* Rework the logic to match the agreed design

* Rename property

* Apply PR comments

* Apply prettier

* Merge MessageWithAnswer

* Apply PR comments
2025-11-06 08:25:05 +00:00
karwosts
19a4e37933 Fix incorrect unit displayed in energy grid flow settings (#27822) 2025-11-06 08:46:19 +02:00
Bram Kragten
76514babd5 Fix landing page build (#27817) 2025-11-05 16:13:48 +01:00
Timothy
b6abbdafb8 All external config properties could be undefined (#27803)
All external config attributes could be undefined
2025-11-05 16:57:51 +02:00
Wendelin
d35e6c0092 Add fallback icon for domain template (#27814) 2025-11-05 15:14:41 +01:00
Wendelin
5c2ee54dec Fix target picker with empty sections (#27813) 2025-11-05 13:56:58 +00:00
Wendelin
1dfca76c81 Fix assist conversation language picker (#27764) 2025-11-05 14:48:18 +01:00
Wendelin
fd7f028fbf Change add trigger/condition/action dialog title (#27811)
Change add dialog title
2025-11-05 15:31:25 +02:00
Wendelin
3f7283b1af Add trigger/condition/action dialog - Show device group always on top (#27812)
add automation element dialog Device always on top
2025-11-05 15:27:53 +02:00
Paul Bottein
d35323ac52 Fix target picker in logbook card editor (#27804)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-11-05 11:57:34 +00:00
Wendelin
06475382e8 Fix auth language picker styles (#27805) 2025-11-05 10:54:36 +01:00
Wendelin
b60dd7f15d Add condition/action dialog: blocks title (#27801) 2025-11-05 10:47:46 +01:00
Wendelin
b77e65fabd Add trigger/condition/action dialog: fix empty elements in search results (#27802) 2025-11-05 10:47:20 +01:00
Wendelin
cea691a04e Fix target picker in card editor (#27800) 2025-11-05 10:03:56 +01:00
Jan-Philipp Benecke
50df2a34cd Fix z-index for target picker item row icon (#27798) 2025-11-05 08:33:11 +01:00
Aidan Timson
e6c0a84994 Add hide background option to iframe card (#27792)
* Add hide background option to iframe card

* Fix

* Add helper
2025-11-05 08:07:43 +02:00
Bram Kragten
b03fa4bdc5 Handle unknown items in target picker (#27795)
* Handle unknown items in target picker

* Update ha-target-picker-item-row.ts

* update colors

* fallback to domain icons
2025-11-04 18:02:56 +01:00
Paul Bottein
058cecc124 Auto refresh summary dashboard when registries changed (#27794) 2025-11-04 16:41:27 +00:00
Paul Bottein
a5f058a7eb Rename safety panel to security panel (#27796) 2025-11-04 17:23:06 +01:00
72 changed files with 2921 additions and 789 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
build/
dist/
/hass_frontend/
/logs/dist/
/translations/
# yarn

View File

@@ -18,16 +18,16 @@ module.exports.sourceMapURL = () => {
module.exports.ignorePackages = () => [];
// Files from NPM packages that we should replace with empty file
module.exports.emptyPackages = ({ isHassioBuild }) =>
module.exports.emptyPackages = ({ isHassioBuild, isLandingPageBuild }) =>
[
require.resolve("@vaadin/vaadin-material-styles/typography.js"),
require.resolve("@vaadin/vaadin-material-styles/font-icons.js"),
// Icons in supervisor conflict with icons in HA so we don't load.
isHassioBuild &&
(isHassioBuild || isLandingPageBuild) &&
require.resolve(
path.resolve(paths.root_dir, "src/components/ha-icon.ts")
),
isHassioBuild &&
(isHassioBuild || isLandingPageBuild) &&
require.resolve(
path.resolve(paths.root_dir, "src/components/ha-icon-picker.ts")
),
@@ -327,6 +327,20 @@ module.exports.config = {
};
},
logs({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "logs" + nameSuffix(latestBuild),
entry: {
entrypoint: path.resolve(paths.logs_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.logs_output_root, latestBuild),
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isStatsBuild,
};
},
landingPage({ isProdBuild, latestBuild }) {
return {
name: "landing-page" + nameSuffix(latestBuild),
@@ -337,6 +351,7 @@ module.exports.config = {
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isLandingPageBuild: true,
};
},
};

View File

@@ -39,6 +39,13 @@ gulp.task(
)
);
gulp.task(
"clean-logs",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.logs_output_root, paths.build_dir])
)
);
gulp.task(
"clean-landing-page",
gulp.parallel("clean-translations", async () =>

View File

@@ -245,6 +245,24 @@ gulp.task(
)
);
const LOGS_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(
"gen-pages-logs-dev",
genPagesDevTask(LOGS_PAGE_ENTRIES, paths.logs_dir, paths.logs_output_root)
);
gulp.task(
"gen-pages-logs-prod",
genPagesProdTask(
LOGS_PAGE_ENTRIES,
paths.logs_dir,
paths.logs_output_root,
paths.logs_output_latest,
paths.logs_output_es5
)
);
const LANDING_PAGE_PAGE_ENTRIES = { "index.html": ["entrypoint"] };
gulp.task(

View File

@@ -202,6 +202,16 @@ gulp.task("copy-static-gallery", async () => {
copyMdiIcons(paths.gallery_output_static);
});
gulp.task("copy-static-logs", async () => {
// Copy app static files
fs.copySync(polyPath("public/static"), paths.logs_output_static);
copyFonts(paths.logs_output_static);
copyTranslations(paths.logs_output_static);
copyLocaleData(paths.logs_output_static);
copyMdiIcons(paths.logs_output_static);
});
gulp.task("copy-static-landing-page", async () => {
// Copy landing-page static files
fs.copySync(

View File

@@ -7,6 +7,7 @@ import "./download-translations.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
import "./logs.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./hassio.js";

View File

@@ -0,0 +1,39 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-logs",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-logs",
gulp.parallel(
"gen-icons-json",
"gen-pages-logs-dev",
"build-translations",
"build-locale-data"
),
"copy-static-logs",
"rspack-dev-server-logs"
)
);
gulp.task(
"build-logs",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-logs",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-logs",
"rspack-prod-logs",
"gen-pages-logs-prod"
)
);

View File

@@ -15,6 +15,7 @@ import {
createGalleryConfig,
createHassioConfig,
createLandingPageConfig,
createLogsConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -204,6 +205,25 @@ gulp.task("rspack-prod-gallery", () =>
)
);
gulp.task("rspack-dev-server-logs", () =>
runDevServer({
compiler: rspack(
createLogsConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.logs_output_root,
port: 5647,
})
);
gulp.task("rspack-prod-logs", () =>
prodBuild(
bothBuilds(createLogsConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
gulp.task("rspack-watch-landing-page", () => {
// This command will run forever because we don't close compiler
rspack(

View File

@@ -59,5 +59,11 @@ module.exports = {
hassio_output_es5: path.resolve(__dirname, "../hassio/build/frontend_es5"),
hassio_publicPath: "/api/hassio/app",
logs_dir: path.resolve(__dirname, "../logs"),
logs_output_root: path.resolve(__dirname, "../logs/dist"),
logs_output_static: path.resolve(__dirname, "../logs/dist/static"),
logs_output_latest: path.resolve(__dirname, "../logs/dist/frontend_latest"),
logs_output_es5: path.resolve(__dirname, "../logs/dist/frontend_es5"),
translations_src: path.resolve(__dirname, "../src/translations"),
};

View File

@@ -41,6 +41,7 @@ const createRspackConfig = ({
isStatsBuild,
isTestBuild,
isHassioBuild,
isLandingPageBuild,
dontHash,
}) => {
if (!dontHash) {
@@ -168,7 +169,9 @@ const createRspackConfig = ({
},
}),
new rspack.NormalModuleReplacementPlugin(
new RegExp(bundle.emptyPackages({ isHassioBuild }).join("|")),
new RegExp(
bundle.emptyPackages({ isHassioBuild, isLandingPageBuild }).join("|")
),
path.resolve(paths.root_dir, "src/util/empty.js")
),
!isProdBuild && new LogStartCompilePlugin(),
@@ -299,6 +302,11 @@ const createHassioConfig = ({
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
const createLogsConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.logs({ isProdBuild, latestBuild, isStatsBuild })
);
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
@@ -308,6 +316,7 @@ module.exports = {
createCastConfig,
createHassioConfig,
createGalleryConfig,
createLogsConfig,
createRspackConfig,
createLandingPageConfig,
};

View File

@@ -1,22 +1,25 @@
import "@material/mwc-linear-progress";
import { type PropertyValues, css, html, nothing } from "lit";
import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../src/common/url/search-params";
import "../../src/components/ha-alert";
import "../../src/components/ha-button";
import "../../src/components/ha-fade-in";
import "../../src/components/ha-spinner";
import { haStyle } from "../../src/resources/styles";
import "../../src/onboarding/onboarding-welcome-links";
import "./components/landing-page-network";
import "./components/landing-page-logs";
import { extractSearchParam } from "../../src/common/url/search-params";
import { onBoardingStyles } from "../../src/onboarding/styles";
import "../../src/components/ha-svg-icon";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import { LandingPageBaseElement } from "./landing-page-base-element";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
import { haStyle } from "../../src/resources/styles";
import "./components/landing-page-logs";
import "./components/landing-page-network";
import {
getSupervisorNetworkInfo,
pingSupervisor,
type NetworkInfo,
} from "./data/supervisor";
import { LandingPageBaseElement } from "./landing-page-base-element";
export const ASSUME_CORE_START_SECONDS = 60;
const SCHEDULE_CORE_CHECK_SECONDS = 1;
@@ -94,16 +97,21 @@ class HaLandingPage extends LandingPageBaseElement {
<ha-language-picker
.value=${this.language}
.label=${""}
button-style
native-name
@value-changed=${this._languageChanged}
inline-arrow
></ha-language-picker>
<a
<ha-button
appearance="plain"
variant="neutral"
href="https://www.home-assistant.io/getting-started/onboarding/"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-onboarding.help")}</a
>
${this.localize("ui.panel.page-onboarding.help")}
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-button>
</div>
`;
}
@@ -218,26 +226,8 @@ class HaLandingPage extends LandingPageBaseElement {
ha-alert p {
text-align: unset;
}
ha-language-picker {
display: block;
width: 200px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
--ha-select-height: 40px;
--mdc-select-fill-color: none;
--mdc-select-label-ink-color: var(--primary-text-color, #212121);
--mdc-select-ink-color: var(--primary-text-color, #212121);
--mdc-select-idle-line-color: transparent;
--mdc-select-hover-line-color: transparent;
--mdc-select-dropdown-icon-color: var(--primary-text-color, #212121);
--mdc-shape-small: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
}
ha-fade-in {
min-height: calc(100vh - 64px - 88px);

9
logs/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
dist/
src/
node_modules/
*.md
.git/
.gitignore
docker-compose.yaml
backend/ha-logs-proxy
backend/README.md

47
logs/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
ARG BUILD_FROM
FROM $BUILD_FROM AS base
# Install dependencies
RUN apk add --no-cache \
bash \
jq \
curl \
go
# Install Home Assistant CLI
ARG BUILD_ARCH
ARG CLI_VERSION
RUN curl -Lso /usr/bin/ha \
"https://github.com/home-assistant/cli/releases/download/${CLI_VERSION}/ha_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/ha
# Build Go backend
WORKDIR /app
COPY backend/go.mod backend/go.sum* ./
RUN go mod download || true
COPY backend/*.go ./
RUN CGO_ENABLED=0 go build -o ha-logs-proxy .
# Final stage
FROM $BUILD_FROM
# Install runtime dependencies
RUN apk add --no-cache \
bash \
jq \
curl
# Copy HA CLI from base
COPY --from=base /usr/bin/ha /usr/bin/ha
# Copy Go backend
COPY --from=base /app/ha-logs-proxy /usr/bin/ha-logs-proxy
WORKDIR /root
# Expose port for backend (5642 = LOGB)
EXPOSE 5642
# Default command
CMD ["/usr/bin/ha-logs-proxy"]

113
logs/README.md Normal file
View File

@@ -0,0 +1,113 @@
# Home Assistant CLI Docker Container
A simple multi-architecture Docker container with the Home Assistant CLI installed.
## Development Usage
The CLI container is integrated into the `script/develop-logs` workflow. Both flags are required:
```bash
# Start dev server with CLI container (requires remote_api add-on)
script/develop-logs -c http://192.168.1.2 -t your_token_here
```
When started with credentials, the container runs a Go backend that proxies HA CLI logs commands. The backend API is available at `http://localhost:5642`.
**Frontend Features:**
- Dropdown menu to select log provider (core, supervisor, host, audio, dns, multicast)
- Follow mode with WebSocket streaming (`ha core logs --follow`)
- Manual refresh to fetch latest logs
- Download logs as text file
- Line wrapping toggle
- Auto-scroll to bottom when following
- Error display with retry
**API Endpoints:**
```bash
# List all endpoints
curl http://localhost:5642/api/logs
# Get static logs
curl http://localhost:5642/api/logs/core
curl http://localhost:5642/api/logs/supervisor
# Health check
curl http://localhost:5642/health
# WebSocket streaming (requires websocat or browser)
websocat ws://localhost:5642/api/logs/core/follow
```
You can also execute HA CLI commands directly:
```bash
docker exec -it ha-cli-dev ha info
docker exec -it ha-cli-dev ha supervisor info
```
Stop everything with Ctrl+C (both the dev server and backend will stop automatically).
### Getting API Token
1. Install the [remote_api add-on](https://github.com/home-assistant/addons/tree/master/remote_api) in Home Assistant
2. Check the add-on logs for the generated token
3. Use the token with the `-t` flag
## Build
### Local Build (Single Architecture)
```bash
docker build \
--build-arg BUILD_FROM=alpine:3.22 \
--build-arg BUILD_ARCH=amd64 \
--build-arg CLI_VERSION=4.42.0 \
-t ha-cli:local \
.
```
### Multi-Architecture Build
The `build.yaml` configuration is designed for use with Home Assistant's build system. For local multi-arch builds, use Docker Buildx:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--build-arg BUILD_FROM=alpine:3.22 \
--build-arg CLI_VERSION=4.42.0 \
-t ha-cli:latest \
.
```
## Usage
### Run CLI Commands
```bash
docker run --rm ha-cli:local ha help
docker run --rm ha-cli:local ha supervisor info
```
### Interactive Shell
```bash
docker run -it --rm ha-cli:local
# Then run: ha <command>
```
### With Docker Compose
```bash
docker compose run --rm ha-cli ha help
```
## Update CLI Version
Edit the `CLI_VERSION` in `build.yaml` or pass it as a build argument:
```bash
docker build --build-arg CLI_VERSION=4.43.0 ...
```
Check for latest versions at: https://github.com/home-assistant/cli/releases

1
logs/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
ha-logs-proxy

108
logs/backend/README.md Normal file
View File

@@ -0,0 +1,108 @@
# HA Logs Proxy Backend
A Go backend that proxies Home Assistant CLI logs commands through a secure HTTP API.
## Features
- Only allows `logs` commands (security-restricted)
- GET endpoints for static logs
- WebSocket endpoints for streaming logs (follow mode)
- CORS enabled for frontend integration
- Simple JSON API
## API Endpoints
### GET /api/logs
List all available endpoints.
### GET /api/logs/core
Get Home Assistant core logs.
### GET /api/logs/supervisor
Get Supervisor logs.
### GET /api/logs/host
Get host system logs.
### GET /api/logs/audio
Get audio logs.
### GET /api/logs/dns
Get DNS logs.
### GET /api/logs/multicast
Get multicast logs.
### GET /health
Health check endpoint.
**Response format (all log endpoints):**
```json
{
"output": "log content here...",
"error": "error message if any"
}
```
### WS /api/logs/*/follow
WebSocket endpoints for streaming logs in real-time.
Available endpoints:
- `WS /api/logs/core/follow` - Stream core logs
- `WS /api/logs/supervisor/follow` - Stream supervisor logs
- `WS /api/logs/host/follow` - Stream host logs
- `WS /api/logs/audio/follow` - Stream audio logs
- `WS /api/logs/dns/follow` - Stream DNS logs
- `WS /api/logs/multicast/follow` - Stream multicast logs
Each WebSocket message contains a single log line as plain text. The connection streams output from `ha {component} logs --follow` command.
## Running Locally
```bash
cd backend
go run main.go
```
The server starts on port 5642 (LOGB) by default. Override with `PORT` environment variable:
```bash
PORT=3000 go run main.go
```
## Building
```bash
go build -o ha-logs-proxy
./ha-logs-proxy
```
## Testing
```bash
# List endpoints
curl http://localhost:5642/api/logs
# Get core logs
curl http://localhost:5642/api/logs/core
# Get supervisor logs
curl http://localhost:5642/api/logs/supervisor
# Health check
curl http://localhost:5642/health
```
## Docker Integration
The backend is designed to run in the same container as the HA CLI, sharing access to the `ha` command.

5
logs/backend/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/home-assistant/frontend/logs/backend
go 1.24
require github.com/gorilla/websocket v1.5.3

2
logs/backend/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

372
logs/backend/main.go Normal file
View File

@@ -0,0 +1,372 @@
package main
import (
"bufio"
"encoding/json"
"log"
"net/http"
"os"
"os/exec"
"github.com/gorilla/websocket"
)
type LogsResponse struct {
Output string `json:"output"`
Error string `json:"error,omitempty"`
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins (CORS)
},
}
func executeHACommand(args []string) (string, error) {
cmd := exec.Command("ha", args...)
output, err := cmd.CombinedOutput()
return string(output), err
}
func streamHACommandToWS(conn *websocket.Conn, args []string) error {
cmd := exec.Command("ha", args...)
// Get stdout pipe
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
// Start command
if err := cmd.Start(); err != nil {
return err
}
// Read and send output line by line
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
cmd.Process.Kill()
return err
}
}
// Wait for command to finish
if err := cmd.Wait(); err != nil {
return err
}
return scanner.Err()
}
func handleCoreLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Execute ha core logs command
output, err := executeHACommand([]string{"core", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
func handleSupervisorLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"supervisor", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleHostLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"host", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleAudioLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"audio", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleDNSLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"dns", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleMulticastLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
output, err := executeHACommand([]string{"multicast", "logs"})
response := LogsResponse{
Output: output,
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET")
json.NewEncoder(w).Encode(response)
}
func handleCoreLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to core logs follow")
if err := streamHACommandToWS(conn, []string{"core", "logs", "--follow"}); err != nil {
log.Printf("Error streaming core logs: %v", err)
}
log.Println("Client disconnected from core logs follow")
}
func handleSupervisorLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to supervisor logs follow")
if err := streamHACommandToWS(conn, []string{"supervisor", "logs", "--follow"}); err != nil {
log.Printf("Error streaming supervisor logs: %v", err)
}
log.Println("Client disconnected from supervisor logs follow")
}
func handleHostLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to host logs follow")
if err := streamHACommandToWS(conn, []string{"host", "logs", "--follow"}); err != nil {
log.Printf("Error streaming host logs: %v", err)
}
log.Println("Client disconnected from host logs follow")
}
func handleAudioLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to audio logs follow")
if err := streamHACommandToWS(conn, []string{"audio", "logs", "--follow"}); err != nil {
log.Printf("Error streaming audio logs: %v", err)
}
log.Println("Client disconnected from audio logs follow")
}
func handleDNSLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to dns logs follow")
if err := streamHACommandToWS(conn, []string{"dns", "logs", "--follow"}); err != nil {
log.Printf("Error streaming dns logs: %v", err)
}
log.Println("Client disconnected from dns logs follow")
}
func handleMulticastLogsFollow(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Println("Client connected to multicast logs follow")
if err := streamHACommandToWS(conn, []string{"multicast", "logs", "--follow"}); err != nil {
log.Printf("Error streaming multicast logs: %v", err)
}
log.Println("Client disconnected from multicast logs follow")
}
func listEndpoints(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
endpoints := map[string]string{
"core": "/api/logs/core",
"supervisor": "/api/logs/supervisor",
"host": "/api/logs/host",
"audio": "/api/logs/audio",
"dns": "/api/logs/dns",
"multicast": "/api/logs/multicast",
"health": "/health",
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(endpoints)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "5642"
}
// Register handlers
http.HandleFunc("/api/logs", listEndpoints)
http.HandleFunc("/api/logs/core", handleCoreLogs)
http.HandleFunc("/api/logs/supervisor", handleSupervisorLogs)
http.HandleFunc("/api/logs/host", handleHostLogs)
http.HandleFunc("/api/logs/audio", handleAudioLogs)
http.HandleFunc("/api/logs/dns", handleDNSLogs)
http.HandleFunc("/api/logs/multicast", handleMulticastLogs)
// WebSocket follow endpoints
http.HandleFunc("/api/logs/core/follow", handleCoreLogsFollow)
http.HandleFunc("/api/logs/supervisor/follow", handleSupervisorLogsFollow)
http.HandleFunc("/api/logs/host/follow", handleHostLogsFollow)
http.HandleFunc("/api/logs/audio/follow", handleAudioLogsFollow)
http.HandleFunc("/api/logs/dns/follow", handleDNSLogsFollow)
http.HandleFunc("/api/logs/multicast/follow", handleMulticastLogsFollow)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
log.Printf("Starting HA Logs Proxy on port %s", port)
log.Printf("Available endpoints:")
log.Printf(" GET /api/logs - List all endpoints")
log.Printf(" GET /api/logs/core - Core logs")
log.Printf(" GET /api/logs/supervisor - Supervisor logs")
log.Printf(" GET /api/logs/host - Host logs")
log.Printf(" GET /api/logs/audio - Audio logs")
log.Printf(" GET /api/logs/dns - DNS logs")
log.Printf(" GET /api/logs/multicast - Multicast logs")
log.Printf(" WS /api/logs/*/follow - Stream logs (WebSocket)")
log.Printf(" GET /health - Health check")
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

19
logs/build.yaml Normal file
View File

@@ -0,0 +1,19 @@
---
image: ghcr.io/home-assistant/{arch}-hassio-cli
build_from:
aarch64: ghcr.io/home-assistant/aarch64-base:3.22
armhf: ghcr.io/home-assistant/armhf-base:3.22
armv7: ghcr.io/home-assistant/armv7-base:3.22
amd64: ghcr.io/home-assistant/amd64-base:3.22
i386: ghcr.io/home-assistant/i386-base:3.22
args:
CLI_VERSION: 4.42.0
cosign:
enabled: true
repository_name: home-assistant/cli
repository_owner: home-assistant
labels:
org.opencontainers.image.title: Home Assistant CLI
org.opencontainers.image.description: Home Assistant CLI for container environments
org.opencontainers.image.source: https://github.com/home-assistant/cli
org.opencontainers.image.licenses: Apache-2.0

16
logs/docker-compose.yaml Normal file
View File

@@ -0,0 +1,16 @@
---
services:
ha-cli:
build:
context: .
args:
BUILD_FROM: alpine:3.22
BUILD_ARCH: amd64
CLI_VERSION: 4.42.0
image: ha-cli:local
ports:
- "5642:5642"
environment:
- PORT=5642
stdin_open: true
tty: true

34
logs/src/auto-theme.ts Normal file
View File

@@ -0,0 +1,34 @@
import { darkSemanticColorStyles } from "../../src/resources/theme/color/semantic.globals";
import { darkColorStyles } from "../../src/resources/theme/color/color.globals";
const mql = matchMedia("(prefers-color-scheme: dark)");
function applyTheme(dark: boolean) {
const el = document.documentElement;
if (dark) {
el.setAttribute("dark", "");
} else {
el.removeAttribute("dark");
}
}
// Add dark theme styles wrapped in media query
// This runs after append-ha-style has loaded the base theme
const styleElement = document.createElement("style");
styleElement.id = "auto-theme-dark";
styleElement.textContent = `
@media (prefers-color-scheme: dark) {
${darkSemanticColorStyles.cssText}
${darkColorStyles.cssText}
}
`;
// Append to head to ensure it comes after base styles
document.head.appendChild(styleElement);
// Apply theme on initial load
applyTheme(mql.matches);
// Listen for theme changes
mql.addEventListener("change", (e) => {
applyTheme(e.matches);
});

8
logs/src/entrypoint.ts Normal file
View File

@@ -0,0 +1,8 @@
import "./logs-app";
// Load base styles first, then apply theme
import("../../src/resources/append-ha-style").then(() => {
import("./auto-theme");
});
document.body.appendChild(document.createElement("logs-app"));

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<meta name="color-scheme" content="dark light" />
<title>Home Assistant Logs</title>
<% for (const entry of latestEntryJS) { %>
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
}
@media (prefers-color-scheme: dark) {
html {
background-color: var(--primary-background-color, #111111);
color: var(--primary-text-color, #e1e1e1);
}
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
margin: 0;
padding: 0;
}
</style>
</head>
<body></body>
</html>

24
logs/src/logs-app.ts Normal file
View File

@@ -0,0 +1,24 @@
import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators";
import "./logs-viewer";
@customElement("logs-app")
class LogsApp extends LitElement {
render() {
return html`<logs-viewer></logs-viewer>`;
}
static styles = css`
:host {
display: block;
min-height: 100vh;
background-color: var(--primary-background-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"logs-app": LogsApp;
}
}

538
logs/src/logs-viewer.ts Normal file
View File

@@ -0,0 +1,538 @@
import {
mdiArrowCollapseDown,
mdiChevronDown,
mdiCircle,
mdiDownload,
mdiRefresh,
mdiWrap,
mdiWrapDisabled,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import "../../src/components/ha-ansi-to-html";
import type { HaAnsiToHtml } from "../../src/components/ha-ansi-to-html";
import "../../src/components/ha-button";
import "../../src/components/ha-button-menu";
import "../../src/components/ha-card";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-list-item";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
// Data types
interface LogProvider {
key: string;
name: string;
}
@customElement("logs-viewer")
export class LogsViewer extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _selectedLogProvider?: string;
@state() private _logProviders: LogProvider[] = [];
@state() private _loading = false;
@state() private _wrapLines = true;
@state() private _error?: string;
@state() private _newLogsIndicator?: boolean;
@query(".error-log") private _logElement?: HTMLElement;
@query("#scroll-top-marker") private _scrollTopMarkerElement?: HTMLElement;
@query("#scroll-bottom-marker")
private _scrollBottomMarkerElement?: HTMLElement;
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
@state() private _scrolledToBottomController =
new IntersectionController<boolean>(this, {
callback(this: IntersectionController<boolean>, entries) {
return entries[0].isIntersecting;
},
});
@state() private _scrolledToTopController =
new IntersectionController<boolean>(this, {});
private _ws: WebSocket | null = null;
private _apiUrl = `http://${window.location.hostname}:5642`;
private async _fetchLogs(): Promise<void> {
if (!this._selectedLogProvider) {
return;
}
this._loading = true;
this._error = undefined;
// Stop any existing websocket
this._stopFollowing();
// Clear existing logs
this._ansiToHtmlElement?.clear();
try {
// First, fetch the latest logs
const response = await fetch(
`${this._apiUrl}/api/logs/${this._selectedLogProvider}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
const logText = data.output || "";
// Parse and display initial logs
if (logText.trim()) {
this._ansiToHtmlElement?.parseTextToColoredPre(logText);
// Add divider line
this._ansiToHtmlElement?.parseLineToColoredPre(
"--- Live logs start here ---"
);
}
this._loading = false;
// Scroll to bottom after loading
this._scrollToBottom();
// Start streaming
this._startFollowing();
} catch (err) {
this._error = `Error loading logs: ${err}`;
this._loading = false;
// eslint-disable-next-line
console.error("Error fetching logs:", err);
}
}
private async _fetchLogProviders(): Promise<void> {
try {
const response = await fetch(`${this._apiUrl}/api/logs`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const providers = await response.json();
// Define the order (matching backend registration order)
const order = ["core", "supervisor", "host", "audio", "dns", "multicast"];
// Convert object to array of providers, filter out health endpoint, and sort
this._logProviders = Object.entries(providers)
.filter(([key]) => key !== "health")
.map(([key]) => ({
key,
name: key.charAt(0).toUpperCase() + key.slice(1),
}))
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
// Set default provider once loaded
if (this._logProviders.length > 0 && !this._selectedLogProvider) {
this._selectedLogProvider = this._logProviders[0].key;
await this._fetchLogs();
}
} catch (err) {
this._error = `Failed to load log providers: ${err}`;
// eslint-disable-next-line
console.error("Error fetching log providers:", err);
}
}
connectedCallback() {
super.connectedCallback();
this._fetchLogProviders();
}
disconnectedCallback() {
super.disconnectedCallback();
this._stopFollowing();
}
protected firstUpdated() {
this._scrolledToBottomController.observe(this._scrollBottomMarkerElement!);
this._scrolledToTopController.observe(this._scrollTopMarkerElement!);
}
protected updated() {
if (this._newLogsIndicator && this._scrolledToBottomController.value) {
this._newLogsIndicator = false;
}
}
private _selectProvider(ev: Event) {
const target = ev.currentTarget as any;
this._selectedLogProvider = target.provider;
this._fetchLogs();
}
private _refresh() {
this._fetchLogs();
}
private _toggleLineWrap() {
this._wrapLines = !this._wrapLines;
}
private _scrollToBottom(): void {
if (this._logElement) {
this._newLogsIndicator = false;
this._logElement.scrollTo(0, this._logElement.scrollHeight);
}
}
private _startFollowing() {
if (!this._selectedLogProvider) {
return;
}
this._stopFollowing();
this._error = undefined;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.hostname}:5642/api/logs/${this._selectedLogProvider}/follow`;
try {
this._ws = new WebSocket(wsUrl);
this._ws.onopen = () => {
// eslint-disable-next-line
console.log("WebSocket connected");
};
this._ws.onmessage = (event) => {
const scrolledToBottom = this._scrolledToBottomController.value;
// Add the new line to the display
this._ansiToHtmlElement?.parseLineToColoredPre(event.data);
// Auto-scroll if user is at bottom
if (scrolledToBottom && this._logElement) {
this._scrollToBottom();
} else {
this._newLogsIndicator = true;
}
};
this._ws.onerror = (error) => {
// eslint-disable-next-line
console.error("WebSocket error:", error);
this._error = "WebSocket connection error";
};
this._ws.onclose = () => {
// eslint-disable-next-line
console.log("WebSocket disconnected");
};
} catch (err) {
this._error = `Failed to start following logs: ${err}`;
// eslint-disable-next-line
console.error("Error starting WebSocket:", err);
}
}
private _stopFollowing() {
if (this._ws) {
this._ws.close();
this._ws = null;
}
}
private _downloadLogs() {
if (!this._selectedLogProvider || !this._ansiToHtmlElement) {
return;
}
// Get the text content from the logs
const logText =
this._ansiToHtmlElement.shadowRoot?.querySelector("pre")?.textContent ||
"";
if (!logText.trim()) {
return;
}
// Create blob from log text
const blob = new Blob([logText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
// Create download link and trigger it
const a = document.createElement("a");
a.href = url;
a.download = `${this._selectedLogProvider}-logs-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
// Cleanup
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
render() {
const currentProvider = this._logProviders.find(
(p) => p.key === this._selectedLogProvider
);
return html`
<div class="container">
<div class="toolbar">
<ha-button-menu>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon slot="end" .path=${mdiChevronDown}></ha-svg-icon>
${currentProvider?.name || "Select Provider"}
</ha-button>
${this._logProviders.map(
(provider) => html`
<ha-list-item
?selected=${provider.key === this._selectedLogProvider}
.provider=${provider.key}
@click=${this._selectProvider}
>
${provider.name}
</ha-list-item>
`
)}
</ha-button-menu>
</div>
<div class="content">
<div class="error-log-intro">
<ha-card outlined>
<div class="header">
<h1 class="card-header">${currentProvider?.name || "Logs"}</h1>
<div class="action-buttons">
<ha-icon-button
.path=${mdiDownload}
@click=${this._downloadLogs}
.label=${"Download logs"}
.disabled=${!this._ansiToHtmlElement}
></ha-icon-button>
<ha-icon-button
.path=${this._wrapLines ? mdiWrapDisabled : mdiWrap}
@click=${this._toggleLineWrap}
.label=${this._wrapLines ? "Full width" : "Wrap lines"}
></ha-icon-button>
<ha-icon-button
.path=${mdiRefresh}
@click=${this._refresh}
.label=${"Refresh"}
.disabled=${!this._selectedLogProvider}
></ha-icon-button>
</div>
</div>
<div class="card-content error-log">
<div id="scroll-top-marker"></div>
${this._loading
? html`<div>Loading logs...</div>`
: this._error
? html`<div class="error">${this._error}</div>`
: nothing}
<ha-ansi-to-html
?wrap-disabled=${!this._wrapLines}
></ha-ansi-to-html>
<div id="scroll-bottom-marker"></div>
</div>
<ha-button
class="new-logs-indicator ${classMap({
visible:
(this._newLogsIndicator &&
!this._scrolledToBottomController.value) ||
false,
})}"
size="small"
appearance="filled"
@click=${this._scrollToBottom}
>
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="start"
></ha-svg-icon>
Scroll down
<ha-svg-icon
.path=${mdiArrowCollapseDown}
slot="end"
></ha-svg-icon>
</ha-button>
${this._ws && !this._error
? html`<div class="live-indicator">
<ha-svg-icon .path=${mdiCircle}></ha-svg-icon>
Live
</div>`
: nothing}
</ha-card>
</div>
</div>
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
direction: var(--direction);
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.toolbar {
padding: var(--ha-space-2) var(--ha-space-4);
display: flex;
justify-content: flex-end;
gap: var(--ha-space-2);
}
.content {
direction: ltr;
}
.error-log-intro {
text-align: center;
margin: 0 var(--ha-space-4);
}
ha-card {
padding-top: var(--ha-space-2);
position: relative;
}
.header {
display: flex;
justify-content: space-between;
padding: 0 var(--ha-space-4);
}
.action-buttons {
display: flex;
align-items: center;
height: 100%;
}
.card-header {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
display: block;
margin-block-start: 0px;
font-weight: var(--ha-font-weight-normal);
white-space: nowrap;
max-width: calc(100% - 150px);
overflow: hidden;
text-overflow: ellipsis;
}
.error-log {
position: relative;
font-family: var(--ha-font-family-code);
clear: both;
text-align: start;
padding-top: var(--ha-space-4);
padding-bottom: var(--ha-space-4);
overflow-y: scroll;
min-height: var(--error-log-card-height, calc(100vh - 244px));
max-height: var(--error-log-card-height, calc(100vh - 244px));
border-top: 1px solid var(--divider-color);
direction: ltr;
}
.error-log > div {
padding: 0 var(--ha-space-4);
overflow: auto;
}
.error {
color: var(--error-color);
padding: var(--ha-space-4);
}
.new-logs-indicator {
overflow: hidden;
position: absolute;
bottom: var(--ha-space-1);
left: var(--ha-space-1);
height: 0;
transition: height 0.4s ease-out;
}
.new-logs-indicator.visible {
height: 32px;
}
@keyframes breathe {
from {
opacity: 0.8;
}
to {
opacity: 0;
}
}
.live-indicator {
position: absolute;
bottom: 0;
inset-inline-end: var(--ha-space-4);
border-top-right-radius: var(--ha-space-2);
border-top-left-radius: var(--ha-space-2);
background-color: var(--primary-color);
color: var(--text-primary-color);
padding: var(--ha-space-1) var(--ha-space-2);
opacity: 0.8;
}
.live-indicator ha-svg-icon {
animation: breathe 1s cubic-bezier(0.5, 0, 1, 1) infinite alternate;
height: 14px;
width: 14px;
}
@media all and (max-width: 870px) {
.error-log {
min-height: var(--error-log-card-height, calc(100vh - 190px));
max-height: var(--error-log-card-height, calc(100vh - 190px));
}
ha-button-menu {
max-width: 50%;
}
ha-button {
max-width: 100%;
}
ha-button::part(label) {
overflow: hidden;
white-space: nowrap;
}
}
ha-list-item[selected] {
color: var(--primary-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"logs-viewer": LogsViewer;
}
}

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -152,7 +152,7 @@
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.5",
"@bundle-stats/plugin-webpack-filter": "4.21.6",
"@lokalise/node-api": "15.3.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
@@ -183,7 +183,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.38.0",
"eslint": "9.39.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
@@ -217,7 +217,7 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.46.2",
"typescript-eslint": "8.46.3",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6",
"webpack-stats-plugin": "1.1.3",

96
script/develop-logs Executable file
View File

@@ -0,0 +1,96 @@
#!/bin/sh
# Run the logs frontend development server
# Stop on errors
set -e
cd "$(dirname "$0")/.."
# Parse command line arguments
SUPERVISOR_ENDPOINT=""
SUPERVISOR_API_TOKEN=""
while getopts "c:t:h" opt; do
case $opt in
c)
SUPERVISOR_ENDPOINT="$OPTARG"
;;
t)
SUPERVISOR_API_TOKEN="$OPTARG"
;;
h)
echo "Usage: $0 -c SUPERVISOR_ENDPOINT -t SUPERVISOR_API_TOKEN"
echo ""
echo "Options:"
echo " -c SUPERVISOR_ENDPOINT (e.g., http://192.168.1.2) [required]"
echo " -t SUPERVISOR_API_TOKEN (from remote_api add-on) [required]"
echo " -h Show this help message"
echo ""
echo "Example:"
echo " $0 -c http://192.168.1.2 -t your_token_here"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
# Validate that both -c and -t are provided
if [ -z "$SUPERVISOR_ENDPOINT" ] || [ -z "$SUPERVISOR_API_TOKEN" ]; then
echo "Error: Both -c and -t are required" >&2
echo "Use -h for help"
exit 1
fi
# Cleanup function
cleanup() {
echo ""
echo "Shutting down..."
echo "Stopping HA CLI container..."
docker stop ha-cli-dev 2>/dev/null || true
exit 0
}
# Set up trap to cleanup on exit
trap cleanup INT TERM EXIT
# Run HA CLI container
echo "Starting HA CLI container..."
# Build the container if needed
if ! docker images | grep -q "ha-cli:local"; then
echo "Building HA CLI container..."
(cd logs && docker compose build)
fi
# Clean up any existing container
docker stop ha-cli-dev 2>/dev/null || true
# Run the container in background (not detached, so it shares stdout)
docker run \
--name ha-cli-dev \
--rm \
-p 5642:5642 \
-e SUPERVISOR_ENDPOINT="$SUPERVISOR_ENDPOINT" \
-e SUPERVISOR_API_TOKEN="$SUPERVISOR_API_TOKEN" \
-e PORT=5642 \
ha-cli:local &
# Store the docker process ID
DOCKER_PID=$!
# Wait a moment for container to start
sleep 2
echo ""
echo "HA Logs Backend API: http://localhost:5642"
echo " GET /api/logs - List endpoints"
echo " GET /api/logs/core - Core logs"
echo " GET /api/logs/supervisor - Supervisor logs"
echo " GET /health - Health check"
echo ""
# Run gulp (this will block until Ctrl+C)
./node_modules/.bin/gulp develop-logs

View File

@@ -1,4 +1,5 @@
/* eslint-disable lit/prefer-static-styles */
import { mdiOpenInNew } from "@mdi/js";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -6,6 +7,8 @@ import punycode from "punycode";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import { extractSearchParamsObject } from "../common/url/search-params";
import "../components/ha-alert";
import "../components/ha-button";
import "../components/ha-svg-icon";
import type { AuthProvider, AuthUrlSearchParams } from "../data/auth";
import { fetchAuthProviders } from "../data/auth";
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
@@ -133,25 +136,8 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
justify-content: space-between;
align-items: center;
}
ha-language-picker {
width: 200px;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
--ha-select-height: 40px;
--mdc-select-fill-color: none;
--mdc-select-label-ink-color: var(--primary-text-color, #212121);
--mdc-select-ink-color: var(--primary-text-color, #212121);
--mdc-select-idle-line-color: transparent;
--mdc-select-hover-line-color: transparent;
--mdc-select-dropdown-icon-color: var(--primary-text-color, #212121);
--mdc-shape-small: 0;
}
.footer a {
text-decoration: none;
color: var(--primary-text-color);
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
}
h1 {
font-size: var(--ha-font-size-3xl);
@@ -205,16 +191,21 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
<ha-language-picker
.value=${this.language}
.label=${""}
button-style
native-name
@value-changed=${this._languageChanged}
inline-arrow
></ha-language-picker>
<a
<ha-button
appearance="plain"
variant="neutral"
href="https://www.home-assistant.io/docs/authentication/"
target="_blank"
rel="noreferrer noopener"
>${this.localize("ui.panel.page-authorize.help")}</a
>
${this.localize("ui.panel.page-authorize.help")}
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-button>
</div>
`;
}

View File

@@ -35,7 +35,6 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
const RESIZE_ANIMATION_DURATION = 250;
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
@@ -91,6 +90,8 @@ export class HaChartBase extends LitElement {
private _shouldResizeChart = false;
private _resizeAnimationDuration?: number;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => {
@@ -214,6 +215,7 @@ export class HaChartBase extends LitElement {
) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
}
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
@@ -977,11 +979,14 @@ export class HaChartBase extends LitElement {
private _handleChartRenderFinished = () => {
if (this._shouldResizeChart) {
this.chart?.resize({
animation: this._reducedMotion
? undefined
: { duration: RESIZE_ANIMATION_DURATION },
animation:
this._reducedMotion ||
typeof this._resizeAnimationDuration !== "number"
? undefined
: { duration: this._resizeAnimationDuration },
});
this._shouldResizeChart = false;
this._resizeAnimationDuration = undefined;
}
};

View File

@@ -87,6 +87,8 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
@@ -375,6 +377,7 @@ export class HaAreaPicker extends LitElement {
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -10,7 +10,6 @@ import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {

View File

@@ -1,6 +1,7 @@
import { mdiMenuDown } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { formatLanguageCode } from "../common/language/format_language";
@@ -8,10 +9,10 @@ import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { FrontendLocaleData } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-button";
import "./ha-generic-picker";
import "./ha-list-item";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-select";
export const getLanguageOptions = (
languages: string[],
@@ -75,6 +76,9 @@ export class HaLanguagePicker extends LitElement {
@property({ attribute: "native-name", type: Boolean })
public nativeName = false;
@property({ type: Boolean, attribute: "button-style" })
public buttonStyle = false;
@property({ attribute: "no-sort", type: Boolean }) public noSort = false;
@property({ attribute: "inline-arrow", type: Boolean })
@@ -82,6 +86,8 @@ export class HaLanguagePicker extends LitElement {
@state() _defaultLanguages: string[] = [];
@query("ha-generic-picker", true) public genericPicker!: HaGenericPicker;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._computeDefaultLanguageOptions();
@@ -101,12 +107,13 @@ export class HaLanguagePicker extends LitElement {
this.hass?.locale
);
private _valueRenderer = (value) => {
const language = this._getItems().find(
(lang) => lang.id === value
)?.primary;
return html`<span slot="headline">${language ?? value}</span> `;
};
private _getLanguageName = (lang?: string) =>
this._getItems().find((language) => language.id === lang)?.primary;
private _valueRenderer = (value) =>
html`<span slot="headline"
>${this._getLanguageName(value) ?? value}</span
> `;
protected render() {
const value =
@@ -130,10 +137,28 @@ export class HaLanguagePicker extends LitElement {
.getItems=${this._getItems}
@value-changed=${this._changed}
hide-clear-icon
></ha-generic-picker>
>
${this.buttonStyle
? html`<ha-button
slot="field"
.disabled=${this.disabled}
@click=${this._openPicker}
appearance="plain"
variant="neutral"
>
${this._getLanguageName(value)}
<ha-svg-icon slot="end" .path=${mdiMenuDown}></ha-svg-icon>
</ha-button>`
: nothing}
</ha-generic-picker>
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this.genericPicker.open();
}
static styles = css`
ha-generic-picker {
width: 100%;

View File

@@ -87,166 +87,208 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
protected render() {
if (this.addOnTop) {
return html` ${this._renderChips()} ${this._renderItems()} `;
return html` ${this._renderPicker()} ${this._renderItems()} `;
}
return html` ${this._renderItems()} ${this._renderChips()} `;
return html` ${this._renderItems()} ${this._renderPicker()} `;
}
private _renderValueChips() {
return html`<div class="mdc-chip-set items">
${this.value?.floor_id
? ensureArray(this.value.floor_id).map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.area_id
? ensureArray(this.value.area_id).map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.device_id
? ensureArray(this.value.device_id).map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.entity_id
? ensureArray(this.value.entity_id).map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${this.value?.label_id
? ensureArray(this.value.label_id).map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
</div>`;
}
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value.label_id)
: [];
private _renderValueGroups() {
return html`<div class="item-groups">
${this.value?.entity_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="entity"
.hass=${this.hass}
.items=${{ entity: ensureArray(this.value?.entity_id) }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${this.value?.device_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="device"
.hass=${this.hass}
.items=${{ device: ensureArray(this.value?.device_id) }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${this.value?.floor_id || this.value?.area_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="area"
.hass=${this.hass}
.items=${{
floor: ensureArray(this.value?.floor_id),
area: ensureArray(this.value?.area_id),
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${this.value?.label_id
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="label"
.hass=${this.hass}
.items=${{ label: ensureArray(this.value?.label_id) }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
</div>`;
}
private _renderItems() {
if (
!this.value?.floor_id &&
!this.value?.area_id &&
!this.value?.device_id &&
!this.value?.entity_id &&
!this.value?.label_id
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
) {
return nothing;
}
return html`
<div class="mdc-chip-set items">
${floorIds.length
? floorIds.map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${areaIds.length
? areaIds.map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${deviceIds.length
? deviceIds.map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${entityIds.length
? entityIds.map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
${labelIds.length
? labelIds.map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing}
</div>
`;
}
private _renderValueGroups() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value?.label_id)
: [];
if (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
) {
return nothing;
}
return html`
<div class="item-groups">
${entityIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="entity"
.hass=${this.hass}
.items=${{ entity: entityIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${deviceIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${floorIds.length || areaIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="area"
.hass=${this.hass}
.items=${{
floor: floorIds,
area: areaIds,
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
${labelIds.length
? html`
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
type="label"
.hass=${this.hass}
.items=${{ label: labelIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
>
</ha-target-picker-item-group>
`
: nothing}
</div>
`;
}
private _renderItems() {
return html`
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
`;
}
private _renderChips() {
private _renderPicker() {
return html`
<div class="add-target-wrapper">
<ha-button
@@ -347,7 +389,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._pickerFilter = filter;
};
private _hidePicker() {
private _hidePicker(ev) {
ev.stopPropagation();
this._open = false;
this._pickerWrapperOpen = false;

View File

@@ -6,6 +6,7 @@ import {
mdiLabel,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -19,9 +20,12 @@ import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label_registry";
import {
@@ -111,10 +115,10 @@ export class HaTargetPickerItemRow extends LitElement {
}
protected render() {
const { name, context, iconPath, fallbackIconPath, stateObject } =
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
this._itemData(this.type, this.itemId);
const showEntities = this.type !== "entity";
const showEntities = this.type !== "entity" && !notFound;
const entries = this.parentEntries || this._entries;
@@ -128,7 +132,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
return html`
<ha-md-list-item type="text">
<ha-md-list-item type="text" class=${notFound ? "error" : ""}>
<div class="icon" slot="start">
${this.subEntry
? html`
@@ -148,11 +152,15 @@ export class HaTargetPickerItemRow extends LitElement {
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
: this.type === "entity"
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
@@ -160,8 +168,14 @@ export class HaTargetPickerItemRow extends LitElement {
</div>
<div slot="headline">${name}</div>
${context && !this.hideContext
? html`<span slot="supporting-text">${context}</span>`
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
@@ -474,26 +488,28 @@ export class HaTargetPickerItemRow extends LitElement {
private _itemData = memoizeOne((type: TargetType, item: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[item];
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
return {
name: floor?.name || item,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
notFound: !floor,
};
}
if (type === "area") {
const area = this.hass.areas?.[item];
const area: AreaRegistryEntry | undefined = this.hass.areas?.[item];
return {
name: area?.name || item,
context: area.floor_id && this.hass.floors?.[area.floor_id]?.name,
context: area?.floor_id && this.hass.floors?.[area.floor_id]?.name,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
notFound: !area,
};
}
if (type === "device") {
const device = this.hass.devices?.[item];
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
if (device.primary_config_entry) {
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -501,24 +517,25 @@ export class HaTargetPickerItemRow extends LitElement {
name: device ? computeDeviceNameDisplay(device, this.hass) : item,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
fallbackIconPath: mdiDevices,
notFound: !device,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject = this.hass.states[item];
const entityName = computeEntityName(
stateObject,
this.hass.entities,
this.hass.devices
);
const { area, device } = getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
@@ -528,15 +545,19 @@ export class HaTargetPickerItemRow extends LitElement {
name: entityName || deviceName || item,
context,
stateObject,
notFound: !stateObject && item !== "all",
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === item);
const label: LabelRegistryEntry | undefined = this._labelRegistry.find(
(lab) => lab.label_id === item
);
return {
name: label?.name || item,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
notFound: !label,
};
});
@@ -597,17 +618,27 @@ export class HaTargetPickerItemRow extends LitElement {
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.error {
background: var(--ha-color-fill-warning-quiet-resting);
}
.error [slot="supporting-text"] {
color: var(--ha-color-on-warning-normal);
}
state-badge {
color: var(--ha-color-on-neutral-quiet);
}
.icon {
width: 24px;
display: flex;
}
img {
width: 24px;
height: 24px;
z-index: 1;
}
ha-icon-button {
--mdc-icon-button-size: 32px;

View File

@@ -705,7 +705,7 @@ export class HaTargetPickerSelector extends LitElement {
) as EntityComboBoxItem[];
}
if (!filterType) {
if (!filterType && entities.length) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.entities")
@@ -733,7 +733,7 @@ export class HaTargetPickerSelector extends LitElement {
devices = this._filterGroup("device", devices);
}
if (!filterType) {
if (!filterType && devices.length) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.devices")
@@ -769,7 +769,7 @@ export class HaTargetPickerSelector extends LitElement {
) as FloorComboBoxItem[];
}
if (!filterType) {
if (!filterType && areasAndFloors.length) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.areas")
@@ -811,7 +811,7 @@ export class HaTargetPickerSelector extends LitElement {
labels = this._filterGroup("label", labels);
}
if (!filterType) {
if (!filterType && labels.length) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.labels")

View File

@@ -12,6 +12,7 @@ import {
mdiChatSleep,
mdiClipboardList,
mdiClock,
mdiCodeBraces,
mdiCog,
mdiCommentAlert,
mdiCounter,
@@ -113,6 +114,7 @@ export const FALLBACK_DOMAIN_ICONS = {
text: mdiFormTextbox,
time: mdiClock,
timer: mdiTimerOutline,
template: mdiCodeBraces,
todo: mdiClipboardList,
tts: mdiSpeakerMessage,
vacuum: mdiRobotVacuum,

View File

@@ -0,0 +1,152 @@
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-spinner";
import type {
ExternalEntityAddToActions,
ExternalEntityAddToAction,
} from "../../external_app/external_messaging";
import { showToast } from "../../util/toast";
import type { HomeAssistant } from "../../types";
@customElement("ha-more-info-add-to")
export class HaMoreInfoAddTo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@state() private _externalActions?: ExternalEntityAddToActions = {
actions: [],
};
@state() private _loading = true;
private async _loadExternalActions() {
if (this.hass.auth.external?.config.hasEntityAddTo) {
this._externalActions =
await this.hass.auth.external?.sendMessage<"entity/add_to/get_actions">(
{
type: "entity/add_to/get_actions",
payload: { entity_id: this.entityId },
}
);
}
}
private async _actionSelected(ev: CustomEvent) {
const action = (ev.currentTarget as any)
.action as ExternalEntityAddToAction;
if (!action.enabled) {
return;
}
try {
await this.hass.auth.external!.fireMessage({
type: "entity/add_to",
payload: {
entity_id: this.entityId,
app_payload: action.app_payload,
},
});
} catch (err: any) {
showToast(this, {
message: this.hass.localize(
"ui.dialogs.more_info_control.add_to.action_failed",
{
error: err.message || err,
}
),
});
}
}
protected async firstUpdated() {
await this._loadExternalActions();
this._loading = false;
}
protected render() {
if (this._loading) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
if (!this._externalActions?.actions.length) {
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.no_actions"
)}
</ha-alert>
`;
}
return html`
<div class="actions-list">
${this._externalActions.actions.map(
(action) => html`
<ha-list-item
graphic="icon"
.disabled=${!action.enabled}
.action=${action}
.twoline=${!!action.details}
@click=${this._actionSelected}
>
<span>${action.name}</span>
${action.details
? html`<span slot="secondary">${action.details}</span>`
: nothing}
<ha-icon slot="graphic" .icon=${action.mdi_icon}></ha-icon>
</ha-list-item>
`
)}
</div>
`;
}
static styles = css`
:host {
display: block;
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: var(--ha-space-8);
}
.actions-list {
display: flex;
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;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-add-to": HaMoreInfoAddTo;
}
}

View File

@@ -8,6 +8,7 @@ import {
mdiPencil,
mdiPencilOff,
mdiPencilOutline,
mdiPlusBoxMultipleOutline,
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -60,6 +61,7 @@ import {
computeShowLogBookComponent,
} from "./const";
import "./controls/more-info-default";
import "./ha-more-info-add-to";
import "./ha-more-info-history-and-logbook";
import "./ha-more-info-info";
import "./ha-more-info-settings";
@@ -73,7 +75,7 @@ export interface MoreInfoDialogParams {
data?: Record<string, any>;
}
type View = "info" | "history" | "settings" | "related";
type View = "info" | "history" | "settings" | "related" | "add_to";
interface ChildView {
viewTag: string;
@@ -194,6 +196,10 @@ export class MoreInfoDialog extends LitElement {
);
}
private _shouldShowAddEntityTo(): boolean {
return !!this.hass.auth.external?.config.hasEntityAddTo;
}
private _getDeviceId(): string | null {
const entity = this.hass.entities[this._entityId!] as
| EntityRegistryEntry
@@ -295,6 +301,11 @@ export class MoreInfoDialog extends LitElement {
this._setView("related");
}
private _goToAddEntityTo(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) return;
this._setView("add_to");
}
private _breadcrumbClick(ev: Event) {
ev.stopPropagation();
this._setView("related");
@@ -521,6 +532,22 @@ export class MoreInfoDialog extends LitElement {
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
${this._shouldShowAddEntityTo()
? html`
<ha-list-item
graphic="icon"
@request-selected=${this._goToAddEntityTo}
>
${this.hass.localize(
"ui.dialogs.more_info_control.add_entity_to"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPlusBoxMultipleOutline}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
</ha-button-menu>
`
: nothing}
@@ -613,7 +640,14 @@ export class MoreInfoDialog extends LitElement {
: "entity"}
></ha-related-items>
`
: nothing
: this._currView === "add_to"
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
></ha-more-info-add-to>
`
: nothing
)}
</div>
`

View File

@@ -36,6 +36,13 @@ interface EMOutgoingMessageConfigGet extends EMMessage {
type: "config/get";
}
interface EMOutgoingMessageEntityAddToGetActions extends EMMessage {
type: "entity/add_to/get_actions";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan";
payload: {
@@ -75,6 +82,10 @@ interface EMOutgoingMessageWithAnswer {
request: EMOutgoingMessageConfigGet;
response: ExternalConfig;
};
"entity/add_to/get_actions": {
request: EMOutgoingMessageEntityAddToGetActions;
response: ExternalEntityAddToActions;
};
}
interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage {
@@ -157,6 +168,14 @@ interface EMOutgoingMessageThreadStoreInPlatformKeychain extends EMMessage {
};
}
interface EMOutgoingMessageAddEntityTo extends EMMessage {
type: "entity/add_to";
payload: {
entity_id: string;
app_payload: string; // Opaque string received from get_actions
};
}
type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError
| EMMessageResultSuccess
@@ -177,7 +196,8 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageThemeUpdate
| EMOutgoingMessageThreadStoreInPlatformKeychain
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice;
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo;
export interface EMIncomingMessageRestart {
id: number;
@@ -293,18 +313,31 @@ type EMIncomingMessage =
type EMIncomingMessageHandler = (msg: EMIncomingMessageCommands) => boolean;
export interface ExternalConfig {
hasSettingsScreen: boolean;
hasSidebar: boolean;
canWriteTag: boolean;
hasExoPlayer: boolean;
canCommissionMatter: boolean;
canImportThreadCredentials: boolean;
canTransferThreadCredentialsToKeychain: boolean;
hasAssist: boolean;
hasBarCodeScanner: number;
canSetupImprov: boolean;
downloadFileSupported: boolean;
appVersion: string;
hasSettingsScreen?: boolean;
hasSidebar?: boolean;
canWriteTag?: boolean;
hasExoPlayer?: boolean;
canCommissionMatter?: boolean;
canImportThreadCredentials?: boolean;
canTransferThreadCredentialsToKeychain?: boolean;
hasAssist?: boolean;
hasBarCodeScanner?: number;
canSetupImprov?: boolean;
downloadFileSupported?: boolean;
appVersion?: string;
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
}
export interface ExternalEntityAddToAction {
enabled: boolean;
name: string; // Translated name of the action to be displayed in the UI
details?: string; // Optional translated details of the action to be displayed in the UI
mdi_icon: string; // MDI icon name to be displayed in the UI (e.g., "mdi:car")
app_payload: string; // Opaque string to be sent back when the action is selected
}
export interface ExternalEntityAddToActions {
actions: ExternalEntityAddToAction[];
}
export class ExternalMessaging {

View File

@@ -33,7 +33,7 @@ const COMPONENTS = {
"media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"),
light: () => import("../panels/light/ha-panel-light"),
safety: () => import("../panels/safety/ha-panel-safety"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
};

View File

@@ -0,0 +1,104 @@
import type { ReactiveElement } from "lit";
import { listenMediaQuery } from "../common/dom/media_query";
import type { HomeAssistant } from "../types";
import type { Condition } from "../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../panels/lovelace/common/validate-condition";
type Constructor<T> = abstract new (...args: any[]) => T;
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
}, []);
}
/**
* Helper to setup media query listeners for conditional visibility
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const mediaQueries = extractMediaQueries(conditions);
if (mediaQueries.length === 0) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
});
addListener(unsub);
});
}
/**
* Mixin to handle conditional listeners for visibility control
*
* Provides lifecycle management for listeners (media queries, time-based, state changes, etc.)
* that control conditional visibility of components.
*
* Usage:
* 1. Extend your component with ConditionalListenerMixin(ReactiveElement)
* 2. Override setupConditionalListeners() to setup your listeners
* 3. Use addConditionalListener() to register unsubscribe functions
* 4. Call clearConditionalListeners() and setupConditionalListeners() when config changes
*
* The mixin automatically:
* - Sets up listeners when component connects to DOM
* - Cleans up listeners when component disconnects from DOM
*/
export const ConditionalListenerMixin = <
T extends Constructor<ReactiveElement>,
>(
superClass: T
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
public connectedCallback() {
super.connectedCallback();
this.setupConditionalListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this.clearConditionalListeners();
}
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
}
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
protected setupConditionalListeners(): void {
// Override in subclass
}
}
return ConditionalListenerClass;
};

View File

@@ -1,24 +1,23 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const CLIMATE_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "climate",
},
},
],
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "climate",
},
};
@customElement("ha-panel-climate")
@@ -33,65 +32,119 @@ class PanelClimate extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
public firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render(): TemplateResult {
protected render() {
return html`
<div class="header">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
<div class="main-title">${this.hass.localize("panel.climate")}</div>
</div>
</div>
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</hui-view-container>
`;
}
private _setLovelace() {
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
CLIMATE_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._lovelace = {
config: CLIMATE_LOVELACE_CONFIG,
rawConfig: CLIMATE_LOVELACE_CONFIG,
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "climate",
mode: "generated",

View File

@@ -8,24 +8,24 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-area-picker";
import "../../../components/ha-button";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import "../../../components/ha-area-picker";
import { updateAreaRegistryEntry } from "../../../data/area_registry";
import type {
FloorRegistryEntry,
FloorRegistryEntryMutableParams,
} from "../../../data/floor_registry";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail";
import { showAreaRegistryDetailDialog } from "./show-dialog-area-registry-detail";
import { updateAreaRegistryEntry } from "../../../data/area_registry";
import type { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail";
class DialogFloorDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -168,11 +168,6 @@ class DialogFloorDetail extends LitElement {
)}
</h3>
<p class="description">
${this.hass.localize(
"ui.panel.config.floors.editor.areas_description"
)}
</p>
${areas.length
? html`<ha-chip-set>
${repeat(
@@ -197,13 +192,17 @@ class DialogFloorDetail extends LitElement {
</ha-input-chip>`
)}
</ha-chip-set>`
: nothing}
: html`<p class="description">
${this.hass.localize(
"ui.panel.config.floors.editor.areas_description"
)}
</p>`}
<ha-area-picker
no-add
.hass=${this.hass}
@value-changed=${this._addArea}
.excludeAreas=${areas.map((a) => a.area_id)}
.label=${this.hass.localize(
.addButtonLabel=${this.hass.localize(
"ui.panel.config.floors.editor.add_area"
)}
></ha-area-picker>

View File

@@ -257,7 +257,7 @@ class DialogAddAutomationElement
const results = fuse.multiTermsSearch(filter);
if (results) {
return results.map((result) => result.item);
return results.map((result) => result.item).filter((item) => item.name);
}
return items;
}
@@ -294,7 +294,7 @@ class DialogAddAutomationElement
const results = fuse.multiTermsSearch(filter);
if (results) {
return results.map((result) => result.item);
return results.map((result) => result.item).filter((item) => item.name);
}
return items;
}
@@ -383,9 +383,16 @@ class DialogAddAutomationElement
generatedCollections.push({
titleKey: collection.titleKey,
groups: groups.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
),
groups: groups.sort((a, b) => {
// make sure device is always on top
if (a.key === "device" || a.key === "device_id") {
return -1;
}
if (b.key === "device" || b.key === "device_id") {
return 1;
}
return stringCompare(a.name, b.name, this.hass.locale.language);
}),
});
});
return generatedCollections;
@@ -678,7 +685,7 @@ class DialogAddAutomationElement
);
const typeTitle = this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.header`
`ui.panel.config.automation.editor.${automationElementType}s.add`
);
const tabButtons = [
@@ -895,7 +902,9 @@ class DialogAddAutomationElement
return html`
<div class="items-title ${this._itemsScrolled ? "scrolled" : ""}">
${title}
${this._tab === "blocks" && !this._filter
? this.hass.localize("ui.panel.config.automation.editor.blocks")
: title}
</div>
<ha-md-list
dialogInitialFocus=${ifDefined(this._fullScreen ? "" : undefined)}
@@ -1045,6 +1054,7 @@ class DialogAddAutomationElement
private _onSearchFocus(ev) {
this._removeKeyboardShortcuts = tinykeys(ev.target, {
ArrowDown: this._focusSearchList,
Enter: this._pickSingleItem,
});
}
@@ -1061,6 +1071,39 @@ class DialogAddAutomationElement
this._itemsListFirstElement.focus();
};
private _pickSingleItem = (ev: KeyboardEvent) => {
if (!this._filter) {
return;
}
ev.preventDefault();
const automationElementType = this._params!.type;
const items = [
...this._getFilteredItems(
automationElementType,
this._filter,
this.hass.localize,
this.hass.services,
this._manifests
),
...(automationElementType !== "trigger"
? this._getFilteredBuildingBlocks(
automationElementType,
this._filter,
this.hass.localize
)
: []),
];
if (items.length !== 1) {
return;
}
this._params!.add(items[0].key);
this.closeDialog();
};
static get styles(): CSSResultGroup {
return [
css`

View File

@@ -20,7 +20,8 @@ import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
import type { ActionSidebarConfig } from "../../../../data/automation";
import type { RepeatAction } from "../../../../data/script";
import { domainToName } from "../../../../data/integration";
import type { RepeatAction, ServiceAction } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
@@ -83,11 +84,23 @@ export default class HaAutomationSidebarAction extends LitElement {
"ui.panel.config.automation.editor.actions.action"
);
const title =
let title =
this.hass.localize(
`ui.panel.config.automation.editor.actions.type.${type}.label` as LocalizeKeys
) || type;
if (type === "service" && (actionConfig as ServiceAction).action) {
const [domain, service] = (actionConfig as ServiceAction).action!.split(
".",
2
);
title = `${domainToName(this.hass.localize, domain)}: ${
this.hass.localize(`component.${domain}.services.${service}.name`) ||
this.hass.services[domain][service]?.name ||
title
}`;
}
const description = isBuildingBlock
? this.hass.localize(
`ui.panel.config.automation.editor.actions.type.${type}.description.picker` as LocalizeKeys

View File

@@ -9,6 +9,7 @@ import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-markdown";
import type { HaRadio } from "../../../../components/ha-radio";
import type {
FlowFromGridSourceEnergyPreference,
@@ -19,11 +20,7 @@ import {
emptyFlowToGridSourceEnergyPreference,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getDisplayUnit,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
import { isExternalStatistic } from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
@@ -47,8 +44,6 @@ export class DialogEnergyGridFlowSettings
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _pickedDisplayUnit?: string | null;
@state() private _energy_units?: string[];
@state() private _error?: string;
@@ -81,11 +76,6 @@ export class DialogEnergyGridFlowSettings
: "stat_energy_to"
];
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
initialSourceId,
params.metadata
);
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
@@ -103,7 +93,6 @@ export class DialogEnergyGridFlowSettings
public closeDialog() {
this._params = undefined;
this._source = undefined;
this._pickedDisplayUnit = undefined;
this._error = undefined;
this._excludeList = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -117,10 +106,6 @@ export class DialogEnergyGridFlowSettings
const pickableUnit = this._energy_units?.join(", ") || "";
const unitPriceSensor = this._pickedDisplayUnit
? `${this.hass.config.currency}/${this._pickedDisplayUnit}`
: undefined;
const unitPriceFixed = `${this.hass.config.currency}/kWh`;
const externalSource =
@@ -246,9 +231,15 @@ export class DialogEnergyGridFlowSettings
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${`${this.hass.localize(
.label=${this.hass.localize(
`ui.panel.config.energy.grid.flow_dialog.${this._params.direction}.cost_entity_input`
)} ${unitPriceSensor ? ` (${unitPriceSensor})` : ""}`}
)}
.helper=${html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.grid.flow_dialog.cost_entity_helper",
{ currency: this.hass.config.currency }
)}
></ha-markdown>`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
@@ -341,16 +332,6 @@ export class DialogEnergyGridFlowSettings
}
private async _statisticChanged(ev: CustomEvent<{ value: string }>) {
if (ev.detail.value) {
const metadata = await getStatisticMetadata(this.hass, [ev.detail.value]);
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
ev.detail.value,
metadata[0]
);
} else {
this._pickedDisplayUnit = undefined;
}
this._source = {
...this._source!,
[this._params!.direction === "from"

View File

@@ -107,6 +107,8 @@ class HaConfigInfo extends LitElement {
const customUiList: { name: string; url: string; version: string }[] =
(window as any).CUSTOM_UI_LIST || [];
const isDark = this.hass.themes?.darkMode || false;
return html`
<hass-subpage
.hass=${this.hass}
@@ -186,7 +188,7 @@ class HaConfigInfo extends LitElement {
: nothing}
</ul>
</ha-card>
<ha-card outlined class="ohf">
<ha-card outlined class="ohf ${isDark ? "dark" : ""}">
<div>
${this.hass.localize("ui.panel.config.info.proud_part_of")}
</div>
@@ -346,6 +348,10 @@ class HaConfigInfo extends LitElement {
max-width: 250px;
}
.ohf.dark img {
color-scheme: dark;
}
.versions {
display: flex;
flex-direction: column;

View File

@@ -332,13 +332,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
});
}
if (this.hass.panels.safety) {
if (this.hass.panels.security) {
result.push({
icon: "mdi:security",
title: this.hass.localize("panel.safety"),
title: this.hass.localize("panel.security"),
show_in_sidebar: false,
mode: "storage",
url_path: "safety",
url_path: "security",
filename: "",
default: false,
require_admin: false,
@@ -470,13 +470,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
}
private _canDelete(urlPath: string) {
return !["lovelace", "energy", "light", "safety", "climate"].includes(
return !["lovelace", "energy", "light", "security", "climate"].includes(
urlPath
);
}
private _canEdit(urlPath: string) {
return !["light", "safety", "climate"].includes(urlPath);
return !["light", "security", "climate"].includes(urlPath);
}
private _handleDelete = async (item: DataTableItem) => {

View File

@@ -109,15 +109,26 @@ export class AssistPipelineDetailConversation extends LitElement {
}
private _supportedLanguagesChanged(ev) {
if (ev.detail.value === "*") {
this._supportedLanguages = ev.detail.value;
if (
this._supportedLanguages === "*" ||
!this._supportedLanguages?.includes(
this.data?.conversation_language || ""
) ||
!this.data?.conversation_language
) {
// wait for update of conversation_engine
setTimeout(() => {
const value = { ...this.data };
value.conversation_language = "*";
if (this._supportedLanguages === "*") {
value.conversation_language = "*";
} else {
value.conversation_language = this._supportedLanguages?.[0] ?? null;
}
fireEvent(this, "value-changed", { value });
}, 0);
}
this._supportedLanguages = ev.detail.value;
}
static styles = css`

View File

@@ -214,7 +214,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._updatePipeline}
.disabled=${this._submitting}
.loading=${this._submitting}
dialogInitialFocus
>
${this._params.pipeline?.id

View File

@@ -1,24 +1,23 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const LIGHT_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "light",
},
},
],
const LIGHT_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "light",
},
};
@customElement("ha-panel-light")
@@ -34,60 +33,118 @@ class PanelLight extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render(): TemplateResult {
protected render() {
return html`
<div class="header">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
<div class="main-title">${this.hass.localize("panel.light")}</div>
</div>
</div>
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</hui-view-container>
`;
}
private _setLovelace() {
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
LIGHT_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [LIGHT_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._lovelace = {
config: LIGHT_LOVELACE_CONFIG,
rawConfig: LIGHT_LOVELACE_CONFIG,
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "light",
mode: "generated",

View File

@@ -2,14 +2,14 @@ import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { HomeAssistant } from "../../../types";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { checkConditionsMet } from "../common/validate-condition";
import { createBadgeElement } from "../create-element/create-badge-element";
import { createErrorBadgeConfig } from "../create-element/create-element-base";
import type { LovelaceBadge } from "../types";
@@ -22,7 +22,7 @@ declare global {
}
@customElement("hui-badge")
export class HuiBadge extends ReactiveElement {
export class HuiBadge extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceBadgeConfig;
@@ -40,20 +40,16 @@ export class HuiBadge extends ReactiveElement {
private _element?: LovelaceBadge;
private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() {
return this;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
@@ -137,26 +133,17 @@ export class HuiBadge extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this.config?.visibility) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
const conditions = this.config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this.config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}

View File

@@ -2,16 +2,16 @@ import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../types";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
import { computeCardSize } from "../common/compute-card-size";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
import { checkConditionsMet } from "../common/validate-condition";
import { tryCreateCardElement } from "../create-element/create-card-element";
import { createErrorCardElement } from "../create-element/create-element-base";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
@@ -24,7 +24,7 @@ declare global {
}
@customElement("hui-card")
export class HuiCard extends ReactiveElement {
export class HuiCard extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceCardConfig;
@@ -44,20 +44,16 @@ export class HuiCard extends ReactiveElement {
private _element?: LovelaceCard;
private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() {
return this;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
@@ -251,26 +247,17 @@ export class HuiCard extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this.config?.visibility) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
const conditions = this.config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this.config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}

View File

@@ -33,7 +33,7 @@ import type { HomeSummaryCard } from "./types";
const COLORS: Record<HomeSummary, string> = {
light: "amber",
climate: "deep-orange",
safety: "blue-grey",
security: "blue-grey",
media_players: "blue",
};
@@ -142,20 +142,20 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
? `${formattedMinTemp}°`
: `${formattedMinTemp} - ${formattedMaxTemp}°`;
}
case "safety": {
case "security": {
// Alarm and lock status
const safetyFilters = HOME_SUMMARIES_FILTERS.safety.map((filter) =>
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(this.hass!, filter)
);
const safetyEntities = findEntities(allEntities, safetyFilters);
const securityEntities = findEntities(allEntities, securityFilters);
const locks = safetyEntities.filter((entityId) => {
const locks = securityEntities.filter((entityId) => {
const domain = computeDomain(entityId);
return domain === "lock";
});
const alarms = safetyEntities.filter((entityId) => {
const alarms = securityEntities.filter((entityId) => {
const domain = computeDomain(entityId);
return domain === "alarm_control_panel";
});

View File

@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
@@ -97,7 +98,10 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
: `${sandbox_user_params} ${IFRAME_SANDBOX}`;
return html`
<ha-card .header=${this._config.title}>
<ha-card
class=${classMap({ "hide-background": this._config.hide_background })}
.header=${this._config.title}
>
<div
id="root"
style=${styleMap({
@@ -133,6 +137,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
flex-direction: column;
}
ha-card.hide-background {
background: transparent;
box-shadow: none;
border: none;
}
#root {
width: 100%;
height: 100%;

View File

@@ -323,6 +323,7 @@ export interface IframeCardConfig extends LovelaceCardConfig {
title?: string;
allow?: string;
url: string;
hide_background?: boolean;
}
export interface LightCardConfig extends LovelaceCardConfig {

View File

@@ -67,7 +67,7 @@ export const handleAction = async (
await hass.loadBackendTranslation("title");
const localize = await hass.loadBackendTranslation("services");
serviceName = `${domainToName(localize, domain)}: ${
localize(`component.${domain}.services.${serviceName}.name`) ||
localize(`component.${domain}.services.${service}.name`) ||
serviceDomains[domain][service].name ||
service
}`;

View File

@@ -1,6 +1,4 @@
import { ensureArray } from "../../../common/array/ensure-array";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import { listenMediaQuery } from "../../../common/dom/media_query";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { UNKNOWN } from "../../../data/entity";
@@ -362,31 +360,3 @@ export function addEntityToCondition(
}
return condition;
}
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
}, []);
}
export function attachConditionMediaQueriesListeners(
conditions: Condition[],
onChange: (visibility: boolean) => void
): MediaQueriesListener[] {
const mediaQueries = extractMediaQueries(conditions);
const listeners = mediaQueries.map((query) => {
const listener = listenMediaQuery(query, (matches) => {
onChange(matches);
});
return listener;
});
return listeners;
}

View File

@@ -1,16 +1,16 @@
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import { deepEqual } from "../../../common/util/deep-equal";
import type { HomeAssistant } from "../../../types";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import type { HuiCard } from "../cards/hui-card";
import type { ConditionalCardConfig } from "../cards/types";
import type { Condition } from "../common/validate-condition";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
extractMediaQueries,
validateConditionalConfig,
} from "../common/validate-condition";
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
@@ -22,7 +22,9 @@ declare global {
}
@customElement("hui-conditional-base")
export class HuiConditionalBase extends ReactiveElement {
export class HuiConditionalBase extends ConditionalListenerMixin(
ReactiveElement
) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public preview = false;
@@ -31,10 +33,6 @@ export class HuiConditionalBase extends ReactiveElement {
protected _element?: HuiCard | LovelaceRow;
private _listeners: MediaQueriesListener[] = [];
private _mediaQueries: string[] = [];
protected createRenderRoot() {
return this;
}
@@ -63,21 +61,14 @@ export class HuiConditionalBase extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
protected setupConditionalListeners() {
if (!this._config || !this.hass) {
return;
}
@@ -85,27 +76,13 @@ export class HuiConditionalBase extends ReactiveElement {
const supportedConditions = this._config.conditions.filter(
(c) => "condition" in c
) as Condition[];
const mediaQueries = extractMediaQueries(supportedConditions);
if (deepEqual(mediaQueries, this._mediaQueries)) return;
this._clearMediaQueries();
const conditions = this._config.conditions;
const hasOnlyMediaQuery =
conditions.length === 1 &&
"condition" in conditions[0] &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
supportedConditions,
(matches) => {
if (hasOnlyMediaQuery) {
this.setVisibility(matches);
return;
}
this._updateVisibility();
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this.setVisibility(conditionsMet);
}
);
}
@@ -119,7 +96,8 @@ export class HuiConditionalBase extends ReactiveElement {
changed.has("hass") ||
changed.has("preview")
) {
this._listenMediaQueries();
this.clearConditionalListeners();
this.setupConditionalListeners();
this._updateVisibility();
}
}

View File

@@ -16,6 +16,7 @@ const cardConfigStruct = assign(
url: optional(string()),
aspect_ratio: optional(string()),
allow_open_top_navigation: optional(boolean()),
hide_background: optional(boolean()),
})
);
@@ -29,6 +30,7 @@ const SCHEMA = [
{ name: "aspect_ratio", selector: { text: {} } },
],
},
{ name: "hide_background", selector: { boolean: {} } },
] as const;
@customElement("hui-iframe-card-editor")
@@ -56,6 +58,7 @@ export class HuiIframeCardEditor
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
@@ -65,8 +68,29 @@ export class HuiIframeCardEditor
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) =>
this.hass!.localize(`ui.panel.lovelace.editor.card.generic.${schema.name}`);
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "hide_background":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.iframe.hide_background"
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "hide_background":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.iframe.hide_background_helper"
);
default:
return "";
}
};
}
declare global {

View File

@@ -1,5 +1,7 @@
import { html, LitElement, nothing } from "lit";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
@@ -9,23 +11,21 @@ import {
optional,
string,
} from "superstruct";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entities-picker";
import "../../../../components/ha-target-picker";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-target-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../../../data/entity";
import { filterLogbookCompatibleEntities } from "../../../../data/logbook";
import { targetStruct } from "../../../../data/script";
import { resolveEntityIDs } from "../../../../data/selector";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-logbook-card";
import type { LogbookCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-logbook-card";
import { targetStruct } from "../../../../data/script";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { HaEntityPickerEntityFilterFunc } from "../../../../data/entity";
import { resolveEntityIDs } from "../../../../data/selector";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
@@ -132,7 +132,6 @@ export class HuiLogbookCardEditor
.hass=${this.hass}
.entityFilter=${this._filterFunc}
.value=${this._targetPicker}
add-on-top
@value-changed=${this._entitiesChanged}
></ha-target-picker>
`;
@@ -189,6 +188,13 @@ export class HuiLogbookCardEditor
);
}
};
static styles = css`
ha-target-picker {
display: block;
margin-top: var(--ha-space-4);
}
`;
}
declare global {

View File

@@ -2,13 +2,13 @@ import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import { checkConditionsMet } from "../common/validate-condition";
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
import type { LovelaceHeadingBadge } from "../types";
import type { LovelaceHeadingBadgeConfig } from "./types";
@@ -21,7 +21,7 @@ declare global {
}
@customElement("hui-heading-badge")
export class HuiHeadingBadge extends ReactiveElement {
export class HuiHeadingBadge extends ConditionalListenerMixin(ReactiveElement) {
@property({ type: Boolean }) public preview = false;
@property({ attribute: false }) public config?: LovelaceHeadingBadgeConfig;
@@ -39,20 +39,16 @@ export class HuiHeadingBadge extends ReactiveElement {
private _element?: LovelaceHeadingBadge;
private _listeners: MediaQueriesListener[] = [];
protected createRenderRoot() {
return this;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
this._listenMediaQueries();
this._updateVisibility();
}
@@ -137,26 +133,17 @@ export class HuiHeadingBadge extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this.config?.visibility) {
protected setupConditionalListeners() {
if (!this.config?.visibility || !this.hass) {
return;
}
const conditions = this.config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this.config.visibility,
(matches) => {
this._updateVisibility(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateVisibility(conditionsMet);
}
);
}

View File

@@ -4,7 +4,6 @@ import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import "../../../components/ha-svg-icon";
import type { LovelaceSectionElement } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -14,12 +13,13 @@ import type {
} from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../types";
import {
ConditionalListenerMixin,
setupMediaQueryListeners,
} from "../../../mixins/conditional-listener-mixin";
import "../cards/hui-card";
import type { HuiCard } from "../cards/hui-card";
import {
attachConditionMediaQueriesListeners,
checkConditionsMet,
} from "../common/validate-condition";
import { checkConditionsMet } from "../common/validate-condition";
import { createSectionElement } from "../create-element/create-section-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
@@ -37,7 +37,7 @@ declare global {
}
@customElement("hui-section")
export class HuiSection extends ReactiveElement {
export class HuiSection extends ConditionalListenerMixin(ReactiveElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: LovelaceSectionRawConfig;
@@ -59,8 +59,6 @@ export class HuiSection extends ReactiveElement {
private _layoutElement?: LovelaceSectionElement;
private _listeners: MediaQueriesListener[] = [];
private _config: LovelaceSectionConfig | undefined;
@storage({
@@ -114,14 +112,10 @@ export class HuiSection extends ReactiveElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._clearMediaQueries();
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._listenMediaQueries();
}
this._updateElement();
}
@@ -158,26 +152,17 @@ export class HuiSection extends ReactiveElement {
}
}
private _clearMediaQueries() {
this._listeners.forEach((unsub) => unsub());
this._listeners = [];
}
private _listenMediaQueries() {
this._clearMediaQueries();
if (!this._config?.visibility) {
protected setupConditionalListeners() {
if (!this._config?.visibility || !this.hass) {
return;
}
const conditions = this._config.visibility;
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
conditions[0].media_query != null;
this._listeners = attachConditionMediaQueriesListeners(
setupMediaQueryListeners(
this._config.visibility,
(matches) => {
this._updateElement(hasOnlyMediaQuery && matches);
this.hass,
(unsub) => this.addConditionalListener(unsub),
(conditionsMet) => {
this._updateElement(conditionsMet);
}
);
}
@@ -199,9 +184,6 @@ export class HuiSection extends ReactiveElement {
type: sectionConfig.type || DEFAULT_SECTION_LAYOUT,
};
this._config = sectionConfig;
if (this.isConnected) {
this._listenMediaQueries();
}
// Create a new layout element if necessary.
let addLayoutElement = false;

View File

@@ -48,7 +48,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
import("./home/home-media-players-view-strategy"),
"home-area": () => import("./home/home-area-view-strategy"),
light: () => import("../../light/strategies/light-view-strategy"),
safety: () => import("../../safety/strategies/safety-view-strategy"),
security: () => import("../../security/strategies/security-view-strategy"),
climate: () => import("../../climate/strategies/climate-view-strategy"),
},
section: {

View File

@@ -2,12 +2,12 @@ import type { EntityFilter } from "../../../../../common/entity/entity_filter";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { climateEntityFilters } from "../../../../climate/strategies/climate-view-strategy";
import { lightEntityFilters } from "../../../../light/strategies/light-view-strategy";
import { safetyEntityFilters } from "../../../../safety/strategies/safety-view-strategy";
import { securityEntityFilters } from "../../../../security/strategies/security-view-strategy";
export const HOME_SUMMARIES = [
"light",
"climate",
"safety",
"security",
"media_players",
] as const;
@@ -16,14 +16,14 @@ export type HomeSummary = (typeof HOME_SUMMARIES)[number];
export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
light: "mdi:lamps",
climate: "mdi:home-thermometer",
safety: "mdi:security",
security: "mdi:security",
media_players: "mdi:multimedia",
};
export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
light: lightEntityFilters,
climate: climateEntityFilters,
safety: safetyEntityFilters,
security: securityEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
};
@@ -31,7 +31,7 @@ export const getSummaryLabel = (
localize: LocalizeFunc,
summary: HomeSummary
) => {
if (summary === "light" || summary === "climate" || summary === "safety") {
if (summary === "light" || summary === "climate" || summary === "security") {
return localize(`panel.${summary}`);
}
return localize(`ui.panel.lovelace.strategy.home.summary_list.${summary}`);

View File

@@ -104,7 +104,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
const {
light,
climate,
safety,
security,
media_players: mediaPlayers,
} = entitiesBySummary;
@@ -136,16 +136,16 @@ export class HomeAreaViewStrategy extends ReactiveElement {
});
}
if (safety.length > 0) {
if (security.length > 0) {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
getSummaryLabel(hass.localize, "safety"),
HOME_SUMMARIES_ICONS.safety,
"/safety?historyBack=1"
getSummaryLabel(hass.localize, "security"),
HOME_SUMMARIES_ICONS.security,
"/security?historyBack=1"
),
...safety.map(computeTileCard),
...security.map(computeTileCard),
],
});
}

View File

@@ -162,7 +162,7 @@ export class HomeMainViewStrategy extends ReactiveElement {
generateEntityFilter(hass, filter)
);
const safetyFilters = HOME_SUMMARIES_FILTERS.safety.map((filter) =>
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(hass, filter)
);
@@ -170,7 +170,7 @@ export class HomeMainViewStrategy extends ReactiveElement {
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate = findEntities(allEntities, climateFilters).length > 0;
const hasSafety = findEntities(allEntities, safetyFilters).length > 0;
const hasSecurity = findEntities(allEntities, securityFilters).length > 0;
const summaryCards: LovelaceCardConfig[] = [
hasLights &&
@@ -201,14 +201,14 @@ export class HomeMainViewStrategy extends ReactiveElement {
columns: 4,
},
} satisfies HomeSummaryCard),
hasSafety &&
hasSecurity &&
({
type: "home-summary",
summary: "safety",
summary: "security",
vertical: true,
tap_action: {
action: "navigate",
navigation_path: "/safety?historyBack=1",
navigation_path: "/security?historyBack=1",
},
grid_options: {
rows: 2,

View File

@@ -1,28 +1,27 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
const SAFETY_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "safety",
},
},
],
const SECURITY_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "security",
},
};
@customElement("ha-panel-safety")
class PanelSafety extends LitElement {
@customElement("ha-panel-security")
class PanelSecurity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -34,62 +33,120 @@ class PanelSafety extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass?.locale !== this.hass.locale) {
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
private _back(ev) {
ev.stopPropagation();
goBack();
}
protected render(): TemplateResult {
protected render() {
return html`
<div class="header">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.safety")}</div>
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
<div class="main-title">${this.hass.localize("panel.security")}</div>
</div>
</div>
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</hui-view-container>
`;
}
private _setLovelace() {
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
SECURITY_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [SECURITY_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._lovelace = {
config: SAFETY_LOVELACE_CONFIG,
rawConfig: SAFETY_LOVELACE_CONFIG,
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "safety",
urlPath: "security",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
@@ -191,6 +248,6 @@ class PanelSafety extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-panel-safety": PanelSafety;
"ha-panel-security": PanelSecurity;
}
}

View File

@@ -17,11 +17,11 @@ import {
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
export interface SafetyViewStrategyConfig {
type: "safety";
export interface SecurityViewStrategyConfig {
type: "security";
}
export const safetyEntityFilters: EntityFilter[] = [
export const securityEntityFilters: EntityFilter[] = [
{
domain: "camera",
entity_category: "none",
@@ -67,7 +67,7 @@ export const safetyEntityFilters: EntityFilter[] = [
},
];
const processAreasForSafety = (
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
@@ -81,12 +81,12 @@ const processAreasForSafety = (
const areaFilter = generateEntityFilter(hass, {
area: area.area_id,
});
const areaSafetyEntities = entities.filter(areaFilter);
const areaSecurityEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, "", false);
for (const entityId of areaSafetyEntities) {
for (const entityId of areaSecurityEntities) {
areaCards.push(computeTileCard(entityId));
}
@@ -121,10 +121,10 @@ const processUnassignedEntities = (
return areaCards;
};
@customElement("safety-view-strategy")
export class SafetyViewStrategy extends ReactiveElement {
@customElement("security-view-strategy")
export class SecurityViewStrategy extends ReactiveElement {
static async generate(
_config: SafetyViewStrategyConfig,
_config: SecurityViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = getAreas(hass.areas);
@@ -135,11 +135,11 @@ export class SafetyViewStrategy extends ReactiveElement {
const allEntities = Object.keys(hass.states);
const safetyFilters = safetyEntityFilters.map((filter) =>
const securityFilters = securityEntityFilters.map((filter) =>
generateEntityFilter(hass, filter)
);
const entities = findEntities(allEntities, safetyFilters);
const entities = findEntities(allEntities, securityFilters);
const floorCount = home.floors.length + (home.areas.length ? 1 : 0);
@@ -164,7 +164,7 @@ export class SafetyViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSafety(areaIds, hass, entities);
const areaCards = processAreasForSecurity(areaIds, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -188,7 +188,7 @@ export class SafetyViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSafety(home.areas, hass, entities);
const areaCards = processAreasForSecurity(home.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -209,9 +209,9 @@ export class SafetyViewStrategy extends ReactiveElement {
heading:
sections.length > 0
? hass.localize(
"ui.panel.lovelace.strategy.safety.other_devices"
"ui.panel.lovelace.strategy.security.other_devices"
)
: hass.localize("ui.panel.lovelace.strategy.safety.devices"),
: hass.localize("ui.panel.lovelace.strategy.security.devices"),
},
...unassignedCards,
],
@@ -229,6 +229,6 @@ export class SafetyViewStrategy extends ReactiveElement {
declare global {
interface HTMLElementTagNameMap {
"safety-view-strategy": SafetyViewStrategy;
"security-view-strategy": SecurityViewStrategy;
}
}

View File

@@ -12,7 +12,7 @@
"media_browser": "Media",
"profile": "Profile",
"light": "Lights",
"safety": "Safety",
"security": "Security",
"climate": "Climate"
},
"state": {
@@ -695,6 +695,11 @@
"remove_device_id": "Remove device",
"remove_entity_id": "Remove entity",
"remove_label_id": "Remove label",
"floor_not_found": "Floor not found",
"area_not_found": "Area not found",
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
@@ -1429,6 +1434,7 @@
"back_to_info": "Back to info",
"info": "Information",
"related": "Related",
"add_entity_to": "Add to",
"history": "History",
"aggregate": "5-minute aggregated",
"logbook": "Activity",
@@ -1445,6 +1451,10 @@
"last_action": "Last action",
"last_triggered": "Last triggered"
},
"add_to": {
"no_actions": "No actions available",
"action_failed": "Failed to perform the action {error}"
},
"sun": {
"azimuth": "Azimuth",
"elevation": "Elevation",
@@ -3066,6 +3076,7 @@
"remove_co2_signal": "Remove Electricity Maps integration",
"add_co2_signal": "Add Electricity Maps integration",
"flow_dialog": {
"cost_entity_helper": "Any sensor with a unit of `{currency}/(valid energy unit)` (e.g. `{currency}/Wh` or `{currency}/kWh`) may be used and will be automatically converted.",
"from": {
"header": "Configure grid consumption",
"paragraph": "Grid consumption is the energy that flows from the energy grid to your home.",
@@ -6980,7 +6991,7 @@
"lights": "Lights",
"other_lights": "Other lights"
},
"safety": {
"security": {
"devices": "Devices",
"other_devices": "Other devices"
},
@@ -7778,7 +7789,9 @@
},
"iframe": {
"name": "Webpage",
"description": "The Webpage card allows you to embed your favorite webpage right into Home Assistant."
"description": "The Webpage card allows you to embed your favorite webpage right into Home Assistant.",
"hide_background": "Hide background",
"hide_background_helper": "Useful for pages which allow a transparent background."
},
"light": {
"name": "Light",

220
yarn.lock
View File

@@ -1204,14 +1204,14 @@ __metadata:
languageName: node
linkType: hard
"@bundle-stats/plugin-webpack-filter@npm:4.21.5":
version: 4.21.5
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.21.5"
"@bundle-stats/plugin-webpack-filter@npm:4.21.6":
version: 4.21.6
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.21.6"
dependencies:
tslib: "npm:2.8.1"
peerDependencies:
core-js: ^3.0.0
checksum: 10/a24e4fb0efdd671e8d21116045a22500494198feb451981d40db732cd890b90b7bad9a9f1d7909c8db9a1069f4c6226ae35939b4d865be437ea8e19b989ebcf1
checksum: 10/c61785cb3a68424dfedb50cbc2ca3a82b06ad4594f09cb93d568658449bb7861048e5fa05e90fb6e1b7a765a86fc8b0cf41cb87d138cd96ca286a93611be7a9e
languageName: node
linkType: hard
@@ -1611,21 +1611,21 @@ __metadata:
languageName: node
linkType: hard
"@eslint/config-helpers@npm:^0.4.1":
version: 0.4.1
resolution: "@eslint/config-helpers@npm:0.4.1"
"@eslint/config-helpers@npm:^0.4.2":
version: 0.4.2
resolution: "@eslint/config-helpers@npm:0.4.2"
dependencies:
"@eslint/core": "npm:^0.16.0"
checksum: 10/e3e6ea4cd19f5a9b803b2d0b3f174d53fcd27415587e49943144994104a42845cf300ed6ffdbd149d958482a49de99c326f9ae4c18c9467727ec60ad36cb5ef9
"@eslint/core": "npm:^0.17.0"
checksum: 10/3f2b4712d8e391c36ec98bc200f7dea423dfe518e42956569666831b89ede83b33120c761dfd3ab6347d8e8894a6d4af47254a18d464a71c6046fd88065f6daf
languageName: node
linkType: hard
"@eslint/core@npm:^0.16.0":
version: 0.16.0
resolution: "@eslint/core@npm:0.16.0"
"@eslint/core@npm:^0.17.0":
version: 0.17.0
resolution: "@eslint/core@npm:0.17.0"
dependencies:
"@types/json-schema": "npm:^7.0.15"
checksum: 10/3cea45971b2d0114267b6101b673270b5d8047448cc7a8cbfdca0b0245e9d5e081cb25f13551dc7d55a090f98c13b33f0c4999f8ee8ab058537e6037629a0f71
checksum: 10/f9a428cc651ec15fb60d7d60c2a7bacad4666e12508320eafa98258e976fafaa77d7be7be91519e75f801f15f830105420b14a458d4aab121a2b0a59bc43517b
languageName: node
linkType: hard
@@ -1646,10 +1646,10 @@ __metadata:
languageName: node
linkType: hard
"@eslint/js@npm:9.38.0":
version: 9.38.0
resolution: "@eslint/js@npm:9.38.0"
checksum: 10/08ba53e3e631e2815ff33e0f48dccf87daf3841eb5605fa5980d18b88cd6dd4cd63b5829ac015e97eeb85807bf91efe7d4e1d4eaf6beb586bc01549b7660c4a2
"@eslint/js@npm:9.39.1":
version: 9.39.1
resolution: "@eslint/js@npm:9.39.1"
checksum: 10/b10b9b953212c0f3ffca475159bbe519e9e98847200c7432d1637d444fddcd7b712d2b7710a7dc20510f9cfbe8db330039b2aad09cb55d9545b116d940dbeed2
languageName: node
linkType: hard
@@ -1660,13 +1660,13 @@ __metadata:
languageName: node
linkType: hard
"@eslint/plugin-kit@npm:^0.4.0":
version: 0.4.0
resolution: "@eslint/plugin-kit@npm:0.4.0"
"@eslint/plugin-kit@npm:^0.4.1":
version: 0.4.1
resolution: "@eslint/plugin-kit@npm:0.4.1"
dependencies:
"@eslint/core": "npm:^0.16.0"
"@eslint/core": "npm:^0.17.0"
levn: "npm:^0.4.1"
checksum: 10/2c37ca00e352447215aeadcaff5765faead39695f1cb91cd3079a43261b234887caf38edc462811bb3401acf8c156c04882f87740df936838290c705351483be
checksum: 10/c5947d0ffeddca77d996ac1b886a66060c1a15ed1d5e425d0c7e7d7044a4bd3813fc968892d03950a7831c9b89368a2f7b281e45dd3c74a048962b74bf3a1cb4
languageName: node
linkType: hard
@@ -1940,9 +1940,9 @@ __metadata:
languageName: node
linkType: hard
"@home-assistant/webawesome@npm:3.0.0-beta.6.ha.6":
version: 3.0.0-beta.6.ha.6
resolution: "@home-assistant/webawesome@npm:3.0.0-beta.6.ha.6"
"@home-assistant/webawesome@npm:3.0.0-beta.6.ha.7":
version: 3.0.0-beta.6.ha.7
resolution: "@home-assistant/webawesome@npm:3.0.0-beta.6.ha.7"
dependencies:
"@ctrl/tinycolor": "npm:4.1.0"
"@floating-ui/dom": "npm:^1.6.13"
@@ -1953,7 +1953,7 @@ __metadata:
lit: "npm:^3.2.1"
nanoid: "npm:^5.1.5"
qr-creator: "npm:^1.0.0"
checksum: 10/5a0b98875e15532862b7637875772aa8a29edc4d5a1ddc8770e003c6ddb10c9f11696541c993affa7baab49ddecd0f2e1abc692f894e8f859e962da7c4d1a2aa
checksum: 10/c20e5b60920a3cd5bbabb38e73d0a446c54074dcbb843272404b15b6a7e584b8a328393c1e845a2a400588fe15bdcd28d2c18aa2ce44b806f72a3b9343a3310f
languageName: node
linkType: hard
@@ -4945,106 +4945,106 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/eslint-plugin@npm:8.46.2"
"@typescript-eslint/eslint-plugin@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/eslint-plugin@npm:8.46.3"
dependencies:
"@eslint-community/regexpp": "npm:^4.10.0"
"@typescript-eslint/scope-manager": "npm:8.46.2"
"@typescript-eslint/type-utils": "npm:8.46.2"
"@typescript-eslint/utils": "npm:8.46.2"
"@typescript-eslint/visitor-keys": "npm:8.46.2"
"@typescript-eslint/scope-manager": "npm:8.46.3"
"@typescript-eslint/type-utils": "npm:8.46.3"
"@typescript-eslint/utils": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
graphemer: "npm:^1.4.0"
ignore: "npm:^7.0.0"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
"@typescript-eslint/parser": ^8.46.2
"@typescript-eslint/parser": ^8.46.3
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/00c659fcc04c185e6cdfb6c7e52beae1935f1475fef4079193a719f93858b6255e07b4764fc7104e9524a4d0b7652e63616b93e7f112f1cba4e983d10383e224
checksum: 10/0c1eb81a43f1d04fdd79c4e59f9f0687b86735ae6c98d94fe5eb021da2f83e0e2426a2922fe94296fb0a9ab131d53fe4cde8b54d0948d7b23e01e648a318bd1c
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/parser@npm:8.46.2"
"@typescript-eslint/parser@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/parser@npm:8.46.3"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.46.2"
"@typescript-eslint/types": "npm:8.46.2"
"@typescript-eslint/typescript-estree": "npm:8.46.2"
"@typescript-eslint/visitor-keys": "npm:8.46.2"
"@typescript-eslint/scope-manager": "npm:8.46.3"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
debug: "npm:^4.3.4"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/2ee394d880b5a9372ecf50ddbf70f66e9ecc16691a210dd40b5b152310a539005dfed13105e0adc81f1a9f49d86f7b78ddf3bf8d777fe84c179eb6a8be2fa56c
checksum: 10/d36edeba9ce37d219115fb101a4496bca2685969b217d0f64c0c255867a8793a8b41a95b86e26775a09b3abbb7c5b93ef712ea9a0fba3d055dcf385b17825075
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/project-service@npm:8.46.2"
"@typescript-eslint/project-service@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/project-service@npm:8.46.3"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.46.2"
"@typescript-eslint/types": "npm:^8.46.2"
"@typescript-eslint/tsconfig-utils": "npm:^8.46.3"
"@typescript-eslint/types": "npm:^8.46.3"
debug: "npm:^4.3.4"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/76ba446f86e83b4afd6dacbebc9a0737b5a3e0500a0712b37fea4f0141dcf4c9238e8e5a9a649cf609a4624cc575431506a2a56432aaa18d4c3a8cf2df9d1480
checksum: 10/2f041dfc664209b6a213cf585df28d0913ddf81916b83119c897a10dd9ad20dcd0ee3c523ee95440f498da6ba9d6e50cf08852418c0a2ebddd92c7a7cd295736
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/scope-manager@npm:8.46.2"
"@typescript-eslint/scope-manager@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/scope-manager@npm:8.46.3"
dependencies:
"@typescript-eslint/types": "npm:8.46.2"
"@typescript-eslint/visitor-keys": "npm:8.46.2"
checksum: 10/6a8a9b644ff57ca9e992348553f19f6e010d76ff4872d972d333a16952e93cce4bf5096a1fefe1af8b452bce963fde6c78410d15817e673b75176ec3241949e9
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
checksum: 10/6bb6c3210bfcca59cf60860b51bfae8d28b01d074a8608b6f24b3290952ff74103e08d390d11cbf613812fca04aa55ad14ad9da04c3041e23acdca235ab1ff78
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.46.2, @typescript-eslint/tsconfig-utils@npm:^8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.2"
"@typescript-eslint/tsconfig-utils@npm:8.46.3, @typescript-eslint/tsconfig-utils@npm:^8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.3"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/e459d131ca646cca6ad164593ca7e8c45ad3daa103a24e1e57fd47b5c1e5b5418948b749f02baa42e61103a496fc80d32ddd1841c11495bbcf37808b88bb0ef4
checksum: 10/e7a16eadf79483d4b61dee56a08d032bafe26d44d634e7863a5875dbb44393570896641272a4e9810f4eac76a4109f59ad667b036d7627ef1647dc672ea19c5e
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/type-utils@npm:8.46.2"
"@typescript-eslint/type-utils@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/type-utils@npm:8.46.3"
dependencies:
"@typescript-eslint/types": "npm:8.46.2"
"@typescript-eslint/typescript-estree": "npm:8.46.2"
"@typescript-eslint/utils": "npm:8.46.2"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
"@typescript-eslint/utils": "npm:8.46.3"
debug: "npm:^4.3.4"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/db5d3d782b44d31f828ebdbec44550c6f94fdcfac1164f59e3922f6413feed749d93df3977625fd5949aaff5c691cf4603a7cd93eaf7b19b9cf6fd91537fb8c7
checksum: 10/b29cd001c715033ec9cd5fdf2723915f1b4c6c9342283ed00d20e4b942117625facba9a2cf3914b06633c2af9a167430f8f134323627adb0be85f73da4e89d72
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.46.2, @typescript-eslint/types@npm:^8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/types@npm:8.46.2"
checksum: 10/c641453c868b730ef64bd731cc47b19e1a5e45c090dfe9542ecd15b24c5a7b6dc94a8ef4e548b976aabcd1ca9dec1b766e417454b98ea59079795eb008226b38
"@typescript-eslint/types@npm:8.46.3, @typescript-eslint/types@npm:^8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/types@npm:8.46.3"
checksum: 10/3de35df2ec2f2937c8f6eb262cd49f34500a18d01e0d8da6f348afd621f6c222c41d4ea15203ebbf0bd59814aa2b4c83fde7eb6d4aad1fa1514ee7a742887c6a
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/typescript-estree@npm:8.46.2"
"@typescript-eslint/typescript-estree@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/typescript-estree@npm:8.46.3"
dependencies:
"@typescript-eslint/project-service": "npm:8.46.2"
"@typescript-eslint/tsconfig-utils": "npm:8.46.2"
"@typescript-eslint/types": "npm:8.46.2"
"@typescript-eslint/visitor-keys": "npm:8.46.2"
"@typescript-eslint/project-service": "npm:8.46.3"
"@typescript-eslint/tsconfig-utils": "npm:8.46.3"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/visitor-keys": "npm:8.46.3"
debug: "npm:^4.3.4"
fast-glob: "npm:^3.3.2"
is-glob: "npm:^4.0.3"
@@ -5053,32 +5053,32 @@ __metadata:
ts-api-utils: "npm:^2.1.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/4d2149ad97e7f7e2e4cf466932f52f38e90414d47341c5938e497fd0826d403db9896bbd5cc08e7488ad0d0ffb3817e6f18e9f0c623d8a8cda09af204f81aab8
checksum: 10/b55cf72fe3dff0b9bdf9b1793e43fdb2789fa6d706ba7d69fb94801bea82041056a95659bd8fe1e6f026787b2e8d0f8d060149841095a0a82044e3469b8d82cd
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/utils@npm:8.46.2"
"@typescript-eslint/utils@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/utils@npm:8.46.3"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.7.0"
"@typescript-eslint/scope-manager": "npm:8.46.2"
"@typescript-eslint/types": "npm:8.46.2"
"@typescript-eslint/typescript-estree": "npm:8.46.2"
"@typescript-eslint/scope-manager": "npm:8.46.3"
"@typescript-eslint/types": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/91f6216f858161c3f59b2e035e0abce68fcdc9fbe45cb693a111c11ce5352c42fe0b1145a91e538c5459ff81b5e3741a4b38189b97e0e1a756567b6467c7b6c9
checksum: 10/369c962bc20a2a6022ef4533ad55ab4e3d2403e7e200505b29fae6f0b8fc99be8fe149d929781f5ead0d3f88f2c74904f60aaa3771e6773e2b7dd8f61f07a534
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.46.2":
version: 8.46.2
resolution: "@typescript-eslint/visitor-keys@npm:8.46.2"
"@typescript-eslint/visitor-keys@npm:8.46.3":
version: 8.46.3
resolution: "@typescript-eslint/visitor-keys@npm:8.46.3"
dependencies:
"@typescript-eslint/types": "npm:8.46.2"
"@typescript-eslint/types": "npm:8.46.3"
eslint-visitor-keys: "npm:^4.2.1"
checksum: 10/4352629a33bc1619dc78d55eaec382be4c7e1059af02660f62bfdb22933021deaf98504d4030b8db74ec122e6d554e9015341f87aed729fb70fae613f12f55a4
checksum: 10/02659a4cc4780d677907ed7e356e18b941e0ed18883acfda0d74d3e388144f90aa098b8fcdc2f4c01e9e6b60ac6154d1afb009feb6169c483260a5c8b4891171
languageName: node
linkType: hard
@@ -8052,18 +8052,18 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:9.38.0":
version: 9.38.0
resolution: "eslint@npm:9.38.0"
"eslint@npm:9.39.1":
version: 9.39.1
resolution: "eslint@npm:9.39.1"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.1"
"@eslint/config-array": "npm:^0.21.1"
"@eslint/config-helpers": "npm:^0.4.1"
"@eslint/core": "npm:^0.16.0"
"@eslint/config-helpers": "npm:^0.4.2"
"@eslint/core": "npm:^0.17.0"
"@eslint/eslintrc": "npm:^3.3.1"
"@eslint/js": "npm:9.38.0"
"@eslint/plugin-kit": "npm:^0.4.0"
"@eslint/js": "npm:9.39.1"
"@eslint/plugin-kit": "npm:^0.4.1"
"@humanfs/node": "npm:^0.16.6"
"@humanwhocodes/module-importer": "npm:^1.0.1"
"@humanwhocodes/retry": "npm:^0.4.2"
@@ -8097,7 +8097,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/fb8971572dfedd1fd67a35a746d2ab399bef320a7f131fdccaec6416f4b4a028e762663c32ccf1a88f715aec6d1c5da066fdb11e20219a0156f1f3fc1a726713
checksum: 10/c85fefe4a81a1a476e62087366907af830b62a6565ac153f6d50a100a42a946aeb049c3af8f06c0e091105ba0fe97ac109f379f32755a67f66ecb7d4d1e4dca3
languageName: node
linkType: hard
@@ -9201,7 +9201,7 @@ __metadata:
"@babel/preset-env": "npm:7.28.5"
"@babel/runtime": "npm:7.28.4"
"@braintree/sanitize-url": "npm:7.1.1"
"@bundle-stats/plugin-webpack-filter": "npm:4.21.5"
"@bundle-stats/plugin-webpack-filter": "npm:4.21.6"
"@codemirror/autocomplete": "npm:6.19.1"
"@codemirror/commands": "npm:6.10.0"
"@codemirror/language": "npm:6.11.3"
@@ -9226,7 +9226,7 @@ __metadata:
"@fullcalendar/list": "npm:6.1.19"
"@fullcalendar/luxon3": "npm:6.1.19"
"@fullcalendar/timegrid": "npm:6.1.19"
"@home-assistant/webawesome": "npm:3.0.0-beta.6.ha.6"
"@home-assistant/webawesome": "npm:3.0.0-beta.6.ha.7"
"@lezer/highlight": "npm:1.2.3"
"@lit-labs/motion": "npm:1.0.9"
"@lit-labs/observers": "npm:2.0.6"
@@ -9312,7 +9312,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.0.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:9.38.0"
eslint: "npm:9.39.1"
eslint-config-airbnb-base: "npm:15.0.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.10"
@@ -9373,7 +9373,7 @@ __metadata:
tinykeys: "npm:3.0.0"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:5.9.3"
typescript-eslint: "npm:8.46.2"
typescript-eslint: "npm:8.46.3"
ua-parser-js: "npm:2.0.6"
vite-tsconfig-paths: "npm:5.1.4"
vitest: "npm:4.0.6"
@@ -14295,18 +14295,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.46.2":
version: 8.46.2
resolution: "typescript-eslint@npm:8.46.2"
"typescript-eslint@npm:8.46.3":
version: 8.46.3
resolution: "typescript-eslint@npm:8.46.3"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.46.2"
"@typescript-eslint/parser": "npm:8.46.2"
"@typescript-eslint/typescript-estree": "npm:8.46.2"
"@typescript-eslint/utils": "npm:8.46.2"
"@typescript-eslint/eslint-plugin": "npm:8.46.3"
"@typescript-eslint/parser": "npm:8.46.3"
"@typescript-eslint/typescript-estree": "npm:8.46.3"
"@typescript-eslint/utils": "npm:8.46.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/cd1bbc5d33c0369f70032165224badf1a8a9f95f39c891e4f71c78ceea9e7b2d71e0516d8b38177a11217867f387788f3fa126381418581409e7a76cdfdfe909
checksum: 10/2f77eb70c8fd6ec4920d5abf828ef28007df8ff94605246a4ca918fadb996a83f7fb82510a1de69fad7f0159ee8f15246d467ebc42df20a4585919cb6b401715
languageName: node
linkType: hard