Compare commits

..

5 Commits

Author SHA1 Message Date
Aidan Timson
c6c2d47ee3 Add support for error state 2025-10-29 09:03:13 +00:00
Aidan Timson
01ba7571f0 Restore optimised parts code 2025-10-28 16:01:49 +00:00
Aidan Timson
70bb29e242 memo 2025-10-27 16:26:29 +00:00
Aidan Timson
34443a009d Reorder 2025-10-27 16:21:17 +00:00
Aidan Timson
e1dce358c3 Migrate ha-icon-picker to generic picker 2025-10-27 16:19:49 +00:00
194 changed files with 2057 additions and 5655 deletions

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0

1
.gitignore vendored
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
22.21.1
22.21.0

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, isLandingPageBuild }) =>
module.exports.emptyPackages = ({ isHassioBuild }) =>
[
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 || isLandingPageBuild) &&
isHassioBuild &&
require.resolve(
path.resolve(paths.root_dir, "src/components/ha-icon.ts")
),
(isHassioBuild || isLandingPageBuild) &&
isHassioBuild &&
require.resolve(
path.resolve(paths.root_dir, "src/components/ha-icon-picker.ts")
),
@@ -327,20 +327,6 @@ 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),
@@ -351,7 +337,6 @@ module.exports.config = {
publicPath: publicPath(latestBuild),
isProdBuild,
latestBuild,
isLandingPageBuild: true,
};
},
};

View File

@@ -39,13 +39,6 @@ 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,24 +245,6 @@ 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,16 +202,6 @@ 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,7 +7,6 @@ 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

@@ -1,39 +0,0 @@
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,7 +15,6 @@ import {
createGalleryConfig,
createHassioConfig,
createLandingPageConfig,
createLogsConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -205,25 +204,6 @@ 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,11 +59,5 @@ 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,7 +41,6 @@ const createRspackConfig = ({
isStatsBuild,
isTestBuild,
isHassioBuild,
isLandingPageBuild,
dontHash,
}) => {
if (!dontHash) {
@@ -169,9 +168,7 @@ const createRspackConfig = ({
},
}),
new rspack.NormalModuleReplacementPlugin(
new RegExp(
bundle.emptyPackages({ isHassioBuild, isLandingPageBuild }).join("|")
),
new RegExp(bundle.emptyPackages({ isHassioBuild }).join("|")),
path.resolve(paths.root_dir, "src/util/empty.js")
),
!isProdBuild && new LogStartCompilePlugin(),
@@ -302,11 +299,6 @@ 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 }));
@@ -316,7 +308,6 @@ module.exports = {
createCastConfig,
createHassioConfig,
createGalleryConfig,
createLogsConfig,
createRspackConfig,
createLandingPageConfig,
};

View File

@@ -16,9 +16,9 @@ import {
} from "../../../../src/common/auth/token_storage";
import { atLeastVersion } from "../../../../src/common/config/version";
import { toggleAttribute } from "../../../../src/common/dom/toggle_attribute";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
import {
@@ -28,6 +28,7 @@ import {
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
import "../../../../src/layouts/hass-loading-screen";
import { generateDefaultViewConfig } from "../../../../src/panels/lovelace/common/generate-lovelace-config";
import "./hc-layout";
@customElement("hc-cast")
@@ -95,9 +96,7 @@ class HcCast extends LitElement {
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
generateDefaultViewConfig({}, {}, {}, {}, () => ""),
]
).map(
(view, idx) => html`

View File

@@ -5,14 +5,14 @@ subtitle: Dialogs provide important prompts in a user flow.
# Material Design 3
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guidelines. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guideliness. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
# Guidelines
## Design
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
- Dialogs have a max width of 560px. Alert and confirmation dialogs got a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guideliness.
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
@@ -26,7 +26,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
- A best practice is to always use a title, even if it is optional by Material guidelines.
- People mainly read the title and a button. Put the most important information in those two.
- Try to avoid user generated content in the title, this could make the title unreadably long.
- Try to avoid user generated content in the title, this could make the title unreadable long.
- If users become unsure, they read the description. Make sure this explains what will happen.
- Strive for minimalism.

View File

@@ -39,7 +39,6 @@ const SENSOR_DEVICE_CLASSES = [
"pm1",
"pm10",
"pm25",
"pm4",
"power_factor",
"power",
"precipitation",

View File

@@ -1,25 +1,22 @@
import "@material/mwc-linear-progress";
import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { type PropertyValues, css, html, nothing } 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 "../../src/components/ha-svg-icon";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
import { haStyle } from "../../src/resources/styles";
import "./components/landing-page-logs";
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 { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import { LandingPageBaseElement } from "./landing-page-base-element";
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;
@@ -97,21 +94,16 @@ class HaLandingPage extends LandingPageBaseElement {
<ha-language-picker
.value=${this.language}
.label=${""}
button-style
native-name
@value-changed=${this._languageChanged}
inline-arrow
></ha-language-picker>
<ha-button
appearance="plain"
variant="neutral"
<a
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>
`;
}
@@ -226,8 +218,26 @@ class HaLandingPage extends LandingPageBaseElement {
ha-alert p {
text-align: unset;
}
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
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;
}
ha-fade-in {
min-height: calc(100vh - 64px - 88px);

View File

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

View File

@@ -1,47 +0,0 @@
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"]

View File

@@ -1,113 +0,0 @@
# 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

View File

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

View File

@@ -1,108 +0,0 @@
# 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.

View File

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

View File

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

View File

@@ -1,372 +0,0 @@
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)
}
}

View File

@@ -1,19 +0,0 @@
---
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

View File

@@ -1,16 +0,0 @@
---
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

View File

@@ -1,34 +0,0 @@
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);
});

View File

@@ -1,8 +0,0 @@
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

@@ -1,37 +0,0 @@
<!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>

View File

@@ -1,24 +0,0 @@
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;
}
}

View File

@@ -1,538 +0,0 @@
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,8 +52,8 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7",
"@lezer/highlight": "1.2.3",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
"@lezer/highlight": "1.2.2",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
"@lit-labs/virtualizer": "2.1.1",
@@ -81,7 +81,7 @@
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
"@material/web": "2.4.1",
"@material/web": "2.4.0",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
@@ -89,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.4",
"@vaadin/vaadin-themable-mixin": "24.9.4",
"@vaadin/combo-box": "24.9.2",
"@vaadin/vaadin-themable-mixin": "24.9.2",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -111,7 +111,7 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.14",
"hls.js": "1.6.13",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18",
@@ -152,13 +152,13 @@
"@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.6",
"@bundle-stats/plugin-webpack-filter": "4.21.5",
"@lokalise/node-api": "15.3.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.7",
"@rspack/core": "1.6.0",
"@octokit/auth-oauth-device": "8.0.2",
"@octokit/plugin-retry": "8.0.2",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.3.4",
"@rspack/core": "1.5.8",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
@@ -178,12 +178,12 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.6",
"@vitest/coverage-v8": "4.0.3",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.39.1",
"eslint": "9.38.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
@@ -201,7 +201,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "27.1.0",
"jsdom": "27.0.1",
"jszip": "3.10.1",
"lint-staged": "16.2.6",
"lit-analyzer": "2.0.3",
@@ -213,13 +213,13 @@
"rspack-manifest-plugin": "5.1.0",
"serve": "14.2.5",
"sinon": "21.0.0",
"tar": "7.5.2",
"tar": "7.5.1",
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.46.3",
"typescript-eslint": "8.46.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6",
"vitest": "4.0.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -231,12 +231,9 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.1",
"@fullcalendar/daygrid": "6.1.19",
"globals": "16.5.0",
"globals": "16.4.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.10.3",
"volta": {
"node": "22.21.1"
}
"packageManager": "yarn@4.10.3"
}

View File

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

View File

@@ -1,96 +0,0 @@
#!/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,5 +1,4 @@
/* 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";
@@ -7,8 +6,6 @@ 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";
@@ -136,8 +133,25 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
justify-content: space-between;
align-items: center;
}
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
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;
}
h1 {
font-size: var(--ha-font-size-3xl);
@@ -191,21 +205,16 @@ 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>
<ha-button
appearance="plain"
variant="neutral"
<a
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

@@ -9,9 +9,9 @@ type EntityCategory = "none" | "config" | "diagnostic";
export interface EntityFilter {
domain?: string | string[];
device_class?: string | string[];
device?: string | null | (string | null)[];
area?: string | null | (string | null)[];
floor?: string | null | (string | null)[];
device?: string | string[];
area?: string | string[];
floor?: string | string[];
label?: string | string[];
entity_category?: EntityCategory | EntityCategory[];
hidden_platform?: string | string[];
@@ -19,18 +19,6 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
if (value === undefined) {
return undefined;
}
if (value === null) {
return new Set([null]);
}
return new Set(ensureArray(value));
};
export const generateEntityFilter = (
hass: HomeAssistant,
filter: EntityFilter
@@ -41,9 +29,11 @@ export const generateEntityFilter = (
const deviceClasses = filter.device_class
? new Set(ensureArray(filter.device_class))
: undefined;
const floors = normalizeFilterArray(filter.floor);
const areas = normalizeFilterArray(filter.area);
const devices = normalizeFilterArray(filter.device);
const floors = filter.floor ? new Set(ensureArray(filter.floor)) : undefined;
const areas = filter.area ? new Set(ensureArray(filter.area)) : undefined;
const devices = filter.device
? new Set(ensureArray(filter.device))
: undefined;
const entityCategories = filter.entity_category
? new Set(ensureArray(filter.entity_category))
: undefined;
@@ -83,20 +73,23 @@ export const generateEntityFilter = (
}
if (floors) {
const floorId = floor?.floor_id ?? null;
if (!floors.has(floorId)) {
if (!floor || !floors.has(floor.floor_id)) {
return false;
}
}
if (areas) {
const areaId = area?.area_id ?? null;
if (!areas.has(areaId)) {
if (!area) {
return false;
}
if (!areas.has(area.area_id)) {
return false;
}
}
if (devices) {
const deviceId = device?.id ?? null;
if (!devices.has(deviceId)) {
if (!device) {
return false;
}
if (!devices.has(device.id)) {
return false;
}
}

View File

@@ -214,7 +214,6 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"pm1",
"pm10",
"pm25",
"pm4",
"power_factor",
"power",
"pressure",

View File

@@ -1,116 +0,0 @@
export interface SwipeGestureResult {
velocity: number;
delta: number;
isSwipe: boolean;
isDownwardSwipe: boolean;
}
export interface SwipeGestureConfig {
velocitySwipeThreshold?: number;
movementTimeThreshold?: number;
}
const VELOCITY_SWIPE_THRESHOLD = 0.5; // px/ms
const MOVEMENT_TIME_THRESHOLD = 100; // ms
/**
* Recognizes swipe gestures and calculates velocity for touch interactions.
* Tracks touch movement and provides velocity-based and position-based gesture detection.
*/
export class SwipeGestureRecognizer {
private _startY = 0;
private _delta = 0;
private _startTime = 0;
private _lastY = 0;
private _lastTime = 0;
private _velocityThreshold: number;
private _movementTimeThreshold: number;
constructor(config: SwipeGestureConfig = {}) {
this._velocityThreshold =
config.velocitySwipeThreshold ?? VELOCITY_SWIPE_THRESHOLD; // px/ms
this._movementTimeThreshold =
config.movementTimeThreshold ?? MOVEMENT_TIME_THRESHOLD; // ms
}
/**
* Initialize gesture tracking with starting touch position
*/
public start(clientY: number): void {
const now = Date.now();
this._startY = clientY;
this._startTime = now;
this._lastY = clientY;
this._lastTime = now;
this._delta = 0;
}
/**
* Update gesture state during movement
* Returns the current delta (negative when dragging down)
*/
public move(clientY: number): number {
const now = Date.now();
this._delta = this._startY - clientY;
this._lastY = clientY;
this._lastTime = now;
return this._delta;
}
/**
* Calculate final gesture result when touch ends
*/
public end(): SwipeGestureResult {
const velocity = this.getVelocity();
const hasSignificantVelocity = Math.abs(velocity) > this._velocityThreshold;
return {
velocity,
delta: this._delta,
isSwipe: hasSignificantVelocity,
isDownwardSwipe: velocity > 0,
};
}
/**
* Get current drag delta (negative when dragging down)
*/
public getDelta(): number {
return this._delta;
}
/**
* Calculate velocity based on recent movement
* Returns 0 if no recent movement detected
* Positive velocity means downward swipe
*/
public getVelocity(): number {
const now = Date.now();
const timeSinceLastMove = now - this._lastTime;
// Only consider velocity if the last movement was recent
if (timeSinceLastMove >= this._movementTimeThreshold) {
return 0;
}
const timeDelta = this._lastTime - this._startTime;
return timeDelta > 0 ? (this._lastY - this._startY) / timeDelta : 0;
}
/**
* Reset all tracking state
*/
public reset(): void {
this._startY = 0;
this._delta = 0;
this._startTime = 0;
this._lastY = 0;
this._lastTime = 0;
}
}

View File

@@ -90,8 +90,6 @@ export class HaChartBase extends LitElement {
private _shouldResizeChart = false;
private _resizeAnimationDuration?: number;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => {
@@ -207,16 +205,6 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
if (
this._compareCustomLegendOptions(
changedProps.get("options"),
this.options
)
) {
// 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();
}
@@ -308,7 +296,7 @@ export class HaChartBase extends LitElement {
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
...itemStyle,
itemStyle,
};
const color = itemStyle?.color as string;
const borderColor = itemStyle?.borderColor as string;
@@ -520,7 +508,6 @@ export class HaChartBase extends LitElement {
);
}
});
this.requestUpdate("_hiddenDatasets");
}
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
@@ -654,13 +641,6 @@ export class HaChartBase extends LitElement {
textBorderWidth: 2,
},
},
pie: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
},
},
sankey: {
label: {
color: style.getPropertyValue("--primary-text-color"),
@@ -978,34 +958,11 @@ export class HaChartBase extends LitElement {
private _handleChartRenderFinished = () => {
if (this._shouldResizeChart) {
this.chart?.resize({
animation:
this._reducedMotion ||
typeof this._resizeAnimationDuration !== "number"
? undefined
: { duration: this._resizeAnimationDuration },
});
this.chart?.resize();
this._shouldResizeChart = false;
this._resizeAnimationDuration = undefined;
}
};
private _compareCustomLegendOptions(
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
) as LegendComponentOption[];
const newLegends = ensureArray(
newOptions?.legend || []
) as LegendComponentOption[];
return (
oldLegends.some((l) => l.show && l.type === "custom") !==
newLegends.some((l) => l.show && l.type === "custom")
);
}
static styles = css`
:host {
display: block;

View File

@@ -87,8 +87,6 @@ export class StateHistoryChartLine extends LitElement {
private _previousYAxisLabelValue = 0;
private _yAxisMaximumFractionDigits = 0;
protected render() {
return html`
<ha-chart-base
@@ -759,12 +757,8 @@ export class StateHistoryChartLine extends LitElement {
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
);
this._yAxisMaximumFractionDigits = Math.max(
this._yAxisMaximumFractionDigits,
maximumFractionDigits
);
const label = formatNumber(value, this.hass.locale, {
maximumFractionDigits: this._yAxisMaximumFractionDigits,
maximumFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {

View File

@@ -1 +0,0 @@
export const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";

View File

@@ -147,7 +147,7 @@ class HaEntitiesPicker extends LitElement {
.createDomains=${this.createDomains}
.required=${this.required && !currentEntities.length}
@value-changed=${this._addEntity}
.addButton=${currentEntities.length > 0}
add-button
></ha-entity-picker>
</div>
`;

View File

@@ -312,7 +312,7 @@ export class HaEntityNamePicker extends LitElement {
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return undefined;
return "";
}
if (items.length === 1) {
const item = items[0];

View File

@@ -4,7 +4,6 @@ import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { ANY_STATE_VALUE } from "./const";
import { ensureArray } from "../../common/array/ensure-array";
import type { HomeAssistant } from "../../types";
import "./ha-entity-state-picker";
@@ -58,7 +57,6 @@ export class HaEntityStatesPicker extends LitElement {
const value = this.value || [];
const hide = [...(this.hideStates || []), ...value];
const hideValue = value.includes(ANY_STATE_VALUE);
return html`
${repeat(
@@ -86,7 +84,7 @@ export class HaEntityStatesPicker extends LitElement {
`
)}
<div>
${(this.disabled && value.length) || hideValue
${this.disabled && value.length
? nothing
: keyed(
value.length,

View File

@@ -87,8 +87,6 @@ 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() {
@@ -377,7 +375,6 @@ 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

@@ -1,7 +1,6 @@
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { customElement, property, state } from "lit/decorators";
import { haStyleScrollbar } from "../resources/styles";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -15,12 +14,6 @@ export class HaBottomSheet extends LitElement {
@state() private _drawerOpen = false;
@query("#drawer") private _drawer!: HTMLElement;
private _gestureRecognizer = new SwipeGestureRecognizer();
private _isDragging = false;
private _handleAfterHide() {
this.open = false;
const ev = new Event("closed", {
@@ -40,132 +33,19 @@ export class HaBottomSheet extends LitElement {
render() {
return html`
<wa-drawer
id="drawer"
placement="bottom"
.open=${this._drawerOpen}
@wa-after-hide=${this._handleAfterHide}
without-header
@touchstart=${this._handleTouchStart}
>
<slot name="header"></slot>
<div id="body" class="body ha-scrollbar">
<div class="body ha-scrollbar">
<slot></slot>
</div>
</wa-drawer>
`;
}
private _handleTouchStart = (ev: TouchEvent) => {
// Check if any element inside drawer in the composed path has scrollTop > 0
for (const path of ev.composedPath()) {
const el = path as HTMLElement;
if (el === this._drawer) {
break;
}
if (el.scrollTop > 0) {
return;
}
}
this._startResizing(ev.touches[0].clientY);
};
private _startResizing(clientY: number) {
// register event listeners for drag handling
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
this._gestureRecognizer.start(clientY);
}
private _handleTouchMove = (ev: TouchEvent) => {
const currentY = ev.touches[0].clientY;
const delta = this._gestureRecognizer.move(currentY);
if (delta < 0) {
ev.preventDefault();
this._isDragging = true;
requestAnimationFrame(() => {
if (this._isDragging) {
this.style.setProperty(
"--dialog-transform",
`translateY(${delta * -1}px)`
);
}
});
}
};
private _animateSnapBack() {
// Add transition for smooth animation
this.style.setProperty(
"--dialog-transition",
`transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease-out`
);
// Reset transform to snap back
this.style.removeProperty("--dialog-transform");
// Remove transition after animation completes
setTimeout(() => {
this.style.removeProperty("--dialog-transition");
}, BOTTOM_SHEET_ANIMATION_DURATION_MS);
}
private _handleTouchEnd = () => {
this._unregisterResizeHandlers();
this._isDragging = false;
const result = this._gestureRecognizer.end();
// If velocity exceeds threshold, use velocity direction to determine action
if (result.isSwipe) {
if (result.isDownwardSwipe) {
// Downward swipe - close the bottom sheet
this._drawerOpen = false;
} else {
// Upward swipe - keep open and animate back
this._animateSnapBack();
}
return;
}
// If velocity is below threshold, use position-based logic
// Get the drawer height to calculate 50% threshold
const drawerBody = this._drawer.shadowRoot?.querySelector(
'[part="body"]'
) as HTMLElement;
const drawerHeight = drawerBody?.offsetHeight || 0;
// delta is negative when dragging down
// Close if dragged down past 50% of the drawer height
if (
drawerHeight > 0 &&
result.delta < 0 &&
Math.abs(result.delta) > drawerHeight * 0.5
) {
this._drawerOpen = false;
} else {
this._animateSnapBack();
}
};
private _unregisterResizeHandlers = () => {
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
};
disconnectedCallback() {
super.disconnectedCallback();
this._unregisterResizeHandlers();
this._isDragging = false;
}
static styles = [
haStyleScrollbar,
css`
@@ -179,8 +59,6 @@ export class HaBottomSheet extends LitElement {
wa-drawer::part(dialog) {
max-height: var(--ha-bottom-sheet-max-height, 90vh);
align-items: center;
transform: var(--dialog-transform);
transition: var(--dialog-transition);
}
wa-drawer::part(body) {
max-width: var(--ha-bottom-sheet-max-width);
@@ -212,11 +90,6 @@ export class HaBottomSheet extends LitElement {
max-width: 100%;
display: flex;
flex-direction: column;
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
`,
];

View File

@@ -59,7 +59,6 @@ export class HaButton extends Button {
line-height: 1;
transition: background-color 0.15s ease-in-out;
text-wrap: wrap;
}
:host([size="small"]) .button {

View File

@@ -148,7 +148,7 @@ export class HaForm extends LitElement implements HaFormElement {
.value=${getValue(this.data, item)}
.label=${this._computeLabel(item, this.data)}
.disabled=${item.disabled || this.disabled || false}
.placeholder=${item.required ? undefined : item.default}
.placeholder=${item.required ? "" : item.default}
.helper=${this._computeHelper(item)}
.localizeValue=${this.localizeValue}
.required=${item.required || false}

View File

@@ -10,6 +10,7 @@ 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 {
@@ -102,6 +103,10 @@ export class HaGenericPicker extends LitElement {
// helper to set new value after closing picker, to avoid flicker
private _newValue?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
private _unsubscribeTinyKeys?: () => void;
protected render() {
@@ -136,6 +141,8 @@ export class HaGenericPicker extends LitElement {
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
@@ -204,11 +211,16 @@ export class HaGenericPicker extends LitElement {
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing;
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
</ha-input-helper-text>`;
}
private _dialogOpened = () => {
@@ -307,6 +319,9 @@ export class HaGenericPicker extends LitElement {
display: block;
margin: var(--ha-space-2) 0 0;
}
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
wa-popover {
--wa-space-l: var(--ha-space-0);

View File

@@ -1,8 +1,4 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type {
ComboBoxDataProviderCallback,
ComboBoxDataProviderParams,
} from "@vaadin/combo-box/vaadin-combo-box-light";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
@@ -10,9 +6,10 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { customIcons } from "../data/custom_icons";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import "./ha-icon";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
interface IconItem {
icon: string;
@@ -21,7 +18,7 @@ interface IconItem {
}
interface RankedIcon {
icon: string;
item: PickerComboBoxItem;
rank: number;
}
@@ -67,13 +64,18 @@ const loadCustomIconItems = async (iconsetPrefix: string) => {
}
};
const rowRenderer: ComboBoxLitRenderer<IconItem | RankedIcon> = (item) => html`
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button">
<ha-icon .icon=${item.icon} slot="start"></ha-icon>
${item.icon}
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
</ha-combo-box-item>
`;
const valueRenderer = (value: string) => html`
<ha-icon .icon=${value} slot="start"></ha-icon>
<span slot="headline">${value}</span>
`;
@customElement("ha-icon-picker")
export class HaIconPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -96,13 +98,11 @@ export class HaIconPicker extends LitElement {
protected render(): TemplateResult {
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
item-value-path="icon"
item-label-path="icon"
.value=${this._value}
allow-custom-value
.dataProvider=${ICONS_LOADED ? this._iconProvider : undefined}
.getItems=${this._getItems}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -110,69 +110,85 @@ export class HaIconPicker extends LitElement {
.placeholder=${this.placeholder}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.renderer=${rowRenderer}
icon
@opened-changed=${this._openedChanged}
.rowRenderer=${rowRenderer}
.valueRenderer=${valueRenderer}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
${this._value || this.placeholder
? html`
<ha-icon .icon=${this._value || this.placeholder} slot="icon">
</ha-icon>
`
: html`<slot slot="icon" name="fallback"></slot>`}
</ha-combo-box>
</ha-generic-picker>
`;
}
// Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne(
(filter: string, iconItems: IconItem[] = ICONS) => {
(filter: string, items: PickerComboBoxItem[]): PickerComboBoxItem[] => {
if (!filter) {
return iconItems;
return items;
}
const filteredItems: RankedIcon[] = [];
const addIcon = (icon: string, rank: number) =>
filteredItems.push({ icon, rank });
const addIcon = (item: PickerComboBoxItem, rank: number) =>
filteredItems.push({ item, rank });
// Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords
for (const item of iconItems) {
if (item.parts.has(filter)) {
addIcon(item.icon, 1);
} else if (item.keywords.includes(filter)) {
addIcon(item.icon, 2);
} else if (item.icon.includes(filter)) {
addIcon(item.icon, 3);
} else if (item.keywords.some((word) => word.includes(filter))) {
addIcon(item.icon, 4);
for (const item of items) {
const iconName = item.id.split(":")[1] || item.id;
const parts = iconName.split("-");
const keywords = item.search_labels?.slice(1) || [];
if (parts.includes(filter)) {
addIcon(item, 1);
} else if (keywords.includes(filter)) {
addIcon(item, 2);
} else if (item.id.includes(filter)) {
addIcon(item, 3);
} else if (keywords.some((word) => word.includes(filter))) {
addIcon(item, 4);
}
}
// Allow preview for custom icon not in list
if (filteredItems.length === 0) {
addIcon(filter, 0);
addIcon(
{
id: filter,
primary: filter,
icon: filter,
search_labels: [filter],
sorting_label: filter,
},
0
);
}
return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank);
return filteredItems
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);
}
);
private _iconProvider = (
params: ComboBoxDataProviderParams,
callback: ComboBoxDataProviderCallback<IconItem | RankedIcon>
) => {
const filteredItems = this._filterIcons(params.filter.toLowerCase(), ICONS);
const iStart = params.page * params.pageSize;
const iEnd = iStart + params.pageSize;
callback(filteredItems.slice(iStart, iEnd), filteredItems.length);
};
private _getItems = (): PickerComboBoxItem[] =>
ICONS.map((icon: IconItem) => ({
id: icon.icon,
primary: icon.icon,
icon: icon.icon,
search_labels: [
icon.icon.split(":")[1] || icon.icon,
...Array.from(icon.parts),
...icon.keywords,
],
sorting_label: icon.icon,
}));
private async _openedChanged(ev: ValueChangedEvent<boolean>) {
const opened = ev.detail.value;
if (opened && !ICONS_LOADED) {
await loadIcons();
this.requestUpdate();
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this.requestUpdate();
});
}
}
@@ -199,15 +215,9 @@ export class HaIconPicker extends LitElement {
}
static styles = css`
*[slot="icon"] {
color: var(--primary-text-color);
position: relative;
bottom: 2px;
}
*[slot="prefix"] {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
ha-generic-picker {
width: 100%;
display: block;
}
`;
}

View File

@@ -1,7 +1,6 @@
import { mdiMenuDown } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { formatLanguageCode } from "../common/language/format_language";
@@ -9,10 +8,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 type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-list-item";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-select";
export const getLanguageOptions = (
languages: string[],
@@ -76,9 +75,6 @@ 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 })
@@ -86,8 +82,6 @@ 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();
@@ -107,13 +101,12 @@ export class HaLanguagePicker extends LitElement {
this.hass?.locale
);
private _getLanguageName = (lang?: string) =>
this._getItems().find((language) => language.id === lang)?.primary;
private _valueRenderer = (value) =>
html`<span slot="headline"
>${this._getLanguageName(value) ?? value}</span
> `;
private _valueRenderer = (value) => {
const language = this._getItems().find(
(lang) => lang.id === value
)?.primary;
return html`<span slot="headline">${language ?? value}</span> `;
};
protected render() {
const value =
@@ -137,28 +130,10 @@ export class HaLanguagePicker extends LitElement {
.getItems=${this._getItems}
@value-changed=${this._changed}
hide-clear-icon
>
${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>
></ha-generic-picker>
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this.genericPicker.open();
}
static styles = css`
ha-generic-picker {
width: 100%;

View File

@@ -39,6 +39,10 @@ export class HaPickerField extends LitElement {
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
public async focus() {
@@ -142,6 +146,11 @@ export class HaPickerField extends LitElement {
background-color: var(--mdc-theme-primary);
}
:host([invalid]) ha-combo-box-item:after {
height: 2px;
background-color: var(--mdc-theme-error, var(--error-color, #b00020));
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;

View File

@@ -87,208 +87,166 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
return html` ${this._renderChips()} ${this._renderItems()} `;
}
return html` ${this._renderItems()} ${this._renderPicker()} `;
return html` ${this._renderItems()} ${this._renderChips()} `;
}
private _renderValueChips() {
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="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>
`;
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>`;
}
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)
: [];
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 (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
!this.value?.floor_id &&
!this.value?.area_id &&
!this.value?.device_id &&
!this.value?.entity_id &&
!this.value?.label_id
) {
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 _renderPicker() {
private _renderChips() {
return html`
<div class="add-target-wrapper">
<ha-button
@@ -389,8 +347,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._pickerFilter = filter;
};
private _hidePicker(ev) {
ev.stopPropagation();
private _hidePicker() {
this._open = false;
this._pickerWrapperOpen = false;

View File

@@ -9,7 +9,7 @@ export class HaTooltip extends Tooltip {
@property({ attribute: "show-delay", type: Number }) showDelay = 150;
/** The amount of time to wait before hiding the tooltip when the user mouses out.. */
@property({ attribute: "hide-delay", type: Number }) hideDelay = 150;
@property({ attribute: "hide-delay", type: Number }) hideDelay = 400;
static get styles(): CSSResultGroup {
return [

View File

@@ -6,7 +6,6 @@ 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";
@@ -20,12 +19,9 @@ 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 {
@@ -115,10 +111,10 @@ export class HaTargetPickerItemRow extends LitElement {
}
protected render() {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
const { name, context, iconPath, fallbackIconPath, stateObject } =
this._itemData(this.type, this.itemId);
const showEntities = this.type !== "entity" && !notFound;
const showEntities = this.type !== "entity";
const entries = this.parentEntries || this._entries;
@@ -132,7 +128,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
return html`
<ha-md-list-item type="text" class=${notFound ? "error" : ""}>
<ha-md-list-item type="text">
<div class="icon" slot="start">
${this.subEntry
? html`
@@ -152,15 +148,11 @@ export class HaTargetPickerItemRow extends LitElement {
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
: stateObject
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
.stateObj=${stateObject}
>
</ha-state-icon>
`
@@ -168,14 +160,8 @@ export class HaTargetPickerItemRow extends LitElement {
</div>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
${context && !this.hideContext
? html`<span slot="supporting-text">${context}</span>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
@@ -488,28 +474,26 @@ export class HaTargetPickerItemRow extends LitElement {
private _itemData = memoizeOne((type: TargetType, item: string) => {
if (type === "floor") {
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
const floor = this.hass.floors?.[item];
return {
name: floor?.name || item,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
notFound: !floor,
};
}
if (type === "area") {
const area: AreaRegistryEntry | undefined = this.hass.areas?.[item];
const area = 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: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
const device = this.hass.devices?.[item];
if (device?.primary_config_entry) {
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -517,25 +501,24 @@ 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: 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 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 deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
@@ -545,19 +528,15 @@ export class HaTargetPickerItemRow extends LitElement {
name: entityName || deviceName || item,
context,
stateObject,
notFound: !stateObject && item !== "all",
};
}
// type label
const label: LabelRegistryEntry | undefined = this._labelRegistry.find(
(lab) => lab.label_id === item
);
const label = this._labelRegistry.find((lab) => lab.label_id === item);
return {
name: label?.name || item,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
notFound: !label,
};
});
@@ -618,27 +597,17 @@ 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 && entities.length) {
if (!filterType) {
// 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 && devices.length) {
if (!filterType) {
// 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 && areasAndFloors.length) {
if (!filterType) {
// 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 && labels.length) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.labels")

View File

@@ -16,10 +16,14 @@ import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
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 { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import { domainToName } from "../../data/integration";
@@ -168,10 +172,23 @@ export class HaTargetPickerValueChip extends LitElement {
if (type === "entity") {
this._setDomainName(computeDomain(itemId));
const stateObj = this.hass.states[itemId];
const stateObject = this.hass.states[itemId];
const entityName = computeEntityName(
stateObject,
this.hass.entities,
this.hass.devices
);
const { device } = getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const deviceName = device ? computeDeviceName(device) : undefined;
return {
name: computeStateName(stateObj) || itemId,
stateObject: stateObj,
name: entityName || deviceName || itemId,
stateObject,
};
}

View File

@@ -1,5 +1,3 @@
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { atLeastVersion } from "../common/config/version";
import type { HomeAssistant } from "../types";
export interface LogProvider {
@@ -10,8 +8,4 @@ export interface LogProvider {
export const fetchErrorLog = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "error_log");
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
isComponentLoaded(hass, "hassio") &&
atLeastVersion(hass.config.version, 2025, 10)
? "/api/hassio/core/logs/latest"
: "/api/error_log";
export const getErrorLogDownloadUrl = "/api/error_log";

View File

@@ -435,9 +435,9 @@ export const convertStatisticsToHistory = (
Object.entries(orderedStatistics).forEach(([key, value]) => {
const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
s: e.mean != null ? e.mean.toString() : e.state!.toString(),
lc: e.end / 1000,
lc: e.start / 1000,
a: {},
lu: e.end / 1000,
lu: e.start / 1000,
}));
statsHistoryStates[key] = entityHistoryStates;
});

View File

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

View File

@@ -484,7 +484,7 @@ class DataEntryFlowDialog extends LitElement {
this._unsubDataEntryFlowProgress = undefined;
}
if (_step.next_flow[0] === "config_flow") {
showConfigFlowDialog(this, {
showConfigFlowDialog(this._params!.dialogParentElement!, {
continueFlowId: _step.next_flow[1],
carryOverDevices: this._devices(
this._params!.flowConfig.showDevices,
@@ -496,23 +496,32 @@ class DataEntryFlowDialog extends LitElement {
});
} else if (_step.next_flow[0] === "options_flow") {
if (_step.type === "create_entry") {
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
showOptionsFlowDialog(
this._params!.dialogParentElement!,
_step.result!,
{
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
}
);
}
} else if (_step.next_flow[0] === "config_subentries_flow") {
if (_step.type === "create_entry") {
showSubConfigFlowDialog(this, _step.result!, _step.next_flow[0], {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
showSubConfigFlowDialog(
this._params!.dialogParentElement!,
_step.result!,
_step.next_flow[0],
{
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
}
);
}
} else {
this.closeDialog();
showAlertDialog(this, {
showAlertDialog(this._params!.dialogParentElement!, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error",
{ error: `Unsupported next flow type: ${_step.next_flow[0]}` }

View File

@@ -1,152 +0,0 @@
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,7 +8,6 @@ import {
mdiPencil,
mdiPencilOff,
mdiPencilOutline,
mdiPlusBoxMultipleOutline,
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -61,7 +60,6 @@ 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";
@@ -75,7 +73,7 @@ export interface MoreInfoDialogParams {
data?: Record<string, any>;
}
type View = "info" | "history" | "settings" | "related" | "add_to";
type View = "info" | "history" | "settings" | "related";
interface ChildView {
viewTag: string;
@@ -196,10 +194,6 @@ 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
@@ -301,11 +295,6 @@ 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");
@@ -532,22 +521,6 @@ 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}
@@ -640,14 +613,7 @@ export class MoreInfoDialog extends LitElement {
: "entity"}
></ha-related-items>
`
: this._currView === "add_to"
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
></ha-more-info-add-to>
`
: nothing
: nothing
)}
</div>
`

View File

@@ -152,18 +152,10 @@ export class MoreInfoHistory extends LitElement {
}
}
private _setUpdateTimer() {
private _setRedrawTimer() {
// redraw the graph every minute to update the time axis
clearInterval(this._interval);
this._interval = window.setInterval(() => {
// If using statistics, refresh the data
if (this._statistics) {
this._fetchStatistics();
}
// If using history, redraw the graph to update the time axis
if (this._stateHistory) {
this._redrawGraph();
}
}, 1000 * 60);
this._interval = window.setInterval(() => this._redrawGraph(), 1000 * 60);
}
private async _getStatisticsMetaData(statisticIds: string[] | undefined) {
@@ -178,30 +170,6 @@ export class MoreInfoHistory extends LitElement {
return statisticsMetaData;
}
private async _fetchStatistics(): Promise<boolean> {
// Fire off the metadata and fetch at the same time
// to avoid waiting in sequence so the UI responds
// faster.
const _metadata = this._getStatisticsMetaData([this.entityId]);
const _statistics = fetchStatistics(
this.hass!,
subHours(new Date(), 24),
undefined,
[this.entityId],
"5minute",
undefined,
statTypes
);
const [metadata, statistics] = await Promise.all([_metadata, _statistics]);
if (metadata && Object.keys(metadata).length) {
this._metadata = metadata;
this._statistics = statistics;
this._statNames = { [this.entityId]: "" };
return true;
}
return false;
}
private async _getStateHistory(): Promise<void> {
if (
isComponentLoaded(this.hass, "recorder") &&
@@ -212,10 +180,27 @@ export class MoreInfoHistory extends LitElement {
// has not opted into statistics so there is no need to check as it
// requires another round-trip to the server.
if (stateObj && stateObj.attributes.state_class) {
const hasStatistics = await this._fetchStatistics();
if (hasStatistics) {
// Using statistics, set up refresh timer
this._setUpdateTimer();
// Fire off the metadata and fetch at the same time
// to avoid waiting in sequence so the UI responds
// faster.
const _metadata = this._getStatisticsMetaData([this.entityId]);
const _statistics = fetchStatistics(
this.hass!,
subHours(new Date(), 24),
undefined,
[this.entityId],
"5minute",
undefined,
statTypes
);
const [metadata, statistics] = await Promise.all([
_metadata,
_statistics,
]);
if (metadata && Object.keys(metadata).length) {
this._metadata = metadata;
this._statistics = statistics;
this._statNames = { [this.entityId]: "" };
return;
}
}
@@ -253,7 +238,7 @@ export class MoreInfoHistory extends LitElement {
this._error = err;
return undefined;
});
this._setUpdateTimer();
this._setRedrawTimer();
}
static styles = [

View File

@@ -36,13 +36,6 @@ 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: {
@@ -82,10 +75,6 @@ interface EMOutgoingMessageWithAnswer {
request: EMOutgoingMessageConfigGet;
response: ExternalConfig;
};
"entity/add_to/get_actions": {
request: EMOutgoingMessageEntityAddToGetActions;
response: ExternalEntityAddToActions;
};
}
interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage {
@@ -168,14 +157,6 @@ 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
@@ -196,8 +177,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageThemeUpdate
| EMOutgoingMessageThreadStoreInPlatformKeychain
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo;
| EMOutgoingMessageImprovConfigureDevice;
export interface EMIncomingMessageRestart {
id: number;
@@ -313,31 +293,18 @@ 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;
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[];
hasSettingsScreen: boolean;
hasSidebar: boolean;
canWriteTag: boolean;
hasExoPlayer: boolean;
canCommissionMatter: boolean;
canImportThreadCredentials: boolean;
canTransferThreadCredentialsToKeychain: boolean;
hasAssist: boolean;
hasBarCodeScanner: number;
canSetupImprov: boolean;
downloadFileSupported: boolean;
appVersion: string;
}
export class ExternalMessaging {

View File

@@ -97,9 +97,6 @@ export const ENTITY_COMPONENT_ICONS: Record<string, ComponentIcons> = {
pm25: {
default: "mdi:molecule",
},
pm4: {
default: "mdi:molecule",
},
power: {
default: "mdi:flash",
},
@@ -677,9 +674,6 @@ export const ENTITY_COMPONENT_ICONS: Record<string, ComponentIcons> = {
pm25: {
default: "mdi:molecule",
},
pm4: {
default: "mdi:molecule",
},
power: {
default: "mdi:flash",
},

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"),
security: () => import("../panels/security/ha-panel-security"),
safety: () => import("../panels/safety/ha-panel-safety"),
climate: () => import("../panels/climate/ha-panel-climate"),
};

View File

@@ -1,104 +0,0 @@
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

@@ -143,14 +143,9 @@ class DialogCalendarEventDetail extends LitElement {
this.hass.locale.time_zone,
this.hass.config.time_zone
);
// For all-day events (date-only strings), parse without timezone to avoid offset issues
const start = isDate(this._data!.dtstart)
? new Date(this._data!.dtstart + "T00:00:00")
: new TZDate(this._data!.dtstart, timeZone);
const endValue = isDate(this._data!.dtend)
? new Date(this._data!.dtend + "T00:00:00")
: new TZDate(this._data!.dtend, timeZone);
// All day event end dates are exclusive in iCalendar format, subtract one day for display
const start = new TZDate(this._data!.dtstart, timeZone);
const endValue = new TZDate(this._data!.dtend, timeZone);
// All day events should be displayed as a day earlier
const end = isDate(this._data.dtend) ? addDays(endValue, -1) : endValue;
// The range can be shortened when the start and end are on the same day.
if (isSameDay(start, end)) {

View File

@@ -332,15 +332,6 @@ class DialogCalendarEventEditor extends LitElement {
private _allDayToggleChanged(ev) {
this._allDay = ev.target.checked;
// When switching to all-day mode, normalize dates to midnight so time portions don't interfere with date comparisons
if (this._allDay && this._dtstart && this._dtend) {
this._dtstart = new Date(
formatDate(this._dtstart, this._timeZone!) + "T00:00:00"
);
this._dtend = new Date(
formatDate(this._dtend, this._timeZone!) + "T00:00:00"
);
}
}
private _startDateChanged(ev: CustomEvent) {

View File

@@ -1,23 +1,24 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } 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 { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
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_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "climate",
},
const CLIMATE_LOVELACE_CONFIG: LovelaceConfig = {
views: [
{
strategy: {
type: "climate",
},
},
],
};
@customElement("ha-panel-climate")
@@ -32,119 +33,65 @@ 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 && oldHass.localize !== this.hass.localize) {
if (oldHass?.locale !== this.hass.locale) {
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() {
protected render(): TemplateResult {
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>
${
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 .hass=${this.hass}>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`;
}
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;
}
private _setLovelace() {
this._lovelace = {
config: config,
rawConfig: rawConfig,
config: CLIMATE_LOVELACE_CONFIG,
rawConfig: CLIMATE_LOVELACE_CONFIG,
editMode: false,
urlPath: "climate",
mode: "generated",

View File

@@ -15,7 +15,6 @@ import {
getFloors,
} from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { getHomeStructure } from "../../lovelace/strategies/home/helpers/home-structure";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
export interface ClimateViewStrategyConfig {
type: "climate";
@@ -115,24 +114,6 @@ const processAreasForClimate = (
return cards;
};
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(hass, {
area: null,
});
const unassignedEntities = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, "", true);
for (const entityId of unassignedEntities) {
areaCards.push(computeTileCard(entityId));
}
return areaCards;
};
@customElement("climate-view-strategy")
export class ClimateViewStrategy extends ReactiveElement {
static async generate(
@@ -171,7 +152,6 @@ export class ClimateViewStrategy extends ReactiveElement {
floorCount > 1
? floor.name
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
icon: floor.icon || floorDefaultIcon(floor),
},
],
};
@@ -208,33 +188,10 @@ export class ClimateViewStrategy extends ReactiveElement {
}
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
sections.length > 0
? hass.localize(
"ui.panel.lovelace.strategy.climate.other_devices"
)
: hass.localize("ui.panel.lovelace.strategy.climate.devices"),
},
...unassignedCards,
],
};
sections.push(section);
}
return {
type: "sections",
max_columns: 2,
sections: sections,
sections: sections || [],
};
}
}

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-aliases-editor";
import "../../../components/ha-area-picker";
import "../../../components/ha-button";
import "../../../components/ha-aliases-editor";
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 { updateAreaRegistryEntry } from "../../../data/area_registry";
import "../../../components/ha-area-picker";
import type {
FloorRegistryEntry,
FloorRegistryEntryMutableParams,
} from "../../../data/floor_registry";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showAreaRegistryDetailDialog } from "./show-dialog-area-registry-detail";
import type { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail";
import { showAreaRegistryDetailDialog } from "./show-dialog-area-registry-detail";
import { updateAreaRegistryEntry } from "../../../data/area_registry";
class DialogFloorDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -168,6 +168,11 @@ 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(
@@ -192,17 +197,13 @@ class DialogFloorDetail extends LitElement {
</ha-input-chip>`
)}
</ha-chip-set>`
: html`<p class="description">
${this.hass.localize(
"ui.panel.config.floors.editor.areas_description"
)}
</p>`}
: nothing}
<ha-area-picker
no-add
.hass=${this.hass}
@value-changed=${this._addArea}
.excludeAreas=${areas.map((a) => a.area_id)}
.addButtonLabel=${this.hass.localize(
.label=${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).filter((item) => item.name);
return results.map((result) => result.item);
}
return items;
}
@@ -294,7 +294,7 @@ class DialogAddAutomationElement
const results = fuse.multiTermsSearch(filter);
if (results) {
return results.map((result) => result.item).filter((item) => item.name);
return results.map((result) => result.item);
}
return items;
}
@@ -383,16 +383,9 @@ class DialogAddAutomationElement
generatedCollections.push({
titleKey: collection.titleKey,
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);
}),
groups: groups.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
),
});
});
return generatedCollections;
@@ -685,7 +678,7 @@ class DialogAddAutomationElement
);
const typeTitle = this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.add`
`ui.panel.config.automation.editor.${automationElementType}s.header`
);
const tabButtons = [
@@ -902,9 +895,7 @@ class DialogAddAutomationElement
return html`
<div class="items-title ${this._itemsScrolled ? "scrolled" : ""}">
${this._tab === "blocks" && !this._filter
? this.hass.localize("ui.panel.config.automation.editor.blocks")
: title}
${title}
</div>
<ha-md-list
dialogInitialFocus=${ifDefined(this._fullScreen ? "" : undefined)}
@@ -1054,7 +1045,6 @@ class DialogAddAutomationElement
private _onSearchFocus(ev) {
this._removeKeyboardShortcuts = tinykeys(ev.target, {
ArrowDown: this._focusSearchList,
Enter: this._pickSingleItem,
});
}
@@ -1071,39 +1061,6 @@ 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`
@@ -1180,11 +1137,11 @@ class DialogAddAutomationElement
}
.groups .selected {
background-color: var(--ha-color-fill-primary-normal-active);
--md-list-item-label-text-color: var(--ha-color-on-primary-normal);
--icon-primary-color: var(--ha-color-on-primary-normal);
--md-list-item-label-text-color: var(--primary-color);
--icon-primary-color: var(--primary-color);
}
.groups .selected ha-svg-icon {
color: var(--ha-color-on-primary-normal);
color: var(--primary-color);
}
.collection-title {

View File

@@ -20,8 +20,7 @@ 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 { domainToName } from "../../../../data/integration";
import type { RepeatAction, ServiceAction } from "../../../../data/script";
import type { RepeatAction } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
@@ -84,23 +83,11 @@ export default class HaAutomationSidebarAction extends LitElement {
"ui.panel.config.automation.editor.actions.action"
);
let title =
const 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

@@ -3,7 +3,6 @@ import {
mdiContentCopy,
mdiContentCut,
mdiDelete,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiPlusCircleMultipleOutline,
@@ -41,8 +40,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _requestShowId = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
@@ -50,7 +47,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._requestShowId = false;
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
@@ -105,24 +101,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
</div>
</ha-md-menu-item>
${!this.yamlMode &&
!("id" in this.config.config) &&
!this._requestShowId
? html`<ha-md-menu-item
slot="menu-items"
.clickAction=${this._showTriggerId}
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>`
: nothing}
<ha-md-divider
slot="menu-items"
role="separator"
@@ -272,7 +250,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@value-changed=${this._valueChangedSidebar}
@yaml-changed=${this._yamlChangedSidebar}
.uiSupported=${this.config.uiSupported}
.showId=${this._requestShowId}
.yamlMode=${this.yamlMode}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
@@ -315,10 +292,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
fireEvent(this, "toggle-yaml-mode");
};
private _showTriggerId = () => {
this._requestShowId = true;
};
static styles = [sidebarEditorStyles, overflowStyles];
}

View File

@@ -29,8 +29,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
@property({ type: Boolean, attribute: "sidebar" }) public inSidebar = false;
@property({ type: Boolean, attribute: "show-id" }) public showId = false;
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
protected render() {
@@ -38,8 +36,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
const yamlMode = this.yamlMode || !this.uiSupported;
const showId = "id" in this.trigger || this.showId;
return html`
<div
class=${classMap({
@@ -74,15 +70,20 @@ export default class HaAutomationTriggerEditor extends LitElement {
></ha-yaml-editor>
`
: html`
${showId && !isTriggerList(this.trigger)
${!isTriggerList(this.trigger)
? html`
<ha-textfield
.label=${this.hass.localize(
.label=${`${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id"
)}
)} (${this.hass.localize(
"ui.panel.config.automation.editor.triggers.optional"
)})`}
.value=${this.trigger.id || ""}
.disabled=${this.disabled}
@change=${this._idChanged}
.helper=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.id_helper"
)}
></ha-textfield>
`
: nothing}

View File

@@ -19,7 +19,6 @@ import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { StateTrigger } from "../../../../../data/automation";
import { ANY_STATE_VALUE } from "../../../../../components/entity/const";
import type { HomeAssistant } from "../../../../../types";
import { baseTriggerStruct, forDictStruct } from "../../structs";
import type { TriggerElement } from "../ha-automation-trigger-row";
@@ -37,12 +36,14 @@ const stateTriggerStruct = assign(
trigger: literal("state"),
entity_id: optional(union([string(), array(string())])),
attribute: optional(string()),
from: optional(union([nullable(string()), array(string())])),
to: optional(union([nullable(string()), array(string())])),
from: optional(nullable(string())),
to: optional(nullable(string())),
for: optional(union([number(), string(), forDictStruct])),
})
);
const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";
@customElement("ha-automation-trigger-state")
export class HaStateTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -56,12 +57,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
attribute: string | undefined,
hideInFrom: string[],
hideInTo: string[]
) =>
(localize: LocalizeFunc, attribute) =>
[
{
name: "entity_id",
@@ -135,7 +131,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
selector: {
state: {
multiple: true,
extra_options: (attribute
? []
: [
@@ -147,7 +142,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
]) as any,
attribute: attribute,
hide_states: hideInFrom,
},
},
},
@@ -158,7 +152,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
selector: {
state: {
multiple: true,
extra_options: (attribute
? []
: [
@@ -170,7 +163,6 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
]) as any,
attribute: attribute,
hide_states: hideInTo,
},
},
},
@@ -215,15 +207,13 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
entity_id: ensureArray(this.trigger.entity_id),
for: trgFor,
};
data.to = this._normalizeStates(this.trigger.to, data.attribute);
data.from = this._normalizeStates(this.trigger.from, data.attribute);
const schema = this._schema(
this.hass.localize,
this.trigger.attribute,
data.to,
data.from
);
if (!data.attribute && data.to === null) {
data.to = ANY_STATE_VALUE;
}
if (!data.attribute && data.from === null) {
data.from = ANY_STATE_VALUE;
}
const schema = this._schema(this.hass.localize, this.trigger.attribute);
return html`
<ha-form
@@ -241,60 +231,22 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.to = this._applyAnyStateExclusive(
newTrigger.to,
newTrigger.attribute
);
if (Array.isArray(newTrigger.to) && newTrigger.to.length === 0) {
delete newTrigger.to;
if (newTrigger.to === ANY_STATE_VALUE) {
newTrigger.to = newTrigger.attribute ? undefined : null;
}
newTrigger.from = this._applyAnyStateExclusive(
newTrigger.from,
newTrigger.attribute
);
if (Array.isArray(newTrigger.from) && newTrigger.from.length === 0) {
delete newTrigger.from;
if (newTrigger.from === ANY_STATE_VALUE) {
newTrigger.from = newTrigger.attribute ? undefined : null;
}
Object.keys(newTrigger).forEach((key) => {
const val = newTrigger[key];
if (val === undefined || val === "") {
delete newTrigger[key];
}
});
Object.keys(newTrigger).forEach((key) =>
newTrigger[key] === undefined || newTrigger[key] === ""
? delete newTrigger[key]
: {}
);
fireEvent(this, "value-changed", { value: newTrigger });
}
private _applyAnyStateExclusive(
val: string | string[] | null | undefined,
attribute?: string
): string | string[] | null | undefined {
const anyStateSelected = Array.isArray(val)
? val.includes(ANY_STATE_VALUE)
: val === ANY_STATE_VALUE;
if (anyStateSelected) {
// Any state is exclusive: null if no attribute, undefined if attribute
return attribute ? undefined : null;
}
return val;
}
private _normalizeStates(
value: string | string[] | null | undefined,
attribute?: string
): string[] {
// If no attribute is selected and backend value is null,
// expose it as the special ANY state option in the UI.
if (!attribute && value === null) {
return [ANY_STATE_VALUE];
}
if (value === undefined || value === null) {
return [];
}
return ensureArray(value);
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>

View File

@@ -125,6 +125,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@query("#overflow-menu") private _overflowMenu?: HaMdMenu;
private _overflowBackup?: BackupContent;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
@@ -260,7 +262,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
type: "overflow-menu",
template: (backup) => html`
<ha-icon-button
.backup=${backup}
.selected=${backup}
.label=${this.hass.localize("ui.common.overflow_menu")}
.path=${mdiDotsVertical}
@click=${this._toggleOverflowMenu}
@@ -292,6 +294,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
this._overflowMenu.close();
return;
}
this._overflowBackup = ev.target.selected;
this._overflowMenu.anchorElement = ev.target;
this._overflowMenu.show();
};
@@ -569,17 +572,15 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
navigate(`/config/backup/details/${id}`);
}
private async _downloadBackup(ev): Promise<void> {
const backup = ev.parentElement.anchorElement.backup;
if (!backup) {
private async _downloadBackup(): Promise<void> {
if (!this._overflowBackup) {
return;
}
downloadBackup(this.hass, this, backup, this.config);
downloadBackup(this.hass, this, this._overflowBackup, this.config);
}
private async _deleteBackup(ev): Promise<void> {
const backup = ev.parentElement.anchorElement.backup;
if (!backup) {
private async _deleteBackup(): Promise<void> {
if (!this._overflowBackup) {
return;
}
@@ -595,9 +596,11 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
try {
await deleteBackup(this.hass, backup.backup_id);
if (this._selected.includes(backup.backup_id)) {
this._selected = this._selected.filter((id) => id !== backup.backup_id);
await deleteBackup(this.hass, this._overflowBackup.backup_id);
if (this._selected.includes(this._overflowBackup.backup_id)) {
this._selected = this._selected.filter(
(id) => id !== this._overflowBackup!.backup_id
);
}
} catch (err: any) {
showAlertDialog(this, {

View File

@@ -20,7 +20,6 @@ import { subscribeEntityRegistry } from "../../../data/entity_registry";
import type { UpdateEntity } from "../../../data/update";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-progress-ring";
@customElement("ha-config-updates")
class HaConfigUpdates extends SubscribeMixin(LitElement) {
@@ -57,29 +56,6 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
this._entities?.find((entity) => entity.entity_id === entityId)
);
private _renderUpdateProgress(entity: UpdateEntity) {
if (entity.attributes.update_percentage != null) {
return html`<ha-progress-ring
size="small"
.value=${entity.attributes.update_percentage}
.label=${this.hass.localize(
"ui.panel.config.updates.update_in_progress"
)}
></ha-progress-ring>`;
}
if (entity.attributes.in_progress) {
return html`<ha-spinner
size="small"
.ariaLabel=${this.hass.localize(
"ui.panel.config.updates.update_in_progress"
)}
></ha-spinner>`;
}
return html`<ha-icon-next></ha-icon-next>`;
}
protected render() {
if (!this.updateEntities?.length) {
return nothing;
@@ -130,9 +106,13 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
)}
></state-badge>
${this.narrow && entity.attributes.in_progress
? html`<div class="absolute">
${this._renderUpdateProgress(entity)}
</div>`
? html`<ha-spinner
class="absolute"
size="small"
.ariaLabel=${this.hass.localize(
"ui.panel.config.updates.update_in_progress"
)}
></ha-spinner>`
: nothing}
</div>
<span slot="headline"
@@ -148,9 +128,16 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
: nothing}
</span>
${!this.narrow
? html`<div slot="end">
${this._renderUpdateProgress(entity)}
</div>`
? entity.attributes.in_progress
? html`<div slot="end">
<ha-spinner
size="small"
.ariaLabel=${this.hass.localize(
"ui.panel.config.updates.update_in_progress"
)}
></ha-spinner>
</div>`
: html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
</ha-md-list-item>
`;
@@ -206,13 +193,13 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
div[slot="start"] {
position: relative;
}
div.absolute {
ha-spinner.absolute {
position: absolute;
left: 6px;
top: 6px;
}
state-badge.updating {
opacity: 0.2;
opacity: 0.5;
}
`,
];

View File

@@ -228,7 +228,7 @@ export class HaDeviceEntitiesCard extends LitElement {
addEntitiesToLovelaceView(
this,
this.hass,
computeCards(this.hass, entities, {
computeCards(this.hass.states, entities, {
title: this.deviceName,
}),
computeSection(entities, {

View File

@@ -5,7 +5,8 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import "../../../../components/ha-alert";
import "../../../../components/ha-area-picker";
import "../../../../components/ha-dialog";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-button";
import "../../../../components/ha-labels-picker";
import type { HaSwitch } from "../../../../components/ha-switch";
@@ -19,6 +20,8 @@ import type { DeviceRegistryDetailDialogParams } from "./show-dialog-device-regi
class DialogDeviceRegistryDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _nameByUser!: string;
@state() private _error?: string;
@@ -42,10 +45,15 @@ class DialogDeviceRegistryDetail extends LitElement {
this._areaId = this._params.device.area_id || "";
this._labels = this._params.device.labels || [];
this._disabledBy = this._params.device.disabled_by;
this._open = true;
await this.updateComplete;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -57,10 +65,12 @@ class DialogDeviceRegistryDetail extends LitElement {
}
const device = this._params.device;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${computeDeviceNameDisplay(device, this.hass)}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${computeDeviceNameDisplay(device, this.hass)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<div>
${this._error
@@ -68,6 +78,7 @@ class DialogDeviceRegistryDetail extends LitElement {
: ""}
<div class="form">
<ha-textfield
autofocus
.value=${this._nameByUser}
@input=${this._nameChanged}
.label=${this.hass.localize(
@@ -75,7 +86,6 @@ class DialogDeviceRegistryDetail extends LitElement {
)}
.placeholder=${device.name || ""}
.disabled=${this._submitting}
dialogInitialFocus
></ha-textfield>
<ha-area-picker
.hass=${this.hass}
@@ -131,22 +141,25 @@ class DialogDeviceRegistryDetail extends LitElement {
</div>
</div>
</div>
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.dialogs.device-registry-detail.update")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}

View File

@@ -9,7 +9,6 @@ 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,
@@ -20,7 +19,11 @@ import {
emptyFlowToGridSourceEnergyPreference,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import { isExternalStatistic } from "../../../../data/recorder";
import {
getDisplayUnit,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
@@ -44,6 +47,8 @@ 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;
@@ -76,6 +81,11 @@ export class DialogEnergyGridFlowSettings
: "stat_energy_to"
];
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
initialSourceId,
params.metadata
);
this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units;
@@ -93,6 +103,7 @@ 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 });
@@ -106,6 +117,10 @@ 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 =
@@ -231,15 +246,9 @@ 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`
)}
.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>`}
)} ${unitPriceSensor ? ` (${unitPriceSensor})` : ""}`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
@@ -332,6 +341,16 @@ 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

@@ -4,7 +4,6 @@ import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-button";
import type { ExtEntityRegistryEntry } from "../../../../../data/entity_registry";
import { removeEntityRegistryEntry } from "../../../../../data/entity_registry";
import { HELPERS_CRUD } from "../../../../../data/helpers_crud";
@@ -23,6 +22,7 @@ import "../../../helpers/forms/ha-schedule-form";
import "../../../helpers/forms/ha-timer-form";
import "../../../voice-assistants/entity-voice-settings";
import "../../entity-registry-settings-editor";
import "../../../../../components/ha-button";
import type { EntityRegistrySettingsEditor } from "../../entity-registry-settings-editor";
@customElement("entity-settings-helper-tab")
@@ -72,28 +72,22 @@ export class EntitySettingsHelperTab extends LitElement {
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
${this._item === null
? html`<ha-alert alert-type="info"
>${this.hass.localize(
"ui.dialogs.helper_settings.yaml_not_editable"
)}</ha-alert
>`
: nothing}
${!this._componentLoaded
? this.hass.localize(
"ui.dialogs.helper_settings.platform_not_loaded",
{ platform: this.entry.platform }
)
: html`
<span @value-changed=${this._valueChanged}>
${dynamicElement(`ha-${this.entry.platform}-form`, {
hass: this.hass,
item: this._item,
entry: this.entry,
disabled: this._item === null,
})}
</span>
`}
: this._item === null
? this.hass.localize("ui.dialogs.helper_settings.yaml_not_editable")
: html`
<span @value-changed=${this._valueChanged}>
${dynamicElement(`ha-${this.entry.platform}-form`, {
hass: this.hass,
item: this._item,
entry: this.entry,
})}
</span>
`}
<entity-registry-settings-editor
.hass=${this.hass}
.entry=${this.entry}
@@ -128,9 +122,6 @@ export class EntitySettingsHelperTab extends LitElement {
}
private _valueChanged(ev: CustomEvent): void {
if (this._item === null) {
return;
}
this._error = undefined;
this._item = ev.detail.value;
}
@@ -204,10 +195,6 @@ export class EntitySettingsHelperTab extends LitElement {
display: block;
padding: 0 !important;
}
ha-alert {
display: block;
margin-bottom: var(--ha-space-4);
}
.form {
padding: 20px 24px;
}

View File

@@ -784,7 +784,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
<ha-labels-picker
.hass=${this.hass}
.value=${this._labels}
.disabled=${!!this.disabled}
.disabled=${this.disabled}
@value-changed=${this._labelsChanged}
></ha-labels-picker>
${this._cameraPrefs

View File

@@ -17,8 +17,6 @@ class HaCounterForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
private _item?: Partial<Counter>;
@state() private _name!: string;
@@ -84,7 +82,6 @@ class HaCounterForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -94,7 +91,6 @@ class HaCounterForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
<ha-textfield
.value=${this._minimum}
@@ -104,7 +100,6 @@ class HaCounterForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.counter.minimum"
)}
.disabled=${this.disabled}
></ha-textfield>
<ha-textfield
.value=${this._maximum}
@@ -114,7 +109,6 @@ class HaCounterForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.counter.maximum"
)}
.disabled=${this.disabled}
></ha-textfield>
<ha-textfield
.value=${this._initial}
@@ -124,7 +118,6 @@ class HaCounterForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.counter.initial"
)}
.disabled=${this.disabled}
></ha-textfield>
<ha-expansion-panel
header=${this.hass.localize(
@@ -140,14 +133,12 @@ class HaCounterForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.counter.step"
)}
.disabled=${this.disabled}
></ha-textfield>
<div class="row">
<ha-switch
.checked=${this._restore}
.configValue=${"restore"}
@change=${this._valueChanged}
.disabled=${this.disabled}
>
</ha-switch>
<div>

View File

@@ -14,8 +14,6 @@ class HaInputBooleanForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
private _item?: InputBoolean;
@state() private _name!: string;
@@ -61,7 +59,6 @@ class HaInputBooleanForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -71,7 +68,6 @@ class HaInputBooleanForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
</div>
`;

View File

@@ -14,8 +14,6 @@ class HaInputButtonForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
@state() private _name!: string;
@state() private _icon!: string;
@@ -61,7 +59,6 @@ class HaInputButtonForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -71,7 +68,6 @@ class HaInputButtonForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
</div>
`;

View File

@@ -17,8 +17,6 @@ class HaInputDateTimeForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
private _item?: InputDateTime;
@state() private _name!: string;
@@ -75,7 +73,6 @@ class HaInputDateTimeForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -85,7 +82,6 @@ class HaInputDateTimeForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
<br />
${this.hass.localize("ui.dialogs.helper_settings.input_datetime.mode")}:
@@ -101,7 +97,6 @@ class HaInputDateTimeForm extends LitElement {
value="date"
.checked=${this._mode === "date"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
@@ -114,7 +109,6 @@ class HaInputDateTimeForm extends LitElement {
value="time"
.checked=${this._mode === "time"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
@@ -127,7 +121,6 @@ class HaInputDateTimeForm extends LitElement {
value="datetime"
.checked=${this._mode === "datetime"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
</div>

View File

@@ -18,8 +18,6 @@ class HaInputNumberForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
private _item?: Partial<InputNumber>;
@state() private _name!: string;
@@ -91,7 +89,6 @@ class HaInputNumberForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -101,7 +98,6 @@ class HaInputNumberForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
<ha-textfield
.value=${this._min}
@@ -112,7 +108,6 @@ class HaInputNumberForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.min"
)}
.disabled=${this.disabled}
></ha-textfield>
<ha-textfield
.value=${this._max}
@@ -123,7 +118,6 @@ class HaInputNumberForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.max"
)}
.disabled=${this.disabled}
></ha-textfield>
<ha-expansion-panel
header=${this.hass.localize(
@@ -145,7 +139,6 @@ class HaInputNumberForm extends LitElement {
value="slider"
.checked=${this._mode === "slider"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
@@ -158,7 +151,6 @@ class HaInputNumberForm extends LitElement {
value="box"
.checked=${this._mode === "box"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
</div>
@@ -171,7 +163,6 @@ class HaInputNumberForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.step"
)}
.disabled=${this.disabled}
></ha-textfield>
<ha-textfield
@@ -181,7 +172,6 @@ class HaInputNumberForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_number.unit_of_measurement"
)}
.disabled=${this.disabled}
></ha-textfield>
</ha-expansion-panel>
</div>

View File

@@ -23,8 +23,6 @@ class HaInputSelectForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
private _item?: InputSelect;
@state() private _name!: string;
@@ -88,7 +86,6 @@ class HaInputSelectForm extends LitElement {
)}
.configValue=${"name"}
@input=${this._valueChanged}
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -98,18 +95,13 @@ class HaInputSelectForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
<div class="header">
${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.options"
)}:
</div>
<ha-sortable
@item-moved=${this._optionMoved}
handle-selector=".handle"
.disabled=${this.disabled}
>
<ha-sortable @item-moved=${this._optionMoved} handle-selector=".handle">
<ha-list class="options">
${this._options.length
? repeat(
@@ -132,7 +124,6 @@ class HaInputSelectForm extends LitElement {
"ui.dialogs.helper_settings.input_select.remove_option"
)}
@click=${this._removeOption}
.disabled=${this.disabled}
.path=${mdiDelete}
></ha-icon-button>
</ha-list-item>
@@ -155,13 +146,8 @@ class HaInputSelectForm extends LitElement {
"ui.dialogs.helper_settings.input_select.add_option"
)}
@keydown=${this._handleKeyAdd}
.disabled=${this.disabled}
></ha-textfield>
<ha-button
size="small"
appearance="plain"
@click=${this._addOption}
.disabled=${this.disabled}
<ha-button size="small" appearance="plain" @click=${this._addOption}
>${this.hass!.localize(
"ui.dialogs.helper_settings.input_select.add"
)}</ha-button

View File

@@ -19,8 +19,6 @@ class HaInputTextForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
private _item?: InputText;
@state() private _name!: string;
@@ -81,7 +79,6 @@ class HaInputTextForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -91,7 +88,6 @@ class HaInputTextForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
<ha-expansion-panel
header=${this.hass.localize(
@@ -109,7 +105,6 @@ class HaInputTextForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.input_text.min"
)}
.disabled=${this.disabled}
></ha-textfield>
<ha-textfield
.value=${this._max}
@@ -134,7 +129,6 @@ class HaInputTextForm extends LitElement {
value="text"
.checked=${this._mode === "text"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
@@ -147,7 +141,6 @@ class HaInputTextForm extends LitElement {
value="password"
.checked=${this._mode === "password"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
</div>
@@ -161,7 +154,6 @@ class HaInputTextForm extends LitElement {
.helper=${this.hass!.localize(
"ui.dialogs.helper_settings.input_text.pattern_helper"
)}
.disabled=${this.disabled}
></ha-textfield>
</ha-expansion-panel>
</div>

View File

@@ -17,9 +17,9 @@ import "../../../../components/ha-textfield";
import type { Schedule, ScheduleDay } from "../../../../data/schedule";
import { weekdays } from "../../../../data/schedule";
import { TimeZone } from "../../../../data/translation";
import { showScheduleBlockInfoDialog } from "./show-dialog-schedule-block-info";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showScheduleBlockInfoDialog } from "./show-dialog-schedule-block-info";
const defaultFullCalendarConfig: CalendarOptions = {
plugins: [timeGridPlugin, interactionPlugin],
@@ -43,8 +43,6 @@ class HaScheduleForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
@state() private _name!: string;
@state() private _icon!: string;
@@ -134,7 +132,6 @@ class HaScheduleForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -144,9 +141,8 @@ class HaScheduleForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
${!this.disabled ? html`<div id="calendar"></div>` : nothing}
<div id="calendar"></div>
</div>
`;
}
@@ -179,9 +175,7 @@ class HaScheduleForm extends LitElement {
}
protected firstUpdated(): void {
if (!this.disabled) {
this._setupCalendar();
}
this._setupCalendar();
}
private _setupCalendar(): void {

View File

@@ -1,18 +1,18 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createDurationData } from "../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-checkbox";
import "../../../../components/ha-duration-input";
import type { HaDurationData } from "../../../../components/ha-duration-input";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-duration-input";
import "../../../../components/ha-textfield";
import type { ForDict } from "../../../../data/automation";
import type { DurationDict, Timer } from "../../../../data/timer";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { createDurationData } from "../../../../common/datetime/create_duration_data";
import type { HaDurationData } from "../../../../components/ha-duration-input";
import type { ForDict } from "../../../../data/automation";
@customElement("ha-timer-form")
class HaTimerForm extends LitElement {
@@ -20,8 +20,6 @@ class HaTimerForm extends LitElement {
@property({ type: Boolean }) public new = false;
@property({ type: Boolean }) public disabled = false;
private _item?: Timer;
@state() private _name!: string;
@@ -79,7 +77,6 @@ class HaTimerForm extends LitElement {
"ui.dialogs.helper_settings.required_error_msg"
)}
dialogInitialFocus
.disabled=${this.disabled}
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
@@ -89,13 +86,11 @@ class HaTimerForm extends LitElement {
.label=${this.hass!.localize(
"ui.dialogs.helper_settings.generic.icon"
)}
.disabled=${this.disabled}
></ha-icon-picker>
<ha-duration-input
.configValue=${"duration"}
.data=${this._duration_data}
@value-changed=${this._valueChanged}
.disabled=${this.disabled}
></ha-duration-input>
<ha-formfield
.label=${this.hass.localize(
@@ -106,7 +101,6 @@ class HaTimerForm extends LitElement {
.configValue=${"restore"}
.checked=${this._restore}
@click=${this._toggleRestore}
.disabled=${this.disabled}
>
</ha-checkbox>
</ha-formfield>
@@ -136,9 +130,6 @@ class HaTimerForm extends LitElement {
}
private _toggleRestore() {
if (this.disabled) {
return;
}
this._restore = !this._restore;
fireEvent(this, "value-changed", {
value: { ...this._item, restore: this._restore },

View File

@@ -107,8 +107,6 @@ 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}
@@ -188,7 +186,7 @@ class HaConfigInfo extends LitElement {
: nothing}
</ul>
</ha-card>
<ha-card outlined class="ohf ${isDark ? "dark" : ""}">
<ha-card outlined class="ohf">
<div>
${this.hass.localize("ui.panel.config.info.proud_part_of")}
</div>
@@ -348,10 +346,6 @@ class HaConfigInfo extends LitElement {
max-width: 250px;
}
.ohf.dark img {
color-scheme: dark;
}
.versions {
display: flex;
flex-direction: column;

View File

@@ -790,10 +790,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
);
const timeString = new Date().toISOString().replace(/:/g, "-");
const logFileName = `home-assistant_${integration}_${timeString}.log`;
const signedUrl = await getSignedPath(
this.hass,
getErrorLogDownloadUrl(this.hass)
);
const signedUrl = await getSignedPath(this.hass, getErrorLogDownloadUrl);
fileDownload(signedUrl.path, logFileName);
}

View File

@@ -82,7 +82,7 @@ class DialogLabelDetail
this.hass,
this._params.entry
? this._params.entry.name || this._params.entry.label_id
: this.hass!.localize("ui.dialogs.label-detail.new_label")
: this.hass!.localize("ui.panel.config.labels.detail.new_label")
)}
>
<div>
@@ -95,9 +95,11 @@ class DialogLabelDetail
.value=${this._name}
.configValue=${"name"}
@input=${this._input}
.label=${this.hass!.localize("ui.dialogs.label-detail.name")}
.label=${this.hass!.localize(
"ui.panel.config.labels.detail.name"
)}
.validationMessage=${this.hass!.localize(
"ui.dialogs.label-detail.required_error_msg"
"ui.panel.config.labels.detail.required_error_msg"
)}
required
></ha-textfield>
@@ -106,21 +108,25 @@ class DialogLabelDetail
.hass=${this.hass}
.configValue=${"icon"}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize("ui.dialogs.label-detail.icon")}
.label=${this.hass!.localize(
"ui.panel.config.labels.detail.icon"
)}
></ha-icon-picker>
<ha-color-picker
.value=${this._color}
.configValue=${"color"}
.hass=${this.hass}
@value-changed=${this._valueChanged}
.label=${this.hass!.localize("ui.dialogs.label-detail.color")}
.label=${this.hass!.localize(
"ui.panel.config.labels.detail.color"
)}
></ha-color-picker>
<ha-textarea
.value=${this._description}
.configValue=${"description"}
@input=${this._input}
.label=${this.hass!.localize(
"ui.dialogs.label-detail.description"
"ui.panel.config.labels.detail.description"
)}
></ha-textarea>
</div>
@@ -134,7 +140,7 @@ class DialogLabelDetail
@click=${this._deleteEntry}
.disabled=${this._submitting}
>
${this.hass!.localize("ui.common.delete")}
${this.hass!.localize("ui.panel.config.labels.detail.delete")}
</ha-button>
`
: nothing}
@@ -144,8 +150,8 @@ class DialogLabelDetail
.disabled=${this._submitting || !this._name}
>
${this._params.entry
? this.hass!.localize("ui.common.update")
: this.hass!.localize("ui.common.create")}
? this.hass!.localize("ui.panel.config.labels.detail.update")
: this.hass!.localize("ui.panel.config.labels.detail.create")}
</ha-button>
</ha-dialog>
`;

View File

@@ -415,7 +415,7 @@ class ErrorLogCard extends LitElement {
const downloadUrl =
this.provider && this.provider !== "core"
? getHassioLogDownloadUrl(this.provider)
: getErrorLogDownloadUrl(this.hass);
: getErrorLogDownloadUrl;
const logFileName =
this.provider && this.provider !== "core"
? `${this.provider}_${timeString}.log`

View File

@@ -2,6 +2,8 @@ import { mdiDotsVertical, mdiDownload, mdiRefresh, mdiText } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { atLeastVersion } from "../../../common/config/version";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/buttons/ha-call-service-button";
@@ -13,6 +15,7 @@ import "../../../components/ha-list-item";
import "../../../components/ha-spinner";
import { getSignedPath } from "../../../data/auth";
import { getErrorLogDownloadUrl } from "../../../data/error_log";
import { coreLatestLogsUrl } from "../../../data/hassio/supervisor";
import { domainToName } from "../../../data/integration";
import type { LoggedError } from "../../../data/system_log";
import {
@@ -228,7 +231,11 @@ export class SystemLogCard extends LitElement {
private async _downloadLogs() {
const timeString = new Date().toISOString().replace(/:/g, "-");
const downloadUrl = getErrorLogDownloadUrl(this.hass);
const downloadUrl =
isComponentLoaded(this.hass, "hassio") &&
atLeastVersion(this.hass.config.version, 2025, 10)
? coreLatestLogsUrl
: getErrorLogDownloadUrl;
const logFileName = `home-assistant_${timeString}.log`;
const signedUrl = await getSignedPath(this.hass, downloadUrl);
fileDownload(signedUrl.path, logFileName);

View File

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

Some files were not shown because too many files have changed in this diff Show More